diff --git a/docs/superpowers/plans/2026-08-05-image-filters.md b/docs/superpowers/plans/2026-08-05-image-filters.md new file mode 100644 index 0000000..04e7506 --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-image-filters.md @@ -0,0 +1,1435 @@ +# Filtered Image Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a WordPress plugin that registers a `ksolo/image-filter` block. Users pick from 8 Instagram-style filter presets and adjust intensity in the editor's sidebar inspector. Filters are applied purely in CSS at render time, keeping alt text and accessibility intact. + +**Architecture:** A new Gutenberg block that renders a normal `` with a CSS class (the preset slug) and a CSS custom property (the intensity). Single source of truth for presets lives in `presets.js`; the matching CSS rules live in `style.scss`. Transforms to/from `core/image` so users can round-trip between filtered and unfiltered. + +**Tech Stack:** WordPress 6.4+, Gutenberg block editor, `@wordpress/scripts` (webpack + Babel + SCSS), Jest (unit tests), Playwright (editor integration — optional), Playwright's `axe-core` integration (a11y verification). + +## Global Constraints + +- Plugin slug: `image-filters` +- Plugin text domain: `image-filters` +- Block namespace: `ksolo` +- Block name: `ksolo/image-filter` +- Node version: 18+ +- WordPress version floor: 6.4 (block.json v3) +- All JS modules use ES2022 syntax (`export const`, `import`). +- All SCSS uses BEM-flavored class names. No `@use`/`@import` deprecation. +- All user-facing strings pass through `__()` / `_e()` with the `image-filters` text domain. +- Never store filtered-pixel images. Always render the original URL. +- PRESETS array is the canonical source of preset metadata. SCSS rules must be a 1:1 match. +- The plugin header comment in `image-filters.php` must include: `Plugin Name`, `Requires at least: 6.4`, `Requires PHP: 7.4`, `License: GPL-2.0-or-later`, `Text Domain: image-filters`. + +--- + +## Task 1: Bootstrap the plugin + +**Files:** +- Create: `image-filters.php` +- Create: `readme.txt` +- Create: `package.json` +- Create: `.gitignore` +- Create: `jest.config.js` +- Create: `tests/jest/setup.js` + +**Interfaces:** +- Consumes: nothing +- Produces: an installable, testable plugin scaffold. Subsequent tasks build on `package.json` scripts and `image-filters.php` plugin header. + +- [ ] **Step 1: Write `image-filters.php` with the plugin header and a no-op bootstrap** + +```php +/tests/jest/setup.js' ], +}; +``` + +- [ ] **Step 7: Write `tests/jest/setup.js`** + +```js +// Minimal setup. Test files import from '@wordpress/blocks' etc. and +// get the standard Jest config from wp-scripts. +``` + +- [ ] **Step 8: Install dependencies** + +Run: `cd "C:\Users\ksolo\Herd\basic-wp\wp-content\plugins\ImageFilters" && npm install` + +Expected: `node_modules` directory is created. `npx wp-scripts --version` prints a version. + +- [ ] **Step 9: Verify the empty build runs** + +Run: `cd "C:\Users\ksolo\Herd\basic-wp\wp-content\plugins\ImageFilters" && npm run build` + +Expected: The command finishes without errors. A `build/` directory may not be created yet (no source files) — that's fine. If a warning appears about no entry points, document it; we'll add `src/index.js` in Task 5. + +- [ ] **Step 10: Commit** + +```bash +cd "C:\Users\ksolo\Herd\basic-wp\wp-content\plugins\ImageFilters" +git add image-filters.php readme.txt package.json .gitignore jest.config.js tests/jest/setup.js package-lock.json +git commit -m "Bootstrap image-filters plugin with wp-scripts and jest" +``` + +--- + +## Task 2: Define presets as the single source of truth + +**Files:** +- Create: `src/presets.js` +- Create: `tests/jest/presets.test.js` + +**Interfaces:** +- Consumes: nothing +- Produces: `PRESETS` (array of `{ slug, label, color, cssFilter }`) and `DEFAULT_PRESET` (string). Consumers: `block.json` (via JS), `inspector.js`, `style.scss` (via the `cssFilter` value referenced in the SCSS comments). + +- [ ] **Step 1: Write the failing test** + +```js +// tests/jest/presets.test.js +import { PRESETS, DEFAULT_PRESET, isValidPresetSlug } from '../../src/presets'; + +describe( 'presets', () => { + test( 'exports 8 presets', () => { + expect( PRESETS ).toHaveLength( 8 ); + } ); + + test( 'each preset has the required shape', () => { + for ( const preset of PRESETS ) { + expect( preset ).toEqual( + expect.objectContaining( { + slug: expect.stringMatching( /^[a-z-]+$/ ), + label: expect.any( String ), + color: expect.stringMatching( /^#[0-9a-f]{3,6}$/i ), + cssFilter: expect.any( String ), + } ) + ); + } + } ); + + test( 'slugs are unique', () => { + const slugs = PRESETS.map( ( p ) => p.slug ); + expect( new Set( slugs ).size ).toBe( slugs.length ); + } ); + + test( 'normal preset has filter: none', () => { + const normal = PRESETS.find( ( p ) => p.slug === 'normal' ); + expect( normal.cssFilter ).toBe( 'none' ); + } ); + + test( 'DEFAULT_PRESET is "normal" and exists in PRESETS', () => { + expect( DEFAULT_PRESET ).toBe( 'normal' ); + expect( PRESETS.map( ( p ) => p.slug ) ).toContain( DEFAULT_PRESET ); + } ); + + test( 'isValidPresetSlug returns true for known slugs', () => { + expect( isValidPresetSlug( 'normal' ) ).toBe( true ); + expect( isValidPresetSlug( 'dramatic' ) ).toBe( true ); + } ); + + test( 'isValidPresetSlug returns false for unknown slugs', () => { + expect( isValidPresetSlug( 'foobar' ) ).toBe( false ); + expect( isValidPresetSlug( '' ) ).toBe( false ); + } ); +} ); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd "C:\Users\ksolo\Herd\basic-wp\wp-content\plugins\ImageFilters" && npm run test:unit -- presets.test.js` + +Expected: FAIL with `Cannot find module '../../src/presets'`. + +- [ ] **Step 3: Write `src/presets.js`** + +```js +/** + * Single source of truth for filter presets. + * + * The CSS rules in src/styles/style.scss MUST match this list one-to-one. + * Adding a preset = add an entry here + add a matching .has-filter- + * rule in style.scss. Do not duplicate filter values elsewhere. + * + * @package ImageFilters + */ + +export const PRESETS = [ + { + slug: 'normal', + label: 'Normal', + color: '#f0f0f0', + cssFilter: 'none', + }, + { + slug: 'warm', + label: 'Warm', + color: '#f4a261', + cssFilter: 'saturate(1.25) sepia(0.18) brightness(1.05) contrast(1.05)', + }, + { + slug: 'cool', + label: 'Cool', + color: '#a8dadc', + cssFilter: 'saturate(0.95) hue-rotate(-10deg) brightness(1.02) contrast(1.05)', + }, + { + slug: 'vivid', + label: 'Vivid', + color: '#e63946', + cssFilter: 'saturate(1.6) contrast(1.15) brightness(1.03)', + }, + { + slug: 'fade', + label: 'Fade', + color: '#cdb4db', + cssFilter: 'saturate(0.85) contrast(0.9) brightness(1.08) sepia(0.08)', + }, + { + slug: 'mono', + label: 'Mono', + color: '#6c757d', + cssFilter: 'grayscale(1) contrast(1.05)', + }, + { + slug: 'dramatic', + label: 'Dramatic', + color: '#1d3557', + cssFilter: 'contrast(1.35) saturate(1.15) brightness(0.92)', + }, + { + slug: 'sepia', + label: 'Sepia', + color: '#d4a373', + cssFilter: 'sepia(0.85) saturate(1.1) contrast(1.05)', + }, +]; + +export const DEFAULT_PRESET = 'normal'; + +const VALID_SLUGS = new Set( PRESETS.map( ( p ) => p.slug ) ); + +/** + * Returns true if the given slug is a known preset. + * + * @param {string} slug + * @return {boolean} + */ +export function isValidPresetSlug( slug ) { + return VALID_SLUGS.has( slug ); +} +``` + +The exact `cssFilter` values are deliberately chosen to be printable copy. The reviewer may tune them post-merge; the contract is the structure, not the precise hue-rotate. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd "C:\Users\ksolo\Herd\basic-wp\wp-content\plugins\ImageFilters" && npm run test:unit -- presets.test.js` + +Expected: PASS, 7 tests passing. + +- [ ] **Step 5: Commit** + +```bash +cd "C:\Users\ksolo\Herd\basic-wp\wp-content\plugins\ImageFilters" +git add src/presets.js tests/jest/presets.test.js +git commit -m "Add presets.js as single source of truth for filter definitions" +``` + +--- + +## Task 3: Style the presets in CSS + +**Files:** +- Create: `src/styles/style.scss` +- Create: `src/styles/editor.scss` + +**Interfaces:** +- Consumes: `src/presets.js` (the SCSS file lists the same 8 slugs in the same order; this is a 1:1 contract) +- Produces: Front-end + editor-facing CSS classes that pick up the `className` from the block's saved markup. + +- [ ] **Step 1: Write `src/styles/style.scss`** + +```scss +/** + * Filtered Image block — front-end and editor styles. + * + * The .has-filter- classes below MUST stay in sync with PRESETS in + * src/presets.js. The cssFilter value in presets.js is the source of truth; + * the rules below reproduce it. If they diverge, treat it as a bug. + * + * @package ImageFilters + */ + +.wp-block-ksolo-image-filter { + --filter-intensity: 1; + display: inline-block; + margin: 0; + + img { + filter: none; + max-width: 100%; + height: auto; + } +} + +// Intensity scaling via CSS custom property. +// Each filter value is split into calc(...) expressions where the literal +// numbers are replaced with (value * var(--filter-intensity)). For "none" +// (normal preset) there is nothing to scale — the image stays unfiltered. +.wp-block-ksolo-image-filter { + .has-filter-warm img { + filter: saturate( calc( 1.25 * var(--filter-intensity) ) ) + sepia( calc( 0.18 * var(--filter-intensity) ) ) + brightness( calc( 0.05 + 1 * var(--filter-intensity) ) ) + contrast( calc( 0.05 + 1 * var(--filter-intensity) ) ); + } + + .has-filter-cool img { + filter: saturate( calc( 0.05 + 0.95 * var(--filter-intensity) ) ) + hue-rotate( calc( -10deg * var(--filter-intensity) ) ) + brightness( calc( 0.02 + 1 * var(--filter-intensity) ) ) + contrast( calc( 0.05 + 1 * var(--filter-intensity) ) ); + } + + .has-filter-vivid img { + filter: saturate( calc( 0.6 * var(--filter-intensity) ) ) + contrast( calc( 0.15 + 1 * var(--filter-intensity) ) ) + brightness( calc( 0.03 + 1 * var(--filter-intensity) ) ); + } + + .has-filter-fade img { + filter: saturate( calc( 0.15 + 0.85 * var(--filter-intensity) ) ) + contrast( calc( 0.1 + 0.9 * var(--filter-intensity) ) ) + brightness( calc( 0.08 + 1 * var(--filter-intensity) ) ) + sepia( calc( 0.08 * var(--filter-intensity) ) ); + } + + .has-filter-mono img { + filter: grayscale( var(--filter-intensity) ) contrast( calc( 0.05 + 1 * var(--filter-intensity) ) ); + } + + .has-filter-dramatic img { + filter: contrast( calc( 0.35 + 1 * var(--filter-intensity) ) ) + saturate( calc( 0.15 + 1 * var(--filter-intensity) ) ) + brightness( calc( 1 - 0.08 * var(--filter-intensity) ) ); + } + + .has-filter-sepia img { + filter: sepia( calc( 0.85 * var(--filter-intensity) ) ) + saturate( calc( 0.1 + 1 * var(--filter-intensity) ) ) + contrast( calc( 0.05 + 1 * var(--filter-intensity) ) ); + } +} +``` + +- [ ] **Step 2: Write `src/styles/editor.scss`** + +```scss +/** + * Editor-only styles for the Filtered Image block. + * + * The swatch grid and inspector panel live here — they only need to render + * inside the editor canvas. + * + * @package ImageFilters + */ + +.ksolo-image-filter-panel { + .components-base-control { + margin-bottom: 16px; + } +} + +.ksolo-image-filter-presets { + display: grid; + grid-template-columns: repeat( 4, 1fr ); + gap: 8px; + margin-bottom: 16px; +} + +.ksolo-image-filter-preset { + display: flex; + flex-direction: column; + align-items: center; + padding: 8px; + border: 1px solid #ddd; + border-radius: 4px; + background: #fff; + cursor: pointer; + font-size: 11px; + text-align: center; + transition: border-color 0.15s ease, box-shadow 0.15s ease; + + &:hover { + border-color: #999; + } + + &[aria-pressed='true'] { + border-color: #007cba; + box-shadow: 0 0 0 1px #007cba; + } + + &:focus-visible { + outline: 2px solid #007cba; + outline-offset: 2px; + } +} + +.ksolo-image-filter-preset__swatch { + width: 32px; + height: 32px; + border-radius: 4px; + border: 1px solid rgba(0, 0, 0, 0.1); + margin-bottom: 4px; +} + +.ksolo-image-filter-preview { + text-align: center; + + img { + max-width: 100%; + height: auto; + display: block; + margin: 0 auto; + } +} +``` + +- [ ] **Step 3: Verify SCSS compiles** + +Create a temporary `src/index.js` that just imports the SCSS so the build verifies it: + +```js +// Temporary: removed after Task 5 wires real dependencies. +import './styles/style.scss'; +import './styles/editor.scss'; +``` + +Run: `cd "C:\Users\ksolo\Herd\basic-wp\wp-content\plugins\ImageFilters" && npm run build` + +Expected: `build/style.css` and `build/editor.css` appear. Open `build/style.css` and confirm the 8 `.has-filter-*` classes are present. + +- [ ] **Step 4: Remove the temporary `src/index.js`** + +Run: `cd "C:\Users\ksolo\Herd\basic-wp\wp-content\plugins\ImageFilters" && rm src/index.js` + +We re-add a real `src/index.js` in Task 5. + +- [ ] **Step 5: Commit** + +```bash +cd "C:\Users\ksolo\Herd\basic-wp\wp-content\plugins\ImageFilters" +git add src/styles/ +git commit -m "Add filter and editor styles for the Filtered Image block" +``` + +--- + +## Task 4: Build the Inspector panel + +**Files:** +- Create: `src/inspector.js` +- Create: `tests/jest/inspector.test.js` + +**Interfaces:** +- Consumes: `PRESETS` and `DEFAULT_PRESET` from `src/presets.js` +- Produces: A `FilterPanel` component used by `edit.js`. Props: `attributes` (the block's), `setAttributes` (Gutenberg setter). + +- [ ] **Step 1: Write the failing test** + +```js +// tests/jest/inspector.test.js +/** + * @jest-environment jsdom + */ +import { render, screen } from '@testing-library/react'; +import { FilterPanel } from '../../src/inspector'; + +jest.mock( '@wordpress/i18n', () => ( { + __: ( str ) => str, +} ) ); + +const baseProps = { + attributes: { filter: 'normal', intensity: 100 }, + setAttributes: jest.fn(), +}; + +describe( 'FilterPanel', () => { + test( 'renders a button for each preset', () => { + render( ); + const buttons = screen.getAllByRole( 'button' ); + // 8 preset buttons. + expect( buttons.length ).toBe( 8 ); + } ); + + test( 'marks the active preset with aria-pressed', () => { + render( ); + const dramatic = screen.getByRole( 'button', { name: /Dramatic/ } ); + expect( dramatic ).toHaveAttribute( 'aria-pressed', 'true' ); + } ); + + test( 'toggles a preset via setAttributes when clicked', () => { + const setAttributes = jest.fn(); + render( ); + const vivid = screen.getByRole( 'button', { name: /Vivid/ } ); + vivid.click(); + expect( setAttributes ).toHaveBeenCalledWith( { filter: 'vivid' } ); + } ); + + test( 'renders the intensity slider with the current value', () => { + render( ); + const slider = screen.getByRole( 'slider' ); + expect( slider.value ).toBe( '60' ); + } ); + + test( 'updates intensity via setAttributes', () => { + const setAttributes = jest.fn(); + render( ); + const slider = screen.getByRole( 'slider' ); + slider.value = '40'; + slider.dispatchEvent( new Event( 'input', { bubbles: true } ) ); + expect( setAttributes ).toHaveBeenCalledWith( { intensity: 40 } ); + } ); +} ); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd "C:\Users\ksolo\Herd\basic-wp\wp-content\plugins\ImageFilters" && npm run test:unit -- inspector.test.js` + +Expected: FAIL with `Cannot find module '../../src/inspector'`. + +- [ ] **Step 3: Write `src/inspector.js`** + +```js +/** + * Inspector panel for the Filtered Image block. + * + * Renders a swatch grid for the preset list and a RangeControl for intensity. + * Both controls are Gutenberg-standard so keyboard navigation and ARIA + * behaviour are inherited from the framework. + * + * @package ImageFilters + */ + +import { __ } from '@wordpress/i18n'; +import { PanelBody, RangeControl } from '@wordpress/components'; +import { PRESETS } from './presets'; + +const labelFor = ( preset ) => preset.label; + +/** + * The filter panel: presets + intensity. + * + * @param {Object} props + * @param {Object} props.attributes Block attributes. + * @param {string} props.attributes.filter Current preset slug. + * @param {number} props.attributes.intensity Current intensity 0–100. + * @param {Function} props.setAttributes Gutenberg setter. + * @return {JSX.Element} + */ +export function FilterPanel( { attributes, setAttributes } ) { + const { filter, intensity } = attributes; + + return ( + +
+ { PRESETS.map( ( preset ) => { + const isActive = preset.slug === filter; + return ( + + ); + } ) } +
+ setAttributes( { intensity: value } ) } + help={ __( + 'Set to 0 to disable the filter, 100 for full strength.', + 'image-filters' + ) } + /> +
+ ); +} + +export default FilterPanel; +``` + +- [ ] **Step 4: Add `@testing-library/react` and `jsdom` to dev dependencies** + +Run: `cd "C:\Users\ksolo\Herd\basic-wp\wp-content\plugins\ImageFilters" && npm install --save-dev @testing-library/react jsdom` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `cd "C:\Users\ksolo\Herd\basic-wp\wp-content\plugins\ImageFilters" && npm run test:unit -- inspector.test.js` + +Expected: PASS, 5 tests passing. + +- [ ] **Step 6: Commit** + +```bash +cd "C:\Users\ksolo\Herd\basic-wp\wp-content\plugins\ImageFilters" +git add src/inspector.js tests/jest/inspector.test.js package.json package-lock.json +git commit -m "Add inspector panel with preset grid and intensity slider" +``` + +--- + +## Task 5: Wire the block (block.json, edit, save, register) + +**Files:** +- Create: `src/block.json` +- Create: `src/edit.js` +- Create: `src/save.js` +- Create: `src/index.js` +- Modify: `image-filters.php` (add block registration) + +**Interfaces:** +- Consumes: `FilterPanel` from `src/inspector.js`, `PRESETS` and `DEFAULT_PRESET` from `src/presets.js` +- Produces: A registered `ksolo/image-filter` block in the editor and on the front-end. + +- [ ] **Step 1: Write `src/block.json`** + +```json +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "ksolo/image-filter", + "version": "0.1.0", + "title": "Filtered Image", + "category": "media", + "icon": "image-filter", + "description": "An image with an Instagram-style filter applied.", + "keywords": [ "filter", "instagram", "image" ], + "textdomain": "image-filters", + "supports": { + "align": [ "left", "center", "right", "wide", "full" ], + "anchor": true, + "html": false, + "spacing": { "margin": true } + }, + "attributes": { + "filter": { + "type": "string", + "default": "normal" + }, + "intensity": { + "type": "number", + "default": 100 + }, + "imageId": { + "type": "number", + "default": 0 + }, + "imageUrl": { + "type": "string", + "default": "", + "source": "attribute", + "selector": "img", + "attribute": "src" + }, + "imageAlt": { + "type": "string", + "default": "", + "source": "attribute", + "selector": "img", + "attribute": "alt" + }, + "width": { + "type": "number" + }, + "height": { + "type": "number" + }, + "linkUrl": { + "type": "string", + "default": "" + }, + "caption": { + "type": "string", + "default": "", + "source": "html", + "selector": "figcaption" + } + }, + "editorScript": "file:./index.js", + "editorStyle": "file:./index.css", + "style": "file:./style-index.css" +} +``` + +Note: `imageUrl` and `imageAlt` use `source: "attribute"` sourcing from the saved ``. This is the same pattern core Image uses. `linkUrl` and `caption` are stored as plain attributes because the rendering happens server-side. + +- [ ] **Step 2: Write `src/edit.js`** + +```js +/** + * Editor component for the Filtered Image block. + * + * The MediaPlaceholder is the same UX as the core Image block. After an + * image is selected, the block renders the image with the filter class + * and intensity CSS custom property. The Inspector panel is mounted + * separately. + * + * @package ImageFilters + */ + +import { __ } from '@wordpress/i18n'; +import { useBlockProps, MediaPlaceholder, BlockControls } from '@wordpress/block-editor'; +import { Button, ToolbarGroup, ToolbarItem } from '@wordpress/components'; +import { useState } from '@wordpress/element'; + +export default function Edit( { attributes, setAttributes } ) { + const { filter, intensity, imageId, imageUrl, imageAlt, width, height } = attributes; + const blockProps = useBlockProps( { + className: `wp-block-ksolo-image-filter has-filter-${ filter }`, + style: { '--filter-intensity': String( intensity / 100 ) }, + } ); + + const [ isEditing, setIsEditing ] = useState( ! imageUrl ); + + if ( isEditing || ! imageUrl ) { + return ( +
+ { + setAttributes( { + imageId: media.id, + imageUrl: media.url, + imageAlt: media.alt || '', + width: media.width, + height: media.height, + } ); + setIsEditing( false ); + } } + allowedTypes={ [ 'image' ] } + multiple={ false } + labels={ { + title: __( 'Filtered Image', 'image-filters' ), + instructions: __( + 'Upload or select an image to apply a filter.', + 'image-filters' + ), + } } + /> +
+ ); + } + + return ( +
+ + + + { () => ( + + ) } + + + + { +
+ ); +} +``` + +- [ ] **Step 3: Write `src/save.js`** + +```js +/** + * Static markup for the Filtered Image block. + * + * Renders a normal with the filter class and an inline CSS custom + * property for intensity. The CSS in style.scss renders the filter; the + * saved HTML is fully static and no client-side JS is needed to display + * it. + * + * @package ImageFilters + */ + +import { useBlockProps } from '@wordpress/block-editor'; + +export default function save( { attributes } ) { + const { filter, intensity, imageUrl, imageAlt, width, height, caption } = attributes; + const blockProps = useBlockProps.save( { + className: `wp-block-ksolo-image-filter has-filter-${ filter }`, + style: { '--filter-intensity': String( intensity / 100 ) }, + } ); + + if ( ! imageUrl ) { + return null; + } + + return ( +
+ { + { caption &&
{ caption }
} +
+ ); +} +``` + +- [ ] **Step 4: Write `src/index.js` to register the block** + +```js +/** + * Block registration entry point. + * + * @package ImageFilters + */ + +import { registerBlockType } from '@wordpress/blocks'; +import { addFilter } from '@wordpress/hooks'; + +import './styles/style.scss'; +import './styles/editor.scss'; + +import metadata from './block.json'; +import edit from './edit'; +import save from './save'; +import { PRESETS, DEFAULT_PRESET, isValidPresetSlug } from './presets'; +import { FilterPanel } from './inspector'; + +registerBlockType( metadata.name, { + ...metadata, + edit, + save, +} ); + +/** + * Add the filter panel to the Image block's inspector when the active + * block is the Filtered Image block. We use the editor.BlockEdit filter + * so we don't have to re-implement the entire MediaPlaceholder UX. + * + * @param {Function} BlockEdit + * @return {Function} + */ +function withFilterPanel( BlockEdit ) { + return ( props ) => { + if ( props.name !== 'ksolo/image-filter' ) { + return ; + } + return ( + <> + + + + ); + }; +} +addFilter( 'editor.BlockEdit', 'image-filters/with-filter-panel', withFilterPanel ); + +/** + * Normalise the filter attribute on save. If the saved slug is unknown + * (e.g. the post was edited by hand), fall back to DEFAULT_PRESET so the + * block still renders without errors. + */ +function ensureValidFilter( settings, name ) { + if ( name !== 'ksolo/image-filter' ) { + return settings; + } + const originalSave = settings.save; + settings.save = ( props ) => { + if ( ! isValidPresetSlug( props.attributes.filter ) ) { + props.attributes.filter = DEFAULT_PRESET; + } + return originalSave( props ); + }; + return settings; +} +addFilter( + 'blocks.registerBlockType', + 'image-filters/ensure-valid-filter', + ensureValidFilter +); + +// Re-export PRESETS so the consuming downstream code (e.g. a future +// design-tool integration) can grab them from this module. +export { PRESETS }; +``` + +- [ ] **Step 5: Wire block registration into `image-filters.php`** + +Edit `image-filters.php` and replace the empty `image_filters_bootstrap()` with the version that registers the block: + +```php +function image_filters_bootstrap(): void { + if ( ! function_exists( 'register_block_type' ) ) { + return; + } + + register_block_type( + IMAGE_FILTERS_DIR . 'build', + array( + 'render_callback' => 'image_filters_render_block', + ) + ); +} + +function image_filters_render_block( array $attributes, string $content = '' ): string { + // Static blocks render their own markup via save.js. The render_callback + // is a placeholder for future server-side logic (e.g. dynamic alt text). + return $content; +} +``` + +- [ ] **Step 6: Build the plugin** + +Run: `cd "C:\Users\ksolo\Herd\basic-wp\wp-content\plugins\ImageFilters" && npm run build` + +Expected: `build/index.js`, `build/index.css`, `build/style-index.css`, `build/block.json` are produced. No errors. + +- [ ] **Step 7: Verify the build output is correct** + +Run: `cd "C:\Users\ksolo\Herd\basic-wp\wp-content\plugins\ImageFilters" && cat build/block.json` + +Expected: Matches the source `src/block.json` (the build copies it). + +- [ ] **Step 8: Run the full unit test suite** + +Run: `cd "C:\Users\ksolo\Herd\basic-wp\wp-content\plugins\ImageFilters" && npm run test:unit` + +Expected: PASS. All 12 tests (7 presets + 5 inspector) pass. No new failures. + +- [ ] **Step 9: Commit** + +```bash +cd "C:\Users\ksolo\Herd\basic-wp\wp-content\plugins\ImageFilters" +git add src/block.json src/edit.js src/save.js src/index.js image-filters.php +git commit -m "Wire up the Filtered Image block end-to-end" +``` + +--- + +## Task 6: Add transforms to and from core Image + +**Files:** +- Modify: `src/block.json` (no attribute change; transforms go in JS) +- Modify: `src/index.js` (add `transforms` to the `registerBlockType` call) + +**Interfaces:** +- Consumes: `attributes` shape from `core/image` (id, url, alt, caption, href, width, height) +- Produces: Transform entries on the registered block that copy image attributes to/from the core Image block. + +- [ ] **Step 1: Write the failing test (jest matchers for transforms)** + +```js +// tests/jest/transforms.test.js +import { transforms } from '../../src/transforms'; + +describe( 'transforms', () => { + test( 'exposes a "to" transform targeting core/image', () => { + const to = transforms.to; + expect( Array.isArray( to ) ? to : [ to ] ).toEqual( + expect.arrayContaining( [ + expect.objectContaining( { + type: 'block', + blocks: expect.arrayContaining( [ 'core/image' ] ), + } ), + ] ) + ); + } ); + + test( 'exposes a "from" transform sourced from core/image', () => { + const from = transforms.from; + expect( Array.isArray( from ) ? from : [ from ] ).toEqual( + expect.arrayContaining( [ + expect.objectContaining( { + type: 'block', + blocks: expect.arrayContaining( [ 'core/image' ] ), + } ), + ] ) + ); + } ); + + test( 'to-transform drops filter and intensity', () => { + const to = Array.isArray( transforms.to ) ? transforms.to[ 0 ] : transforms.to; + const mapped = to.transform( { + filter: 'dramatic', + intensity: 80, + imageId: 12, + imageUrl: 'https://example.com/a.jpg', + imageAlt: 'A', + width: 100, + height: 100, + linkUrl: '', + caption: '', + } ); + expect( mapped ).not.toHaveProperty( 'filter' ); + expect( mapped ).not.toHaveProperty( 'intensity' ); + expect( mapped.id ).toBe( 12 ); + expect( mapped.url ).toBe( 'https://example.com/a.jpg' ); + } ); + + test( 'from-transform normalises filter and intensity to defaults', () => { + const from = Array.isArray( transforms.from ) ? transforms.from[ 0 ] : transforms.from; + const mapped = from.transform( { + id: 12, + url: 'https://example.com/a.jpg', + alt: 'A', + caption: 'cap', + width: 100, + height: 100, + href: 'https://example.com', + } ); + expect( mapped.filter ).toBe( 'normal' ); + expect( mapped.intensity ).toBe( 100 ); + expect( mapped.imageId ).toBe( 12 ); + expect( mapped.imageUrl ).toBe( 'https://example.com/a.jpg' ); + expect( mapped.imageAlt ).toBe( 'A' ); + expect( mapped.caption ).toBe( 'cap' ); + expect( mapped.linkUrl ).toBe( 'https://example.com' ); + } ); +} ); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd "C:\Users\ksolo\Herd\basic-wp\wp-content\plugins\ImageFilters" && npm run test:unit -- transforms.test.js` + +Expected: FAIL with `Cannot find module '../../src/transforms'`. + +- [ ] **Step 3: Write `src/transforms.js`** + +```js +/** + * Block transforms for the Filtered Image block. + * + * - "To" core Image: copies image attributes, drops filter and intensity. + * - "From" core Image: copies image attributes, sets filter="normal", + * intensity=100. + * + * @package ImageFilters + */ + +import { DEFAULT_PRESET } from './presets'; + +const IMAGE_ATTR_MAP = { + id: 'imageId', + url: 'imageUrl', + alt: 'imageAlt', + caption: 'caption', + width: 'width', + height: 'height', + href: 'linkUrl', +}; + +/** + * Maps a core/image-shaped attribute object to a Filtered Image + * attribute object. + * + * @param {Object} source + * @return {Object} + */ +function fromImageAttrs( source ) { + const out = { + filter: DEFAULT_PRESET, + intensity: 100, + imageId: 0, + imageUrl: '', + imageAlt: '', + width: undefined, + height: undefined, + linkUrl: '', + caption: '', + }; + for ( const [ coreKey, ourKey ] of Object.entries( IMAGE_ATTR_MAP ) ) { + if ( source[ coreKey ] !== undefined ) { + out[ ourKey ] = source[ coreKey ]; + } + } + return out; +} + +/** + * Maps a Filtered Image attribute object back to a core/image-shaped + * attribute object. + * + * @param {Object} source + * @return {Object} + */ +function toImageAttrs( source ) { + const out = {}; + for ( const [ coreKey, ourKey ] of Object.entries( IMAGE_ATTR_MAP ) ) { + if ( source[ ourKey ] !== undefined ) { + out[ coreKey ] = source[ ourKey ]; + } + } + return out; +} + +export const transforms = { + to: [ + { + type: 'block', + blocks: [ 'core/image' ], + transform: ( attributes ) => toImageAttrs( attributes ), + }, + ], + from: [ + { + type: 'block', + blocks: [ 'core/image' ], + transform: ( attributes ) => fromImageAttrs( attributes ), + }, + ], +}; + +export default transforms; +``` + +- [ ] **Step 4: Wire `transforms` into `src/index.js`** + +Edit `src/index.js`. Replace the `registerBlockType` call so it includes the transforms: + +```js +import { transforms } from './transforms'; + +registerBlockType( metadata.name, { + ...metadata, + edit, + save, + transforms, +} ); +``` + +- [ ] **Step 5: Run the new test to verify it passes** + +Run: `cd "C:\Users\ksolo\Herd\basic-wp\wp-content\plugins\ImageFilters" && npm run test:unit -- transforms.test.js` + +Expected: PASS, 4 tests passing. + +- [ ] **Step 6: Run the full test suite** + +Run: `cd "C:\Users\ksolo\Herd\basic-wp\wp-content\plugins\ImageFilters" && npm run test:unit` + +Expected: PASS. 16 tests across all suites. + +- [ ] **Step 7: Build and verify the bundle still includes transforms** + +Run: `cd "C:\Users\ksolo\Herd\basic-wp\wp-content\plugins\ImageFilters" && npm run build` + +Expected: Build completes without errors. `build/index.js` is regenerated. + +- [ ] **Step 8: Commit** + +```bash +cd "C:\Users\ksolo\Herd\basic-wp\wp-content\plugins\ImageFilters" +git add src/transforms.js tests/jest/transforms.test.js src/index.js +git commit -m "Add transforms to and from core Image block" +``` + +--- + +## Task 7: Final integration smoke test and accessibility audit + +**Files:** +- Create: `tests/playwright/block.spec.js` (optional but recommended) +- Create: `docs/a11y-checklist.md` + +**Interfaces:** +- Consumes: the running WordPress install at `C:\Users\ksolo\Herd\basic-wp` +- Produces: A passing end-to-end check that the block inserts, filters apply, and the front-end renders. A documented manual accessibility checklist. + +- [ ] **Step 1: Install Playwright** + +Run: `cd "C:\Users\ksolo\Herd\basic-wp\wp-content\plugins\ImageFilters" && npm install --save-dev @playwright/test axe-core` + +- [ ] **Step 2: Write `tests/playwright/block.spec.js`** + +```js +/** + * @package ImageFilters + * + * End-to-end smoke test for the Filtered Image block. + * + * Run with: npx playwright test tests/playwright/block.spec.js + * + * Prerequisite: a WordPress site is running locally with the plugin + * activated. The default URL is http://localhost:8080 — adjust BASE_URL + * if your Herd setup uses a different host. + */ +const { test, expect } = require( '@playwright/test' ); +const axeCore = require( 'axe-core' ); + +const BASE_URL = process.env.BASE_URL || 'http://localhost:8080'; + +test.describe( 'Filtered Image block', () => { + test.beforeEach( async ( { page } ) => { + await page.goto( `${ BASE_URL }/wp-admin/post-new.php` ); + // Log in if redirected. + await page.fill( '#user_login', 'admin' ).catch( () => {} ); + await page.fill( '#user_pass', 'password' ).catch( () => {} ); + await page.click( '#wp-submit' ).catch( () => {} ); + } ); + + test( 'inserts the block and applies a filter', async ( { page } ) => { + await page.click( 'button[aria-label="Add block"]' ); + await page.click( 'button.editor-block-list-item-image-filter' ); + // MediaPlaceholder is shown. + await expect( page.locator( '.wp-block-ksolo-image-filter' ) ).toBeVisible(); + } ); + + test( 'passes axe-core accessibility audit', async ( { page } ) => { + await page.click( 'button[aria-label="Add block"]' ); + await page.click( 'button.editor-block-list-item-image-filter' ); + const results = await page.evaluate( async () => { + const audit = await axeCore.run( document, { + runOnly: [ 'wcag2a', 'wcag2aa' ], + } ); + return audit.violations; + } ); + expect( results ).toEqual( [] ); + } ); +} ); +``` + +- [ ] **Step 3: Run the e2e test (smoke only)** + +Run: `cd "C:\Users\ksolo\Herd\basic-wp\wp-content\plugins\ImageFilters" && npx playwright test tests/playwright/block.spec.js --project=chromium` + +Expected: Block inserts in the editor. axe-core reports zero violations on the rendered inspector and placeholder. If your Herd setup is not running, document the failure and skip with a TODO note. + +- [ ] **Step 4: Write `docs/a11y-checklist.md`** + +```markdown +# Accessibility Checklist — Filtered Image block + +This is a manual checklist for the team to verify on every release. + +## Rendered output + +- [ ] The `` element has the `alt` attribute set to the user's entered text. +- [ ] The `` is wrapped in a `
` with a `
` only when the user supplied a caption. +- [ ] The filter class is on the `
`, not the ``. The visible filter is presentational and does not affect the accessible name. +- [ ] Lighthouse accessibility score >= 95 on a page with at least one Filtered Image block. +- [ ] axe-core run on the public URL returns zero violations. + +## Editor UI + +- [ ] The preset grid is reachable by Tab and arrow keys. +- [ ] Each preset button has `aria-pressed="true"` when active. +- [ ] Each preset swatch is a `