docs: add filter retune implementation plan

This commit is contained in:
Keith Solomon
2026-08-06 14:42:52 -05:00
parent b774a83dfc
commit 38c2ecfa01
@@ -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.10.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 <preset> filter <reason>`**
---
## 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.