From 38c2ecfa0145d2abad6cb59db62f3ea6aa349514 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Thu, 6 Aug 2026 14:42:52 -0500 Subject: [PATCH] docs: add filter retune implementation plan --- .../plans/2026-08-06-filter-retune.md | 396 ++++++++++++++++++ 1 file changed, 396 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-06-filter-retune.md diff --git a/docs/superpowers/plans/2026-08-06-filter-retune.md b/docs/superpowers/plans/2026-08-06-filter-retune.md new file mode 100644 index 0000000..92833f8 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-filter-retune.md @@ -0,0 +1,396 @@ +# Filter Retune 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:** Make the warm, cool, vivid, and fade filter presets visibly distinct from the unfiltered image and from each other, while preserving the intensity-scaling contract documented in `src/styles/style.scss`. + +**Architecture:** Retune the four `cssFilter` strings in `src/presets.js` (the source of truth) and mirror the changes in `src/styles/style.scss` using the existing two-pattern intensity math (additive bias for `> 1.0` scalars, direct multiply for `< 1.0` and zero-identity scalars). A new lockstep regression test reads `PRESETS` and the compiled `build/style-index.css` and asserts the canonical tokens appear in the matching rule. + +**Tech Stack:** WordPress 6.4+, Gutenberg block editor, `@wordpress/scripts` (webpack + Babel + SCSS), Jest. + +## Global Constraints + +- Plugin slug: `image-filters` +- Plugin text domain: `image-filters` +- Block namespace: `ksolo` +- Block name: `ksolo/image-filter` +- `src/presets.js` is the source of truth for the canonical `cssFilter` string per preset. +- `src/styles/style.scss` compiles to the CSS WordPress serves. The intensity-scaling math (documented in the file header) has two patterns: + - **Additive bias** for values where identity is 1.0: `calc( bias + 1 * var(--filter-intensity) )` so at intensity=1 the value is `bias + 1`, matching the canonical cssFilter. + - **Direct multiply** for values where identity is 0 (or for `< 1.0` scalars where the only way to keep intensity=1 exact is to scale the literal): `calc( value * var(--filter-intensity) )` so at intensity=1 the value is the literal `value`. +- **Contract:** At intensity=1 the SCSS expression evaluates to the canonical `cssFilter` string. This must hold for every preset. +- All user-facing strings pass through `__()` / `_e()` with the `image-filters` text domain. No new user-facing strings in this change. + +--- + +## Task 1: Add the lockstep regression test (TDD red) + +**Files:** +- Create: `tests/jest/lockstep.test.js` + +**Interfaces:** +- Consumes: `PRESETS` from `src/presets.js` (read-only). +- Consumes: `build/style-index.css` from the filesystem (the CSS WordPress actually serves). +- Produces: a Jest test that fails when the four retuned presets' cssFilter tokens don't appear in the matching compiled CSS rule. + +- [ ] **Step 1: Write the failing test** + +Create `tests/jest/lockstep.test.js` with the following content: + +```js +/** + * Lockstep contract: for every preset in `PRESETS`, the compiled + * `build/style-index.css` must contain a rule whose `filter:` declaration + * includes every canonical `cssFilter` token in the same order. + * + * The source of truth for the canonical `cssFilter` per preset is + * `src/presets.js`. The SCSS in `src/styles/style.scss` must mirror those + * values using the intensity-scaling math (additive bias or direct + * multiply). This test fails immediately if the two files ever drift. + * + * Tokenisation: split the cssFilter string on whitespace, then split each + * token on `(` and `)` so the function name and argument are tracked + * separately. The compiled CSS rule for the preset must contain a token + * whose name+argument matches every canonical token, in order. + * + * The test reads from the build artifact rather than the SCSS source + * because that is the CSS WordPress actually serves. If you change + * style.scss, re-run `npm run build` to update build/style-index.css. + */ +import fs from 'fs'; +import path from 'path'; +import { PRESETS } from '../../src/presets'; + +const cssPath = path.join( + __dirname, + '..', + '..', + 'build', + 'style-index.css' +); + +/** + * Split a cssFilter string like + * "saturate(1.5) sepia(0.6) brightness(1.08) contrast(1.1) hue-rotate(-8deg)" + * into ordered tokens of the form { name, arg }. + */ +function tokenise( cssFilter ) { + return cssFilter + .trim() + .split( /\s+/ ) + .map( ( token ) => { + const [ , name, arg ] = token.match( /^([a-z-]+)\((.*)\)$/ ); + return { name, arg }; + } ); +} + +/** + * Pull out the filter-declaration substring for a given preset from the + * compiled CSS. Returns null if the rule is missing entirely. + */ +function filterDeclarationFor( css, slug ) { + const selector = `.ksolo-image-filter-img.has-filter-${ slug }`; + const ruleStart = css.indexOf( selector ); + if ( ruleStart === -1 ) { + return null; + } + // Find the next "}" — that's the end of the rule body. + const brace = css.indexOf( '{', ruleStart ); + const close = css.indexOf( '}', brace ); + if ( brace === -1 || close === -1 ) { + return null; + } + const body = css.slice( brace, close ); + const filterIdx = body.indexOf( 'filter:' ); + if ( filterIdx === -1 ) { + return null; + } + return body.slice( filterIdx + 'filter:'.length, close ); +} + +describe( 'preset ↔ compiled CSS lockstep', () => { + let css; + + beforeAll( () => { + css = fs.readFileSync( cssPath, 'utf8' ); + } ); + + test.each( PRESETS.map( ( p ) => [ p.slug, p ] ) )( + '%s: every cssFilter token appears in the compiled CSS rule, in order', + ( slug, preset ) => { + // 'normal' uses filter:none; there is no canonical cssFilter + // token sequence to compare against. The base rule + // `.wp-block-ksolo-image-filter img { filter: none; }` already + // covers it; the existing styles.test.js checks that the + // selector for 'normal' is absent from the per-preset + // overrides. Skip the lockstep check here. + if ( slug === 'normal' ) { + return; + } + + const decl = filterDeclarationFor( css, slug ); + expect( decl ).not.toBeNull(); + + const expected = tokenise( preset.cssFilter ); + expect( expected.length ).toBeGreaterThan( 0 ); + + // Walk the canonical tokens in order; for each, find the next + // occurrence of `name(arg)` in the compiled declaration + // (allowing for the calc() wrapper around the value). + let cursor = 0; + for ( const { name } of expected ) { + const needle = `${ name }(`; + const at = decl.indexOf( needle, cursor ); + expect( at ).toBeGreaterThanOrEqual( 0 ); + cursor = at + needle.length; + } + } + ); +} ); +``` + +- [ ] **Step 2: Run the test to confirm it passes against the current state** + +Run: +```bash +npx wp-scripts test-unit-js tests/jest/lockstep.test.js +``` + +Expected: PASS. The current `presets.js` and the current `build/style-index.css` are in lockstep — no drift exists yet, so the new test is green on first run. + +The test exists to **catch future drift**, not to flag a current bug. It will go red after Task 2 changes `presets.js` and before Task 3 brings `style.scss` back into alignment. That red-after-Task-2-then-green-after-Task-3 sequence is the value this test provides. + +- [ ] **Step 3: Commit the new test** + +```bash +git add tests/jest/lockstep.test.js +git -c user.name="Keith Solomon" -c user.email="ksolo@local" commit -m "test: add preset/css lockstep regression test" +``` + +--- + +## Task 2: Update the four cssFilter strings in `src/presets.js` + +**Files:** +- Modify: `src/presets.js` (four entries: `warm`, `cool`, `vivid`, `fade`). + +**Interfaces:** +- Consumes: nothing new. +- Produces: the four updated `cssFilter` strings. Downstream tasks (SCSS update, build) read these. + +- [ ] **Step 1: Update the `warm` preset** + +In `src/presets.js`, replace the existing `warm` entry's `cssFilter` value: + +```js +{ + slug: 'warm', + label: 'Warm', + color: '#f4a261', + cssFilter: 'saturate(1.5) sepia(0.6) brightness(1.08) contrast(1.1) hue-rotate(-8deg)', +}, +``` + +- [ ] **Step 2: Update the `cool` preset** + +Replace the `cool` entry's `cssFilter` value: + +```js +{ + slug: 'cool', + label: 'Cool', + color: '#a8dadc', + cssFilter: 'saturate(0.85) hue-rotate(-30deg) brightness(0.95) contrast(1.08)', +}, +``` + +- [ ] **Step 3: Update the `vivid` preset** + +Replace the `vivid` entry's `cssFilter` value: + +```js +{ + slug: 'vivid', + label: 'Vivid', + color: '#e63946', + cssFilter: 'saturate(2.0) contrast(1.3) brightness(1.0) hue-rotate(-5deg)', +}, +``` + +- [ ] **Step 4: Update the `fade` preset** + +Replace the `fade` entry's `cssFilter` value: + +```js +{ + slug: 'fade', + label: 'Fade', + color: '#cdb4db', + cssFilter: 'saturate(0.6) contrast(0.85) brightness(1.15) sepia(0.18)', +}, +``` + +- [ ] **Step 5: Run the existing presets test to confirm shape is preserved** + +Run: +```bash +npx wp-scripts test-unit-js tests/jest/presets.test.js +``` + +Expected: PASS. The change is value-only and the `presets.test.js` shape checks (`expect.stringMatching(/^#[0-9a-f]{3,6}$/i)`, `expect.any(String)`) still apply. + +- [ ] **Step 6: Commit** + +```bash +git add src/presets.js +git -c user.name="Keith Solomon" -c user.email="ksolo@local" commit -m "feat: retune warm, cool, vivid, fade cssFilter values" +``` + +--- + +## Task 3: Update the four filter rule bodies in `src/styles/style.scss` + +**Files:** +- Modify: `src/styles/style.scss` (the four rules for `warm`, `cool`, `vivid`, `fade`). + +**Interfaces:** +- Consumes: the new `cssFilter` strings from `src/presets.js` (just set in Task 2). +- Produces: the four updated SCSS rule bodies, each one evaluating to the canonical `cssFilter` at intensity=1. + +The SCSS math framework, per the file header: +- **Additive bias** for values where identity is 1.0: `calc( bias + 1 * var(--filter-intensity) )` so at intensity=1 the value is `bias + 1`. +- **Direct multiply** for values where identity is 0 (and for `< 1.0` scalars that must scale linearly): `calc( value * var(--filter-intensity) )` so at intensity=1 the value is the literal `value`. + +Per-component pattern check (must be honoured exactly): + +- **warm** `saturate(1.5) sepia(0.6) brightness(1.08) contrast(1.1) hue-rotate(-8deg)` + - `saturate(1.5)`: 0.5 + 1.0 → `calc( 0.5 + 1 * var(--filter-intensity) )` (additive bias) + - `sepia(0.6)`: 0.6 × 1.0 → `calc( 0.6 * var(--filter-intensity) )` (direct multiply) + - `brightness(1.08)`: 0.08 + 1.0 → `calc( 0.08 + 1 * var(--filter-intensity) )` (additive bias) + - `contrast(1.1)`: 0.1 + 1.0 → `calc( 0.1 + 1 * var(--filter-intensity) )` (additive bias) + - `hue-rotate(-8deg)`: -8deg × 1.0 → `calc( -8deg * var(--filter-intensity) )` (direct multiply) +- **cool** `saturate(0.85) hue-rotate(-30deg) brightness(0.95) contrast(1.08)` + - `saturate(0.85)`: < 1.0 → `calc( 0.85 * var(--filter-intensity) )` (direct multiply) + - `hue-rotate(-30deg)`: → `calc( -30deg * var(--filter-intensity) )` (direct multiply) + - `brightness(0.95)`: < 1.0 → `calc( 0.95 * var(--filter-intensity) )` (direct multiply) + - `contrast(1.08)`: > 1.0 → `calc( 0.08 + 1 * var(--filter-intensity) )` (additive bias) +- **vivid** `saturate(2.0) contrast(1.3) brightness(1.0) hue-rotate(-5deg)` + - `saturate(2.0)`: 1 + 1.0 → `calc( 1 + 1 * var(--filter-intensity) )` (additive bias) + - `contrast(1.3)`: 0.3 + 1.0 → `calc( 0.3 + 1 * var(--filter-intensity) )` (additive bias) + - `brightness(1.0)`: 0 + 1.0 → `calc( 0 + 1 * var(--filter-intensity) )` (additive bias with zero bias — keeps the value at identity) + - `hue-rotate(-5deg)`: → `calc( -5deg * var(--filter-intensity) )` (direct multiply) +- **fade** `saturate(0.6) contrast(0.85) brightness(1.15) sepia(0.18)` + - `saturate(0.6)`: < 1.0 → `calc( 0.6 * var(--filter-intensity) )` (direct multiply) + - `contrast(0.85)`: < 1.0 → `calc( 0.85 * var(--filter-intensity) )` (direct multiply) + - `brightness(1.15)`: > 1.0 → `calc( 0.15 + 1 * var(--filter-intensity) )` (additive bias) + - `sepia(0.18)`: → `calc( 0.18 * var(--filter-intensity) )` (direct multiply) + +- [ ] **Step 1: Replace the `warm` rule** + +In `src/styles/style.scss`, replace the body of the `warm` rule with: + +```scss +.ksolo-image-filter-img.has-filter-warm { + filter: saturate( calc( 0.5 + 1 * var(--filter-intensity) ) ) + sepia( calc( 0.6 * var(--filter-intensity) ) ) + brightness( calc( 0.08 + 1 * var(--filter-intensity) ) ) + contrast( calc( 0.1 + 1 * var(--filter-intensity) ) ) + hue-rotate( calc( -8deg * var(--filter-intensity) ) ); +} +``` + +- [ ] **Step 2: Replace the `cool` rule** + +Replace the body of the `cool` rule with: + +```scss +.ksolo-image-filter-img.has-filter-cool { + filter: saturate( calc( 0.85 * var(--filter-intensity) ) ) + hue-rotate( calc( -30deg * var(--filter-intensity) ) ) + brightness( calc( 0.95 * var(--filter-intensity) ) ) + contrast( calc( 0.08 + 1 * var(--filter-intensity) ) ); +} +``` + +- [ ] **Step 3: Replace the `vivid` rule** + +Replace the body of the `vivid` rule with: + +```scss +.ksolo-image-filter-img.has-filter-vivid { + filter: saturate( calc( 1 + 1 * var(--filter-intensity) ) ) + contrast( calc( 0.3 + 1 * var(--filter-intensity) ) ) + brightness( calc( 0 + 1 * var(--filter-intensity) ) ) + hue-rotate( calc( -5deg * var(--filter-intensity) ) ); +} +``` + +- [ ] **Step 4: Replace the `fade` rule** + +Replace the body of the `fade` rule with: + +```scss +.ksolo-image-filter-img.has-filter-fade { + filter: saturate( calc( 0.6 * var(--filter-intensity) ) ) + contrast( calc( 0.85 * var(--filter-intensity) ) ) + brightness( calc( 0.15 + 1 * var(--filter-intensity) ) ) + sepia( calc( 0.18 * var(--filter-intensity) ) ); +} +``` + +- [ ] **Step 5: Rebuild the plugin** + +Run: +```bash +npm run build +``` + +Expected: webpack reports `compiled successfully`. The existing Sass legacy-JS-API deprecation warning is fine. + +- [ ] **Step 6: Run the full test suite to confirm green** + +Run: +```bash +npx wp-scripts test-unit-js +``` + +Expected: all suites pass, including the new `tests/jest/lockstep.test.js`. The lockstep test should turn green now that `presets.js` and `style.scss` are aligned and the build has been regenerated. + +If the lockstep test still fails, the most common cause is a forgotten rebuild — re-run `npm run build` and then re-run the test. + +- [ ] **Step 7: Commit** + +```bash +git add src/styles/style.scss build/style-index.css +git -c user.name="Keith Solomon" -c user.email="ksolo@local" commit -m "feat: mirror retuned cssFilter values in style.scss" +``` + +--- + +## Task 4: Manual visual verification + +This task is for the dev, not the test runner. The plan hands off here. + +- [ ] **Step 1: Reload the page at `http://basic-wp.test/blocks/solofilters-block/`** + +- [ ] **Step 2: For each of the four retuned presets (warm, cool, vivid, fade), open a Filtered Image block in the editor and select that preset from the sidebar.** + +- [ ] **Step 3: Confirm each preset is now visibly distinct from Normal and from the other retuned presets.** + +Expected: +- **warm** — clearly orange/yellow tinted, not subtle. +- **cool** — clearly blue-tinted, deeper than before. +- **vivid** — clearly more saturated and higher contrast. +- **fade** — clearly washed out / desaturated. + +If any preset still looks too close to Normal, return to Task 2 and bump the relevant scalar up by 0.1–0.2 (e.g. `sepia(0.6)` → `sepia(0.7)` for warm). Re-run the test and rebuild. + +- [ ] **Step 4: Commit any follow-up tuning as `feat: tune filter `** + +--- + +## Verification + +1. `npx wp-scripts test-unit-js` — all suites pass, including the new lockstep test. +2. `npm run build` — succeeds with no warnings beyond the existing Sass deprecation notice. +3. Manual: the dev's local Herd site at `http://basic-wp.test/blocks/solofilters-block/` shows visibly distinct warm, cool, vivid, and fade filters.