docs: add filter retune implementation plan
This commit is contained in:
@@ -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 <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.
|
||||
Reference in New Issue
Block a user