Add design spec for Filtered Image block plugin
This commit is contained in:
@@ -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 (0–100%).
|
||||
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` | 0–100. 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.)
|
||||
Reference in New Issue
Block a user