initial commit

This commit is contained in:
rodolfomartinez 2026-02-02 14:17:16 -05:00
parent e468202f95
commit 423b9a25fb
24 changed files with 6670 additions and 0 deletions

View file

@ -0,0 +1,827 @@
# CLAUDE.md — Maple Icons WordPress Plugin
## Quick Reference
| Document | When to Reference |
|----------|-------------------|
| **CLAUDE.md** (this file) | Architecture, file structure, build order |
| **SECURITY.md** | Writing ANY PHP code (same patterns as Maple Local Fonts) |
---
## Project Overview
Build a WordPress plugin called **Maple Icons** that:
1. Fetches open-source icon sets from CDN and stores them locally
2. Provides preset icon sets: Heroicons, Lucide, Feather, Phosphor, Material
3. Only ONE icon set can be active at a time (though multiple can be downloaded)
4. Offers a Gutenberg block "Maple Icons" for inserting icons
5. Icons use `currentColor` to inherit text color from Global Styles
**Key principle:** Download once, serve locally. Zero runtime external requests after initial fetch.
---
## Requirements
- **Minimum PHP:** 7.4
- **Minimum WordPress:** 6.5 (for block.json apiVersion 3 and Global Styles)
- **License:** GPL-2.0-or-later
---
## Preset Icon Sets
All icons fetched from jsdelivr CDN with pinned versions:
| Set | Package | Version | Styles | ~Icons | viewBox |
|-----|---------|---------|--------|--------|---------|
| Heroicons | `heroicons` | 2.1.1 | outline, solid, mini | 290 | 24×24 |
| Lucide | `lucide-static` | 0.303.0 | icons | 1,400 | 24×24 |
| Feather | `feather-icons` | 4.29.1 | icons | 280 | 24×24 |
| Phosphor | `@phosphor-icons/core` | 2.1.1 | regular, bold, light, thin, fill, duotone | 1,200× styles | 256×256 (normalize) |
| Material | `@material-design-icons/svg` | 0.14.13 | filled, outlined, round, sharp, two-tone | 2,500 | 24×24 |
### CDN URL Patterns
```
Heroicons: https://cdn.jsdelivr.net/npm/heroicons@2.1.1/24/outline/{name}.svg
Lucide: https://cdn.jsdelivr.net/npm/lucide-static@0.303.0/icons/{name}.svg
Feather: https://cdn.jsdelivr.net/npm/feather-icons@4.29.1/dist/icons/{name}.svg
Phosphor: https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2.1.1/assets/{style}/{name}.svg
Material: https://cdn.jsdelivr.net/npm/@material-design-icons/svg@0.14.13/{style}/{name}.svg
```
### SVG Normalization Required
| Set | Issue | Fix |
|-----|-------|-----|
| Phosphor | viewBox="0 0 256 256" | Normalize to 24×24 |
| Material | `fill="#000000"` hardcoded | Replace with `currentColor` |
| All | May have XML declarations, comments | Strip during save |
---
## User Flow
```
Admin visits Settings → Maple Icons (or plugin action link)
Sees list of preset icon sets with Download/Delete buttons
Clicks "Download" on Heroicons → progress bar → icons saved locally
Clicks "Set Active" to make Heroicons the active set
User inserts "Maple Icons" block in Gutenberg editor
Block shows + button → click → icon picker modal with search
User searches/filters → selects icon → SVG inserted inline
SVG uses currentColor → inherits from Global Styles
All icons served from local storage (wp-content/maple-icons/)
```
---
## File Structure
```
maple-icons-wp/
├── maple-icons.php # Main plugin file
├── index.php # Silence is golden
├── uninstall.php # Clean removal
├── readme.txt # WordPress.org readme
├── package.json # Block build config
├── includes/
│ ├── index.php
│ ├── class-mi-icon-sets.php # Preset icon set definitions
│ ├── class-mi-icon-registry.php # Icon management & search
│ ├── class-mi-downloader.php # CDN fetch & local storage
│ ├── class-mi-admin-page.php # Settings page
│ └── class-mi-ajax-handler.php # AJAX handlers
├── presets/ # Bundled manifests (icon names, tags)
│ ├── index.php
│ ├── heroicons.json
│ ├── lucide.json
│ ├── feather.json
│ ├── phosphor.json
│ └── material.json
├── build/ # Compiled block assets (generated)
│ ├── index.php
│ ├── index.js
│ ├── index.asset.php
│ └── style-index.css
├── src/ # Block source (for development)
│ ├── index.js # Block registration
│ ├── edit.js # Editor component
│ ├── save.js # Save component (inline SVG)
│ ├── icon-picker.js # Icon selection modal
│ ├── editor.scss # Editor-only styles
│ └── block.json # Block metadata
├── assets/
│ ├── index.php
│ ├── admin.css # Settings page styles
│ └── admin.js # Settings page JS (download progress, etc.)
└── languages/
├── index.php
└── maple-icons.pot
```
### Local Icon Storage
Icons are stored in:
```
wp-content/maple-icons/{set-slug}/{style}/{icon-name}.svg
```
Example:
```
wp-content/maple-icons/heroicons/outline/academic-cap.svg
wp-content/maple-icons/heroicons/solid/academic-cap.svg
wp-content/maple-icons/lucide/icons/activity.svg
wp-content/maple-icons/phosphor/regular/airplane.svg
```
Using `wp-content/maple-icons/` (not uploads) to avoid cleanup plugin interference.
---
## Class Responsibilities
### MI_Icon_Sets
Static definitions of all preset icon sets.
```php
class MI_Icon_Sets {
public static function get_all(): array;
public static function get(string $slug): ?array;
public static function get_cdn_url(string $slug, string $style, string $name): string;
public static function get_manifest_path(string $slug): string;
}
```
Returns structure:
```php
[
'slug' => 'heroicons',
'name' => 'Heroicons',
'version' => '2.1.1',
'license' => 'MIT',
'url' => 'https://heroicons.com',
'cdn_base' => 'https://cdn.jsdelivr.net/npm/heroicons@2.1.1/',
'styles' => [
'outline' => ['path' => '24/outline', 'label' => 'Outline'],
'solid' => ['path' => '24/solid', 'label' => 'Solid'],
'mini' => ['path' => '20/solid', 'label' => 'Mini'],
],
'default_style' => 'outline',
'viewbox' => '0 0 24 24',
'normalize' => false, // true for Phosphor (256→24)
'color_fix' => false, // true for Material (replace hardcoded colors)
]
```
### MI_Icon_Registry
Manages downloaded sets and provides icon search/retrieval.
```php
class MI_Icon_Registry {
public function get_downloaded_sets(): array;
public function get_active_set(): ?string;
public function set_active(string $slug): bool;
public function is_downloaded(string $slug): bool;
public function get_icons_for_set(string $slug, string $style): array;
public function search_icons(string $query, int $limit = 50): array;
public function get_icon_svg(string $slug, string $style, string $name): string|WP_Error;
public function delete_set(string $slug): bool;
}
```
### MI_Downloader
Handles fetching icons from CDN and storing locally.
```php
class MI_Downloader {
public function download_set(string $slug, callable $progress_callback = null): array|WP_Error;
public function download_icon(string $slug, string $style, string $name): string|WP_Error;
private function normalize_svg(string $svg, array $set_config): string;
private function get_local_path(string $slug, string $style, string $name): string;
}
```
### MI_Admin_Page
Settings page under Settings → Maple Icons.
- List all preset icon sets
- Show download status (downloaded/not downloaded)
- Download button with progress indicator
- Delete button for downloaded sets
- Radio buttons to select active set
- Preview sample icons from each downloaded set
### MI_Ajax_Handler
AJAX endpoints:
- `mi_download_set` — Download an icon set from CDN
- `mi_delete_set` — Delete a downloaded icon set
- `mi_set_active` — Set the active icon set
- `mi_search_icons` — Search icons in active set (for block picker)
- `mi_get_icon_svg` — Get specific icon SVG (for block)
---
## Settings Storage
```php
// Option name: maple_icons_settings
[
'active_set' => 'heroicons', // Slug of active set, or empty string
'downloaded_sets' => [
'heroicons' => [
'version' => '2.1.1',
'downloaded_at' => '2024-01-15 10:30:00',
'icon_count' => 876, // Total across all styles
],
'lucide' => [
'version' => '0.303.0',
'downloaded_at' => '2024-01-15 11:00:00',
'icon_count' => 1400,
],
],
]
```
---
## Manifest Format (presets/*.json)
Each preset ships with a manifest listing all icons:
```json
{
"slug": "heroicons",
"name": "Heroicons",
"version": "2.1.1",
"icons": [
{
"name": "academic-cap",
"tags": ["education", "graduation", "school", "hat"],
"category": "objects",
"styles": ["outline", "solid", "mini"]
},
{
"name": "adjustments-horizontal",
"tags": ["settings", "controls", "sliders"],
"category": "ui",
"styles": ["outline", "solid", "mini"]
}
]
}
```
Manifests are bundled with the plugin to avoid runtime dependency on jsdelivr API.
---
## Gutenberg Block Architecture
### Block Registration (block.json)
```json
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "jetrails/maple-icons",
"version": "1.0.0",
"title": "Maple Icons",
"category": "design",
"icon": "star-filled",
"description": "Insert an icon from your downloaded icon sets.",
"keywords": ["icon", "svg", "symbol", "maple"],
"textdomain": "maple-icons",
"attributes": {
"iconSet": {
"type": "string",
"default": ""
},
"iconStyle": {
"type": "string",
"default": ""
},
"iconName": {
"type": "string",
"default": ""
},
"iconSVG": {
"type": "string",
"default": ""
},
"size": {
"type": "number",
"default": 24
},
"label": {
"type": "string",
"default": ""
},
"strokeWidth": {
"type": "number",
"default": 0
},
"strokeColor": {
"type": "string",
"default": ""
}
},
"supports": {
"html": false,
"align": ["left", "center", "right"],
"color": {
"text": true,
"background": true,
"gradients": true
},
"spacing": {
"margin": true,
"padding": true
},
"shadow": true
},
"editorScript": "file:./index.js",
"editorStyle": "file:./style-index.css"
}
```
### Block Style Controls (Inspector Panel)
The block sidebar will include these controls:
**Icon Settings:**
- Icon picker button (change icon)
- Size slider (8-256px, default 24)
- Accessibility label text input
**Color Settings (via WordPress supports):**
- Icon color (text color) — inherited by SVG via `currentColor`
- Background color
- Gradient support
**Stroke Settings (custom panel):**
- Stroke width slider (0-10px)
- Stroke color picker
**Spacing (via WordPress supports):**
- Padding controls
- Margin controls
**Effects:**
- Drop shadow presets (via WordPress `shadow` support)
### Style Application
```jsx
// In edit.js and save.js, compute styles:
const iconStyles = {
width: `${size}px`,
height: `${size}px`,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
};
// Stroke is applied via CSS filter or SVG manipulation
const svgStyles = strokeWidth > 0 ? {
filter: `drop-shadow(0 0 0 ${strokeColor})`,
WebkitTextStroke: `${strokeWidth}px ${strokeColor}`,
} : {};
// Note: For true SVG stroke, we modify the SVG's stroke attribute
// This is handled in the SVG wrapper, not CSS
```
### SVG Stroke Handling
For stroke on SVG icons, we wrap the SVG output:
```jsx
// The SVG itself uses currentColor for fill/stroke
// Additional stroke effect applied via CSS or inline style
<span
className="wp-block-maple-icon__svg"
style={{
// Apply stroke via paint-order and stroke properties
'--mi-stroke-width': strokeWidth ? `${strokeWidth}px` : undefined,
'--mi-stroke-color': strokeColor || undefined,
}}
dangerouslySetInnerHTML={{ __html: iconSVG }}
/>
```
With CSS:
```css
.wp-block-maple-icon__svg svg {
stroke: var(--mi-stroke-color, currentColor);
stroke-width: var(--mi-stroke-width, 0);
paint-order: stroke fill;
}
```
### Block Behavior
**Initial State (no icon selected):**
- Shows + button placeholder
- Click opens icon picker modal
**With icon selected:**
- Displays inline SVG
- Sidebar shows: size control, accessibility label, change icon button
**Icon Picker Modal:**
- Search input (filters as you type)
- Style selector dropdown (if set has multiple styles)
- Grid of icon thumbnails
- Click to select → SVG fetched and stored in attributes
### Save Output (save.js)
```jsx
export default function save({ attributes }) {
const { iconSVG, size, label } = attributes;
if (!iconSVG) {
return null;
}
const blockProps = useBlockProps.save({
className: 'wp-block-maple-icon',
style: {
width: `${size}px`,
height: `${size}px`,
display: 'inline-flex',
},
});
return (
<span {...blockProps}>
<span
dangerouslySetInnerHTML={{ __html: iconSVG }}
role={label ? 'img' : 'presentation'}
aria-label={label || undefined}
aria-hidden={!label ? 'true' : undefined}
/>
</span>
);
}
```
---
## Download Process
### Batch Download Flow
1. User clicks "Download" for an icon set
2. Frontend disables button, shows progress bar
3. AJAX request to `mi_download_set`
4. Backend:
a. Load manifest from `presets/{slug}.json`
b. Create local directory structure
c. For each icon in manifest:
- Fetch SVG from CDN
- Normalize (viewBox, currentColor)
- Save to local filesystem
d. Update settings with download info
5. Return success with icon count
6. Frontend updates UI to show "Downloaded" state
### Batching Strategy
To avoid timeouts and memory issues:
- Process icons in batches of 50
- Use streaming/chunked approach
- Allow resume on failure (track progress in transient)
```php
// Download in batches
$batch_size = 50;
$icons = $manifest['icons'];
$total = count($icons);
for ($i = 0; $i < $total; $i += $batch_size) {
$batch = array_slice($icons, $i, $batch_size);
foreach ($batch as $icon) {
// Download each icon in batch
}
// Update progress transient
set_transient('mi_download_progress_' . $slug, [
'completed' => min($i + $batch_size, $total),
'total' => $total,
], HOUR_IN_SECONDS);
}
```
---
## SVG Sanitization
Same approach as original CLAUDE.md — strict allowlist of SVG elements and attributes.
```php
function mi_sanitize_svg($svg) {
$allowed_tags = [
'svg' => ['xmlns', 'viewbox', 'width', 'height', 'fill', 'stroke', ...],
'path' => ['d', 'fill', 'stroke', 'stroke-width', ...],
'circle' => ['cx', 'cy', 'r', 'fill', 'stroke', ...],
'rect' => ['x', 'y', 'width', 'height', 'rx', 'ry', ...],
'line' => ['x1', 'y1', 'x2', 'y2', ...],
'polyline' => ['points', ...],
'polygon' => ['points', ...],
'ellipse' => ['cx', 'cy', 'rx', 'ry', ...],
'g' => ['fill', 'stroke', 'transform', ...],
];
// Remove dangerous content
$svg = preg_replace('/<script\b[^>]*>.*?<\/script>/is', '', $svg);
$svg = preg_replace('/\son\w+\s*=/i', ' data-removed=', $svg);
return wp_kses($svg, $allowed_tags);
}
```
### SVG Normalization
```php
function mi_normalize_svg($svg, $set_config) {
// 1. Strip XML declaration
$svg = preg_replace('/<\?xml[^>]*\?>/i', '', $svg);
// 2. Strip comments
$svg = preg_replace('/<!--.*?-->/s', '', $svg);
// 3. Normalize viewBox for Phosphor (256 → 24)
if ($set_config['normalize']) {
$svg = preg_replace('/viewBox=["\']0 0 256 256["\']/', 'viewBox="0 0 24 24"', $svg);
}
// 4. Fix hardcoded colors for Material
if ($set_config['color_fix']) {
$svg = preg_replace('/fill=["\']#[0-9a-fA-F]{3,6}["\']/', 'fill="currentColor"', $svg);
$svg = preg_replace('/fill=["\']black["\']/', 'fill="currentColor"', $svg);
}
// 5. Ensure currentColor is used
// Most sets already use currentColor, but double-check
// 6. Remove width/height attributes (let CSS control)
$svg = preg_replace('/\s(width|height)=["\'][^"\']*["\']/', '', $svg);
return trim($svg);
}
```
---
## Admin Settings Page UI
```
┌─────────────────────────────────────────────────────────────────┐
│ Maple Icons │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ICON SETS │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ◉ Heroicons ✓ Downloaded │ │
│ │ 290 icons · Outline, Solid, Mini · MIT │ │
│ │ [Preview: ⚙ 🏠 👤 ✉ 🔔 ⭐] │ │
│ │ [Delete] │ │
│ ├─────────────────────────────────────────────────────────────┤ │
│ │ ○ Lucide ✓ Downloaded │ │
│ │ 1,400 icons · Icons · ISC │ │
│ │ [Preview: ⚙ 📁 📄 💾 🖨 ⬇] │ │
│ │ [Delete] │ │
│ ├─────────────────────────────────────────────────────────────┤ │
│ │ ○ Feather Not downloaded │ │
│ │ 280 icons · Icons · MIT │ │
│ │ [Download] │ │
│ ├─────────────────────────────────────────────────────────────┤ │
│ │ ○ Phosphor Not downloaded │ │
│ │ 7,200 icons · 6 styles · MIT │ │
│ │ [Download] │ │
│ ├─────────────────────────────────────────────────────────────┤ │
│ │ ○ Material Not downloaded │ │
│ │ 2,500 icons · 5 styles · Apache 2.0 │ │
│ │ [Download] │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ◉ = Active icon set (used in Maple Icons block) │
│ │
│ ─────────────────────────────────────────────────────────────── │
│ │
│ USAGE │
│ 1. Download one or more icon sets above │
│ 2. Select which set should be active (radio button) │
│ 3. Insert icons using the "Maple Icons" block in the editor │
│ 4. Icons inherit your theme's text color automatically │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## Icon Picker Modal (Block Editor)
```
┌─────────────────────────────────────────────────────────────────┐
│ Select Icon [X] │
├─────────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 🔍 Search icons... │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ Style: [Outline ▼] │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │ │
│ │ │ ⚙ │ │ 🏠 │ │ 👤 │ │ ✉ │ │ 🔔 │ │ ⭐ │ │ ❤ │ │ 🔍 │ │ │
│ │ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘ │ │
│ │ cog home user mail bell star heart search │ │
│ │ │ │
│ │ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │ │
│ │ │ + │ │ ✓ │ │ ✕ │ │ ⬆ │ │ ⬇ │ │ ◀ │ │ ▶ │ │ ↻ │ │ │
│ │ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘ │ │
│ │ plus check x up down left right refresh │ │
│ │ │ │
│ │ [Load More...] │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ Selected: arrow-right │
│ │
│ [Cancel] [Insert Icon] │
└─────────────────────────────────────────────────────────────────┘
```
---
## Build Order
### Phase 1: Foundation
1. `maple-icons.php` — Plugin header, constants, autoloader, activation hook
2. `index.php` files in ALL directories
3. `includes/class-mi-icon-sets.php` — Static preset definitions
### Phase 2: Download Infrastructure
4. `includes/class-mi-downloader.php` — CDN fetch, normalize, store
5. `includes/class-mi-icon-registry.php` — Downloaded set management
6. `includes/class-mi-ajax-handler.php` — Download/delete/activate endpoints
7. Create manifest files: `presets/*.json`
### Phase 3: Admin Interface
8. `includes/class-mi-admin-page.php` — Settings page
9. `assets/admin.css` — Settings page styles
10. `assets/admin.js` — Download progress, AJAX handlers
### Phase 4: Gutenberg Block
11. `src/block.json` — Block metadata
12. `src/index.js` — Block registration
13. `src/edit.js` — Editor component
14. `src/save.js` — Save component
15. `src/icon-picker.js` — Modal component
16. `src/editor.scss` — Editor styles
17. `package.json` + build
### Phase 5: Cleanup & Polish
18. `uninstall.php` — Clean removal
19. `readme.txt` — WordPress.org readme
20. Testing
---
## Constants
```php
define('MI_VERSION', '1.0.0');
define('MI_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('MI_PLUGIN_URL', plugin_dir_url(__FILE__));
define('MI_PLUGIN_BASENAME', plugin_basename(__FILE__));
define('MI_ICONS_DIR', WP_CONTENT_DIR . '/maple-icons/');
define('MI_ICONS_URL', content_url('/maple-icons/'));
define('MI_PRESETS_DIR', MI_PLUGIN_DIR . 'presets/');
// Limits
define('MI_DOWNLOAD_BATCH_SIZE', 50);
define('MI_SEARCH_LIMIT', 50);
define('MI_DOWNLOAD_TIMEOUT', 10); // Per-icon timeout in seconds
```
---
## Security Checklist
Reference SECURITY.md for full details. Key points:
- [ ] ABSPATH check on every PHP file
- [ ] index.php in every directory
- [ ] Nonce verification FIRST in every AJAX handler
- [ ] Capability check SECOND (`manage_options` for settings, `edit_posts` for block)
- [ ] Validate icon set slugs against preset allowlist
- [ ] Validate style slugs against set's defined styles
- [ ] Sanitize all SVG content before storage
- [ ] Validate file paths (prevent path traversal)
- [ ] Use `wp_remote_get()` for CDN requests
- [ ] Escape all output
---
## WordPress Hooks Used
```php
// Activation/Deactivation
register_activation_hook(__FILE__, 'mi_activate');
register_deactivation_hook(__FILE__, 'mi_deactivate');
// Block registration
add_action('init', 'mi_register_block');
// Admin menu
add_action('admin_menu', 'mi_register_settings_page');
// Admin assets (settings page only)
add_action('admin_enqueue_scripts', 'mi_enqueue_admin_assets');
// Plugin action links (Settings link in plugin list)
add_filter('plugin_action_links_' . MI_PLUGIN_BASENAME, 'mi_add_action_links');
// AJAX handlers (admin only)
add_action('wp_ajax_mi_download_set', 'mi_ajax_download_set');
add_action('wp_ajax_mi_delete_set', 'mi_ajax_delete_set');
add_action('wp_ajax_mi_set_active', 'mi_ajax_set_active');
add_action('wp_ajax_mi_search_icons', 'mi_ajax_search_icons');
add_action('wp_ajax_mi_get_icon_svg', 'mi_ajax_get_icon_svg');
// WooCommerce HPOS compatibility
add_action('before_woocommerce_init', 'mi_declare_hpos_compatibility');
```
---
## Compatibility Notes
### WooCommerce HPOS
Declare compatibility (we don't touch orders):
```php
add_action('before_woocommerce_init', function() {
if (class_exists(\Automattic\WooCommerce\Utilities\FeaturesUtil::class)) {
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility(
'custom_order_tables',
__FILE__,
true
);
}
});
```
### Caching Plugins
Icons stored locally means no cache issues. SVG is inline in post_content.
### Wordfence
CDN requests only happen during admin download action, using standard `wp_remote_get()`.
### LearnDash / WPForms
No interference — we only add a block, no frontend hooks.
---
## Testing Checklist
### Download Flow
- [ ] Download Heroicons → all icons saved locally
- [ ] Download progress shows correctly
- [ ] Download Lucide (large set) → doesn't timeout
- [ ] Delete downloaded set → files removed
- [ ] Switch active set → setting persists
### Block Editor
- [ ] Insert Maple Icons block → shows placeholder
- [ ] Click + → opens icon picker
- [ ] Search filters icons correctly
- [ ] Style dropdown works (for sets with multiple styles)
- [ ] Select icon → SVG appears in editor
- [ ] Save post → icon persists
- [ ] Frontend → icon displays correctly
- [ ] Change text color in Global Styles → icon color changes
### Security
- [ ] Download without nonce → 403
- [ ] Download as non-admin → 403
- [ ] Invalid set slug → rejected
- [ ] Path traversal attempt → rejected
### Edge Cases
- [ ] No sets downloaded → block shows helpful message
- [ ] Active set deleted → block handles gracefully
- [ ] CDN unreachable during download → appropriate error
- [ ] Partial download failure → can retry/resume