Add design spec for Filtered Image block plugin

This commit is contained in:
Keith Solomon
2026-08-05 12:09:36 -05:00
commit c7b2c38eb9
@@ -0,0 +1,210 @@
# Filtered Image — WordPress Block Plugin
**Date:** 2026-08-05
**Status:** Approved (pending user review of this written spec)
**Author:** Brainstorming session
## Purpose
A WordPress plugin that lets users add Instagram-style visual filters to images in the block editor. Filters are applied purely in CSS at render time, so they are reversible, lightweight, and accessibility-safe.
## Goals
1. Let users pick a named filter preset and apply it to an image in the block editor.
2. Let users adjust the filter's intensity (0100%).
3. Keep the rendered image accessible — alt text always describes the original image content, not the visual styling.
4. Keep the plugin small, well-organized, and easy to maintain.
## Non-Goals
- Server-side image processing or pixel manipulation.
- Per-channel sliders (exposure, warmth, vignette). Out of scope for v1.
- Block Style variations on `core/image`. Out of scope for v1.
- Block Bindings source. Out of scope for v1.
- A "bake" action that flattens the filter to a real image. Out of scope for v1.
## Architecture
### Plugin shape
A standard modern Gutenberg block plugin using `wp-scripts` for the build.
```
image-filters/
├── image-filters.php # Plugin header + bootstrap
├── readme.txt
├── package.json # wp-scripts build config
├── src/
│ ├── index.js # Block registration entry point
│ ├── block.json # Block metadata
│ ├── edit.js # Editor component
│ ├── save.js # Static markup
│ ├── inspector.js # Filter panel: preset grid + intensity slider
│ ├── presets.js # Single source of truth for preset definitions
│ ├── hooks.js # Optional: filter presets extension point
│ └── styles/
│ ├── editor.scss # Editor-only styles
│ └── style.scss # Front-end + editor styles
├── build/ # Generated by wp-scripts build (gitignored)
└── languages/
```
### Why this shape
- `presets.js` is the single source of truth for what each preset is. The CSS file is the single source of truth for how each preset is rendered. Adding a new preset = one entry in `presets.js` + one CSS rule.
- No file is bigger than ~50 lines. Each file does one thing.
- The plugin builds with the standard `wp-scripts` workflow.
## Block Definition
**Block name:** `ksolo/image-filter`
**Title:** "Filtered Image"
**Category:** Media
**Icon:** a filter / camera icon
**Description:** "An image with an Instagram-style filter applied."
### Attributes
| Attribute | Type | Default | Purpose |
|--------------|---------|--------------|--------------------------------------------------------|
| `filter` | string | `"normal"` | Preset slug. One of the preset slugs defined in `presets.js`. |
| `intensity` | number | `100` | 0100. Intensity slider value. |
| `imageId` | number | `0` | Attachment ID (matches `core/image`). |
| `imageUrl` | string | `""` | Image URL (matches `core/image`). |
| `imageAlt` | string | `""` | Alt text (matches `core/image`). |
| `width` | number | `undefined` | Display width (matches `core/image`). |
| `height` | number | `undefined` | Display height (matches `core/image`). |
| `linkUrl` | string | `""` | Optional link target (matches `core/image`). |
| `caption` | string | `""` | Optional caption (matches `core/image`). |
### Supports
- `align: ['left', 'center', 'right', 'wide', 'full']` — same as core Image
- `anchor: true`
- `html: false`
- `spacing: { margin: true }`
We do not duplicate `duotone`, `filter`, or `title` — those are core Image's, and we don't add them.
### Transforms
- **To `core/image`:** copies `imageId`, `imageUrl`, `imageAlt`, `width`, `height`, `linkUrl`, `caption`. Drops `filter` and `intensity`.
- **From `core/image`:** copies the same 7 image attributes. Sets `filter: 'normal'`, `intensity: 100`.
## Components
### `presets.js`
The single source of truth for the preset list. Exports an array of `{ slug, label, color }` objects and a `DEFAULT_PRESET` constant.
```js
export const PRESETS = [
{ slug: 'normal', label: 'Normal', color: '#f0f0f0' },
{ slug: 'warm', label: 'Warm', color: '#f4a261' },
{ slug: 'cool', label: 'Cool', color: '#a8dadc' },
{ slug: 'vivid', label: 'Vivid', color: '#e63946' },
{ slug: 'fade', label: 'Fade', color: '#cdb4db' },
{ slug: 'mono', label: 'Mono', color: '#6c757d' },
{ slug: 'dramatic', label: 'Dramatic', color: '#1d3557' },
{ slug: 'sepia', label: 'Sepia', color: '#d4a373' },
];
export const DEFAULT_PRESET = 'normal';
```
### `block.json`
Standard Gutenberg block metadata. Attributes, supports, and transforms all declared here so PHP and JS both read the same source.
### `edit.js`
Thin editor component. Handles the MediaPlaceholder (image selection), renders the image with the filter class, defers to `Inspector` for the panel.
### `inspector.js`
The filter panel. Two children:
- A grid of `<button>` elements (one per preset), each styled as a colored tile with the preset label. The active preset has `aria-pressed="true"`. Keyboard-navigable.
- A `<RangeControl>` for intensity, labeled "Filter intensity."
### `save.js`
Three lines: `<figure>`, `<img>`, optional `<figcaption>`. Computes the CSS class from `attributes.filter` and the inline custom property from `attributes.intensity / 100`.
### `style.scss`
One rule per preset in the `PRESETS` array. Plus the intensity scaling logic via CSS custom property. The preset count is set by `PRESETS` in `presets.js`; the CSS file is updated to match.
## Data Flow
### In the editor (`edit.js`)
1. User selects image → attributes populated via the MediaPlaceholder.
2. User clicks a preset in the inspector → `setAttributes({ filter: 'dramatic' })`.
3. User drags the intensity slider → `setAttributes({ intensity: 80 })`.
4. The image's `<img>` element re-renders with the new `className` and `style`.
5. CSS rules in the editor enqueue handle the visual preview.
### In the saved HTML (`save.js`)
```html
<figure class="wp-block-ksolo-image-filter has-filter-dramatic" style="--filter-intensity:0.8;">
<img src="..." alt="..." width="..." height="..." />
<figcaption>...</figcaption> <!-- only if caption is set -->
</figure>
```
### On the front-end (`style.scss`)
The CSS contains one rule per preset. The intensity is a CSS custom property that scales the filter's amount:
```scss
.wp-block-ksolo-image-filter {
--filter-intensity: 1;
img { filter: none; }
}
.has-filter-warm { img { filter: ... } }
.has-filter-cool { img { filter: ... } }
/* ... etc */
```
The render is server-side static HTML — no client-side JS needed to display the filter.
## Error Handling and Edge Cases
1. **No image selected**`edit.js` shows the MediaPlaceholder. Standard core Image UX.
2. **Image deleted from media library** — The saved URL will 404. Same behavior as core Image; we don't solve it specially.
3. **User picks "Normal" preset**`filter: none`; intensity slider is a no-op multiplier.
4. **Intensity at 0** — The filter is `none` regardless of preset.
5. **Invalid filter value in saved HTML** — The CSS rule for that class doesn't exist; image renders unfiltered. Graceful degrade.
## Accessibility
- **Alt text flows through verbatim.** The saved `<img>` always has the `alt` attribute the user typed.
- **Inspector controls are Gutenberg's standard components**, which handle ARIA correctly.
- **The preset grid uses `<button>` elements** with `aria-pressed`, so screen readers announce the active state.
- **The intensity slider is a `<RangeControl>`** with a label.
- **The filter is presentational only** — it never affects the accessible name.
- **The "Normal" preset is always available** as a reset, so users can always revert to a known good state.
## Testing Strategy
1. **Unit tests** (Jest) for `presets.js` and small helpers (slug-to-class conversion, intensity scaling).
2. **Integration smoke test** (Playwright) — load the editor, insert the block, click each preset, verify the rendered class is correct.
3. **Manual accessibility checklist** — Lighthouse, axe-core, keyboard-only navigation.
4. **Block validation**`npm run test` from `wp-scripts` runs the JSON schema validation against the saved block markup.
## Performance
- CSS file is < 1KB before gzip.
- The image itself is unaltered.
- No extra HTTP requests.
- Intensity scaling is a single CSS variable. No per-instance style compilation.
## Security
No user input handled beyond the standard block attributes. No AJAX, no file uploads, no DB writes. The plugin inherits all sanitization from WordPress's block attribute system.
## Open Questions
None. (The spec was refined through a brainstorming session with the user; all decisions are locked.)