From b8961ad1ce1d44fa321c1c1beb146235f6b8c8ca Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Thu, 6 Aug 2026 15:36:06 -0500 Subject: [PATCH] docs: add intensity math implementation plan --- .../plans/2026-08-06-intensity-math.md | 330 ++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-06-intensity-math.md diff --git a/docs/superpowers/plans/2026-08-06-intensity-math.md b/docs/superpowers/plans/2026-08-06-intensity-math.md new file mode 100644 index 0000000..89ea037 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-intensity-math.md @@ -0,0 +1,330 @@ +# Intensity Math 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 intensity slider honest. At intensity=0 the filter is fully off (`filter: none`). At intensity=1 the canonical cssFilter applies. Mid-range is a linear blend. + +**Architecture:** Replace the two-pattern intensity framework in `src/styles/style.scss` (additive bias + direct multiply) with linear interpolation between identity (1 for saturate/contrast/brightness, 0 for sepia/hue-rotate/grayscale) and the canonical value. The new formula hits both endpoints exactly. The intensity=1 contract (canonical cssFilter) is preserved; the intensity=0 contract is now actually true. + +**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. +- **Contract:** At intensity=0 the net filter must equal `filter: none`. At intensity=1 the net filter must equal the canonical `cssFilter` for the active preset. (Both endpoints exact; mid-range is a linear blend between the two.) +- All user-facing strings pass through `__()` / `_e()` with the `image-filters` text domain. No new user-facing strings. + +--- + +## Task 1: Add the intensity-shape regression test (TDD) + +**Files:** +- Create: `tests/jest/intensity.test.js` + +**Interfaces:** +- Consumes: the source `src/styles/style.scss` (read-only). +- Produces: a Jest test that fails if any preset's SCSS rule body uses the old additive-bias or direct-multiply shapes that produce non-identity values at intensity=0. + +**Strategy:** A snapshot test that captures the entire SCSS file. On first run, the snapshot is created from the new SCSS. On every subsequent run, the SCSS must match the snapshot exactly. Any future drift — including a reversion to the old math — fails the test. + +- [ ] **Step 1: Write the failing test** + +Create `tests/jest/intensity.test.js` with the following content: + +```js +/** + * Snapshot test for `src/styles/style.scss`. + * + * The file encodes the intensity-interpolation math that maps + * `var(--filter-intensity)` (0..1) onto the canonical cssFilter for each + * preset. The contract: at intensity=0 the net filter is `filter: none`; + * at intensity=1 the net filter is the canonical cssFilter. + * + * The old math (additive bias + direct multiply) failed the intensity=0 + * endpoint — additive-bias scalars decayed to their bias values (e.g. + * `contrast(0.05)`), and direct-multiply scalars decayed to 0 (which is + * not identity for saturate/contrast/brightness). The new math uses + * linear interpolation: + * + * - For scalars where identity=1 (saturate, contrast, brightness): + * - if v > 1: `calc(1 + (v - 1) * var(--filter-intensity))` + * - if v < 1: `calc(1 - (1 - v) * var(--filter-intensity))` + * - if v = 1: identity, no filter component needed + * - For zero-identity scalars (sepia, hue-rotate, grayscale): + * `calc(v * var(--filter-intensity))` + * + * This snapshot test pins the entire SCSS file so any future drift — + * including a reversion to the old math — fails loudly. + * + * On first run with the new SCSS, the snapshot is written to disk and + * the test passes. On every subsequent run, the SCSS must match. + */ +import fs from 'fs'; +import path from 'path'; + +const scssPath = path.join( + __dirname, + '..', + '..', + 'src', + 'styles', + 'style.scss' +); + +describe( 'src/styles/style.scss', () => { + test( 'matches the snapshot (intensity math shape is locked in)', () => { + const scss = fs.readFileSync( scssPath, 'utf8' ); + expect( scss ).toMatchSnapshot(); + } ); +} ); +``` + +- [ ] **Step 2: Run the test to confirm it passes (snapshot is created on first run)** + +Run: +```bash +npx wp-scripts test-unit-js tests/jest/intensity.test.js +``` + +Expected: PASS, and a new `tests/jest/__snapshots__/intensity.test.js.snap` file is created containing the current SCSS. + +- [ ] **Step 3: Commit the new test** + +```bash +git add tests/jest/intensity.test.js tests/jest/__snapshots__/intensity.test.js.snap +git -c user.name="Keith Solomon" -c user.email="ksolo@local" commit -m "test: add SCSS snapshot test for intensity math shape" +``` + +--- + +## Task 2: Update the seven filter rule bodies in `src/styles/style.scss` + +**Files:** +- Modify: `src/styles/style.scss` (the seven rules for `warm`, `cool`, `vivid`, `fade`, `mono`, `dramatic`, `sepia`). +- Modify: `src/styles/style.scss` (the file-header comment block that documents the math framework — the wording needs to change from "additive bias / direct multiply" to "linear interpolation"). + +**Interfaces:** +- Consumes: the existing canonical cssFilter strings from `src/presets.js`. +- Produces: each of the seven SCSS rules rewritten using the new linear formulas. The intensity=1 endpoint must equal the canonical cssFilter (per the spec's per-preset pattern check). + +**Per-component formula** (must be applied to every scalar where identity=1): + +- If `v > 1`: `calc(1 + (v - 1) * var(--filter-intensity))` — produces `v` at i=1, `1` (identity) at i=0. +- If `v < 1`: `calc(1 - (1 - v) * var(--filter-intensity))` — produces `v` at i=1, `1` (identity) at i=0. +- If `v = 1`: drop the term entirely. + +For zero-identity scalars (sepia, hue-rotate, grayscale), the existing `calc(v * var(--filter-intensity))` form is unchanged. + +- [ ] **Step 1: Replace the file-header comment block** + +The current header (lines 1-23) describes the additive-bias / direct-multiply framework. Replace it with the following. The new comment is shorter and documents the linear interpolation: + +Replace the existing header comment in `src/styles/style.scss` (everything from the opening `/**` through the closing `*/` and the empty line that follows) with: + +```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. + * + * Intensity behaviour: each scalar where identity=1 (saturate, contrast, + * brightness) is interpolated linearly between identity (1) at + * intensity=0 and the canonical value (v) at intensity=1: + * + * - if v > 1: calc(1 + (v - 1) * var(--filter-intensity)) + * - if v < 1: calc(1 - (1 - v) * var(--filter-intensity)) + * - if v = 1: identity (no filter component needed) + * + * Zero-identity scalars (sepia, hue-rotate, grayscale) use direct + * multiply: calc(v * var(--filter-intensity)). At intensity=0 they + * collapse to 0, which is the identity value for these properties. + * + * Net effect: at intensity=0 every rule evaluates to `filter: none` + * (identity is identity for every component). At intensity=1 every + * rule evaluates to the canonical cssFilter for the active preset. + * + * Selector shape: the wrapper
carries `wp-block-ksolo-image-filter` + * AND the `has-filter-` class on the same element (see edit.js and + * save.js), and the inner ALSO carries `ksolo-image-filter-img` and + * the same `has-filter-` class. We target the by its stable, + * always-present pair of classes (`.ksolo-image-filter-img.has-filter-`) + * scoped under the wrapper so the rule still applies when Gutenberg's + * `useBlockProps` adds additional wrapper-level classes. + */ +``` + +Note: this preserves the existing selector-shape comment that documents why the rule targets the inner img by both classes. + +- [ ] **Step 2: Replace the `warm` rule body** + +In `src/styles/style.scss`, replace the `warm` rule body with: + +```scss +.ksolo-image-filter-img.has-filter-warm { + filter: saturate( calc( 1 + 0.5 * var(--filter-intensity) ) ) + sepia( calc( 0.6 * var(--filter-intensity) ) ) + brightness( calc( 1 + 0.08 * var(--filter-intensity) ) ) + contrast( calc( 1 + 0.1 * var(--filter-intensity) ) ) + hue-rotate( calc( -8deg * var(--filter-intensity) ) ); +} +``` + +- [ ] **Step 3: Replace the `cool` rule body** + +Replace the `cool` rule body with: + +```scss +.ksolo-image-filter-img.has-filter-cool { + filter: saturate( calc( 1 - 0.15 * var(--filter-intensity) ) ) + hue-rotate( calc( -30deg * var(--filter-intensity) ) ) + brightness( calc( 1 - 0.05 * var(--filter-intensity) ) ) + contrast( calc( 1 + 0.08 * var(--filter-intensity) ) ); +} +``` + +- [ ] **Step 4: Replace the `vivid` rule body** + +Replace the `vivid` rule body with: + +```scss +.ksolo-image-filter-img.has-filter-vivid { + filter: saturate( calc( 1 + 1 * var(--filter-intensity) ) ) + contrast( calc( 1 + 0.3 * var(--filter-intensity) ) ) + hue-rotate( calc( -5deg * var(--filter-intensity) ) ); +} +``` + +Note: `brightness(1.0)` is dropped because it would be `calc(1 + 0 * var(--filter-intensity))` — a no-op that contributes nothing. Removing the term cleans up the rule. + +- [ ] **Step 5: Replace the `fade` rule body** + +Replace the `fade` rule body with: + +```scss +.ksolo-image-filter-img.has-filter-fade { + filter: saturate( calc( 1 - 0.4 * var(--filter-intensity) ) ) + contrast( calc( 1 - 0.15 * var(--filter-intensity) ) ) + brightness( calc( 1 + 0.15 * var(--filter-intensity) ) ) + sepia( calc( 0.18 * var(--filter-intensity) ) ); +} +``` + +- [ ] **Step 6: Replace the `mono` rule body** + +Replace the `mono` rule body with: + +```scss +.ksolo-image-filter-img.has-filter-mono { + filter: grayscale( calc( 1 * var(--filter-intensity) ) ) contrast( calc( 1 + 0.05 * var(--filter-intensity) ) ); +} +``` + +- [ ] **Step 7: Replace the `dramatic` rule body** + +Replace the `dramatic` rule body with: + +```scss +.ksolo-image-filter-img.has-filter-dramatic { + filter: contrast( calc( 1 + 0.35 * var(--filter-intensity) ) ) + saturate( calc( 1 + 0.15 * var(--filter-intensity) ) ) + brightness( calc( 1 - 0.08 * var(--filter-intensity) ) ); +} +``` + +- [ ] **Step 8: Replace the `sepia` rule body** + +Replace the `sepia` rule body with: + +```scss +.ksolo-image-filter-img.has-filter-sepia { + filter: sepia( calc( 0.85 * var(--filter-intensity) ) ) + saturate( calc( 1 + 0.1 * var(--filter-intensity) ) ) + contrast( calc( 1 + 0.05 * var(--filter-intensity) ) ); +} +``` + +- [ ] **Step 9: Rebuild the plugin** + +Run: +```bash +npm run build +``` + +Expected: webpack reports `compiled successfully`. The existing Sass legacy-JS-API deprecation warning is fine. + +- [ ] **Step 10: Run the snapshot test to verify the SCSS shape matches** + +Run: +```bash +npx wp-scripts test-unit-js tests/jest/intensity.test.js +``` + +Expected: FAIL. The SCSS has just been rewritten with the new formulas, but the snapshot was created from the old SCSS in Task 1 Step 2. The test failure is expected — it confirms the snapshot test would catch a future drift. + +Update the snapshot: + +```bash +npx wp-scripts test-unit-js tests/jest/intensity.test.js -u +``` + +Expected: PASS. The snapshot is now the new SCSS. + +- [ ] **Step 11: Run the full test suite** + +Run: +```bash +npx wp-scripts test-unit-js +``` + +Expected: all suites pass, including the existing `lockstep.test.js` (the intensity=1 contract still holds — the canonical cssFilter tokens still appear in each compiled rule). The intensity=0 contract is now true (verified by manual check, not by an automated test — see Task 3). + +If `lockstep.test.js` fails, the most common cause is a typo in the new SCSS values. Re-check each rule against the spec's per-preset pattern check. + +- [ ] **Step 12: Commit** + +```bash +git add src/styles/style.scss tests/jest/__snapshots__/intensity.test.js.snap +git -c user.name="Keith Solomon" -c user.email="ksolo@local" commit -m "fix: use linear interpolation for intensity so intensity=0 is filter:none" +``` + +Note: `build/` is gitignored (per the project's `.gitignore`), so the rebuilt `build/style-index.css` is not committed. This is the standard `@wordpress/scripts` workflow — consumers run their own build. + +--- + +## Task 3: Manual visual verification (intensity=0 endpoint) + +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: Open a Filtered Image block in the editor and select the Sepia preset (or any other preset).** + +- [ ] **Step 3: Drag the intensity slider to 0.** + +Expected: the image must look identical to the unfiltered image (no slate gray, no flat color, no broken pixels). + +- [ ] **Step 4: Drag the intensity slider to 100.** + +Expected: the image must look like the canonical cssFilter (Sepia → warm sepia-toned, Dramatic → high contrast darker, Mono → grayscale, etc.). This is the existing intensity=1 contract, which the lockstep test already verifies. + +- [ ] **Step 5: Drag the intensity slider to mid-range (e.g. 50).** + +Expected: a smooth linear blend between the unfiltered and the fully-filtered image. No sudden jumps, no broken states. + +- [ ] **Step 6: Repeat for at least 3 other presets (warm, dramatic, mono are good test cases) to confirm the contract holds for all of them.** + +- [ ] **Step 7: If any preset looks wrong at intensity=0**, return to Task 2 and check the rule body against the spec's per-preset pattern check. The bug is almost certainly a typo in one of the `calc(...)` expressions. + +--- + +## Verification + +1. `npx wp-scripts test-unit-js` — all suites pass (presets, transforms, inspector, edit, styles, lockstep, intensity). +2. `npm run build` — succeeds with no warnings beyond the existing Sass deprecation notice. +3. Manual: at `http://basic-wp.test/blocks/solofilters-block/`, every preset at intensity=0 must look identical to the unfiltered image; every preset at intensity=1 must look like the canonical cssFilter.