monorepo/native/wordpress/maple-icons-wp/CLAUDE.MD
2026-02-02 14:17:16 -05:00

827 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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