diff --git a/docs/superpowers/specs/2026-08-06-filter-retune-design.md b/docs/superpowers/specs/2026-08-06-filter-retune-design.md new file mode 100644 index 0000000..00c6f91 --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-filter-retune-design.md @@ -0,0 +1,122 @@ +# Filter Retune Design + +> **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, so the eight-preset grid presents as a useful set of choices rather than four near-duplicates. + +**User-reported problem:** On the screenshot at `http://basic-wp.test/blocks/solofilters-block/`, the four "color" presets (warm, cool, vivid, fade) all look almost identical to the unfiltered image. Only mono, dramatic, and sepia read as clearly different. + +**Scope:** Retune exactly four presets. Do not touch mono, dramatic, sepia, or normal. + +**Tech Stack:** WordPress 6.4+, Gutenberg block editor, `@wordpress/scripts` (webpack + Babel + SCSS), Jest (unit tests). + +## 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: `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 continue to hold. +- All user-facing strings pass through `__()` / `_e()` with the `image-filters` text domain. No new user-facing strings in this change. + +## New `cssFilter` values (Option B — Instagram look) + +| Preset | New `cssFilter` | +|---|---| +| warm | `saturate(1.5) sepia(0.6) brightness(1.08) contrast(1.1) hue-rotate(-8deg)` | +| cool | `saturate(0.85) hue-rotate(-30deg) brightness(0.95) contrast(1.08)` | +| vivid | `saturate(2.0) contrast(1.3) brightness(1.0) hue-rotate(-5deg)` | +| fade | `saturate(0.6) contrast(0.85) brightness(1.15) sepia(0.18)` | + +**Rationale:** Each preset stays in its conceptual lane (warm stays warm, cool stays cool, vivid stays vibrant, fade stays washed) but the magnitudes are pushed to where they're clearly visible — comparable to the look on the bold presets in the user's screenshot. The hue-rotate additions on warm, cool, and vivid nudge the color cast in the right direction without changing the preset's identity. + +## SCSS mirror (preserves the intensity=1 contract) + +```scss +.wp-block-ksolo-image-filter { + .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) ) ); + } + + .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) ) ); + } + + .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) ) ); + } + + .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) ) ); + } + + // mono, dramatic, sepia: unchanged +} +``` + +Pattern-per-component check (confirms the intensity=1 contract): + +- **warm** + - `saturate(1.5)`: 0.5 + 1.0 → additive bias ✓ + - `sepia(0.6)`: 0.6 × 1.0 → direct multiply ✓ + - `brightness(1.08)`: 0.08 + 1.0 → additive bias ✓ + - `contrast(1.1)`: 0.1 + 1.0 → additive bias ✓ + - `hue-rotate(-8deg)`: -8deg × 1.0 → direct multiply ✓ +- **cool** + - `saturate(0.85)`: < 1.0 → direct multiply ✓ + - `hue-rotate(-30deg)`: direct multiply ✓ + - `brightness(0.95)`: < 1.0 → direct multiply ✓ + - `contrast(1.08)`: > 1.0 → additive bias ✓ +- **vivid** + - `saturate(2.0)`: 1 + 1.0 → additive bias ✓ + - `contrast(1.3)`: 0.3 + 1.0 → additive bias ✓ + - `brightness(1.0)`: 0 + 1.0 → additive bias (zero bias keeps the value at identity) ✓ + - `hue-rotate(-5deg)`: direct multiply ✓ +- **fade** + - `saturate(0.6)`: < 1.0 → direct multiply ✓ + - `contrast(0.85)`: < 1.0 → direct multiply ✓ + - `brightness(1.15)`: > 1.0 → additive bias ✓ + - `sepia(0.18)`: direct multiply ✓ + +## Out of scope + +- No changes to `inspector.js`, `edit.js`, `save.js`, `transforms.js`, `index.js`, or `block.json`. +- No changes to mono, dramatic, sepia, normal. +- No changes to intensity math or to the intensity slider default. +- No changes to the swatch `color` in presets.js — the swatch is a UI hint, not a literal pixel sample, and the user did not flag it as wrong. + +## Testing + +1. **Existing tests pass unchanged** — `tests/jest/presets.test.js`, `tests/jest/transforms.test.js`, `tests/jest/inspector.test.js`, `tests/jest/edit.test.js`, `tests/jest/styles.test.js` should all stay green. +2. **New lockstep test** (`tests/jest/lockstep.test.js`): for every preset in `PRESETS`, read the compiled `build/style-index.css` and assert the rule's `filter:` declaration contains the canonical `cssFilter` token sequence. 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 token in the canonical cssFilter (the order of tokens must also match, because the SCSS rule writes them in the same order). This pins the source-of-truth contract: if `presets.js` and `style.scss` ever drift, the test fails. +3. **Manual visual confirmation** is the user's job — it requires a real WordPress site and an image. The dev's local Herd install at `http://basic-wp.test/blocks/solofilters-block/` is the verification target. + +## Files to change + +- `src/presets.js` — update the four `cssFilter` strings. +- `src/styles/style.scss` — update the four filter rule bodies; keep all other rules unchanged. +- `tests/jest/lockstep.test.js` — new file. +- `build/style-index.css` — regenerated by `npm run build`. No new dependencies. + +## Verification + +1. `npx wp-scripts test-unit-js` — all existing tests pass plus the new lockstep test. +2. `npm run build` — succeeds with no warnings beyond the existing Sass deprecation notice. +3. Manual: load the existing page, open each preset in the sidebar, confirm warm/cool/vivid/fade now read as clearly different from normal and from each other.