docs: add intensity math design spec
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
# Intensity Math 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 intensity slider's contract honest. At intensity=0 the filter is fully off (`filter: none`). At intensity=1 the canonical cssFilter applies. The mid-range is a smooth linear blend.
|
||||
|
||||
**User-reported problem:** Sliding the intensity to 0 on the Sepia filter (and every other preset) does not turn the filter off — it produces a broken image (slate gray, near-black, flat, etc.). The inspector's help text promises "Set to 0 to disable the filter, 100 for full strength" — that promise is false under the current math.
|
||||
|
||||
**Root cause:** The two-pattern intensity framework in `src/styles/style.scss` was designed to make intensity=1 exact (matching the canonical `cssFilter` from `presets.js`) but trades away the intensity=0 endpoint:
|
||||
- **Additive bias** for `>1.0` scalars: `calc(bias + 1 * intensity)`. At intensity=0 the value decays to the bias (a non-identity value), so e.g. `contrast(0.05)` is still applied.
|
||||
- **Direct multiply** for `<1.0` and zero-identity scalars: `calc(value * intensity)`. At intensity=0 the value decays to 0, which is identity for sepia/hue-rotate/grayscale but is **not** identity for saturate/contrast/brightness (0 means black / fully desaturated / zero contrast).
|
||||
|
||||
Neither pattern produces a `filter: none` at intensity=0.
|
||||
|
||||
**Scope:** Fix the intensity math in `src/styles/style.scss`. No changes to `presets.js`, `inspector.js`, `edit.js`, `index.js`, `transforms.js`, or `block.json`.
|
||||
|
||||
**Tech Stack:** WordPress 6.4+, Gutenberg block editor, `@wordpress/scripts`, Jest.
|
||||
|
||||
## 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; the 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.
|
||||
|
||||
## New intensity math
|
||||
|
||||
For any scalar where identity=1 (saturate, contrast, brightness) and the canonical value is `v`:
|
||||
|
||||
- If `v > 1`: `calc(1 + (v - 1) * var(--filter-intensity))` — at i=0 = 1, at i=1 = v.
|
||||
- If `v < 1`: `calc(1 - (1 - v) * var(--filter-intensity))` — at i=0 = 1, at i=1 = v.
|
||||
- If `v = 1`: identity, no filter component needed (the term can be dropped from the rule).
|
||||
|
||||
For zero-identity scalars (sepia, hue-rotate, grayscale), the existing direct multiply works correctly and is unchanged:
|
||||
`calc(v * var(--filter-intensity))` — at i=0 = 0 (identity for these), at i=1 = v.
|
||||
|
||||
### Per-component verification (Sepia at intensity=0, the user's bug)
|
||||
|
||||
| Component | SCSS | At intensity=0 | Identity? |
|
||||
|---|---|---|---|
|
||||
| `sepia(0.85)` | `calc(0.85 * var(--filter-intensity))` | `sepia(0)` | yes |
|
||||
| `saturate(1.1)` | `calc(1 + 0.1 * var(--filter-intensity))` | `saturate(1)` | yes |
|
||||
| `contrast(1.05)` | `calc(1 + 0.05 * var(--filter-intensity))` | `contrast(1)` | yes |
|
||||
|
||||
Net filter at intensity=0: `filter: none` ✓
|
||||
|
||||
### Per-component verification (Sepia at intensity=1, the existing contract)
|
||||
|
||||
| Component | SCSS | At intensity=1 | Canonical? |
|
||||
|---|---|---|---|
|
||||
| `sepia(0.85)` | `calc(0.85 * 1)` | `sepia(0.85)` | yes |
|
||||
| `saturate(1.1)` | `calc(1 + 0.1 * 1)` | `saturate(1.1)` | yes |
|
||||
| `contrast(1.05)` | `calc(1 + 0.05 * 1)` | `contrast(1.05)` | yes |
|
||||
|
||||
Both endpoints exact ✓
|
||||
|
||||
### Per-preset pattern check (at intensity=1, must match the canonical cssFilter)
|
||||
|
||||
- **warm** `saturate(1.5) sepia(0.6) brightness(1.08) contrast(1.1) hue-rotate(-8deg)`
|
||||
- `saturate(1.5)`: `calc(1 + 0.5 * intensity)` → at i=1 = 1.5 ✓
|
||||
- `sepia(0.6)`: `calc(0.6 * intensity)` → at i=1 = 0.6 ✓
|
||||
- `brightness(1.08)`: `calc(1 + 0.08 * intensity)` → at i=1 = 1.08 ✓
|
||||
- `contrast(1.1)`: `calc(1 + 0.1 * intensity)` → at i=1 = 1.1 ✓
|
||||
- `hue-rotate(-8deg)`: `calc(-8deg * intensity)` → at i=1 = -8deg ✓
|
||||
- **cool** `saturate(0.85) hue-rotate(-30deg) brightness(0.95) contrast(1.08)`
|
||||
- `saturate(0.85)`: `calc(1 - 0.15 * intensity)` → 0.85 ✓
|
||||
- `hue-rotate(-30deg)`: `calc(-30deg * intensity)` → -30deg ✓
|
||||
- `brightness(0.95)`: `calc(1 - 0.05 * intensity)` → 0.95 ✓
|
||||
- `contrast(1.08)`: `calc(1 + 0.08 * intensity)` → 1.08 ✓
|
||||
- **vivid** `saturate(2.0) contrast(1.3) brightness(1.0) hue-rotate(-5deg)`
|
||||
- `saturate(2.0)`: `calc(1 + 1 * intensity)` → 2.0 ✓
|
||||
- `contrast(1.3)`: `calc(1 + 0.3 * intensity)` → 1.3 ✓
|
||||
- `brightness(1.0)`: identity (drop the term — the previous `calc(0 + 1 * intensity)` form is replaced by absence)
|
||||
- `hue-rotate(-5deg)`: `calc(-5deg * intensity)` → -5deg ✓
|
||||
- **fade** `saturate(0.6) contrast(0.85) brightness(1.15) sepia(0.18)`
|
||||
- `saturate(0.6)`: `calc(1 - 0.4 * intensity)` → 0.6 ✓
|
||||
- `contrast(0.85)`: `calc(1 - 0.15 * intensity)` → 0.85 ✓
|
||||
- `brightness(1.15)`: `calc(1 + 0.15 * intensity)` → 1.15 ✓
|
||||
- `sepia(0.18)`: `calc(0.18 * intensity)` → 0.18 ✓
|
||||
- **mono** `grayscale(1) contrast(1.05)`
|
||||
- `grayscale(1)`: `calc(1 * intensity)` → 1 ✓
|
||||
- `contrast(1.05)`: `calc(1 + 0.05 * intensity)` → 1.05 ✓
|
||||
- **dramatic** `contrast(1.35) saturate(1.15) brightness(0.92)`
|
||||
- `contrast(1.35)`: `calc(1 + 0.35 * intensity)` → 1.35 ✓
|
||||
- `saturate(1.15)`: `calc(1 + 0.15 * intensity)` → 1.15 ✓
|
||||
- `brightness(0.92)`: `calc(1 - 0.08 * intensity)` → 0.92 ✓
|
||||
- **sepia** `sepia(0.85) saturate(1.1) contrast(1.05)`
|
||||
- `sepia(0.85)`: `calc(0.85 * intensity)` → 0.85 ✓
|
||||
- `saturate(1.1)`: `calc(1 + 0.1 * intensity)` → 1.1 ✓
|
||||
- `contrast(1.05)`: `calc(1 + 0.05 * intensity)` → 1.05 ✓
|
||||
|
||||
All seven presets hold at both endpoints.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- No changes to `inspector.js`, `edit.js`, `index.js`, `transforms.js`, `presets.js`, `block.json`.
|
||||
- No changes to the intensity slider UI (label, range, min, max, default).
|
||||
- No changes to the help text "Set to 0 to disable the filter, 100 for full strength" — it now matches the actual contract.
|
||||
- No changes to the `lockstep.test.js` regression — the intensity=1 contract still holds, and the test still walks the canonical tokens in the compiled CSS rule.
|
||||
- No changes to the `normal` preset (its rule is `filter: none` in the base; per-preset overrides don't apply).
|
||||
|
||||
## Testing
|
||||
|
||||
1. **New unit test** (`tests/jest/intensity.test.js`): programmatically verify the linear interpolation formula gives the right value at intensity=0, 0.5, and 1 for representative components from each preset. The test reads the compiled `build/style-index.css` and parses each `calc(...)` expression to confirm the structure. (A simpler approach: just snapshot the entire SCSS file against an expected string, and let the developer visually verify the formula shapes. Snapshot tests are the conventional regression test for SCSS that doesn't have a runtime evaluator.)
|
||||
2. **Existing tests continue to apply.** `presets.test.js`, `transforms.test.js`, `inspector.test.js`, `edit.test.js`, `styles.test.js`, `lockstep.test.js` all stay green.
|
||||
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/styles/style.scss` — replace each filter rule body with the new linear formulas; rewrite the header comment to document the new math framework.
|
||||
- `tests/jest/intensity.test.js` — new snapshot test pinning the SCSS shape.
|
||||
- `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 intensity test.
|
||||
2. `npm run build` — succeeds with no warnings beyond the existing Sass deprecation notice.
|
||||
3. Manual: at `http://basic-wp.test/blocks/solofilters-block/`, select a Sepia (or any) filtered image, drag the intensity slider to 0 — the image must look identical to the unfiltered image. Drag to 100 — the image must look like the canonical cssFilter. Mid-range — a smooth linear blend between the two.
|
||||
Reference in New Issue
Block a user