Compare commits

..
12 Commits
Author SHA1 Message Date
Keith Solomon 17c6e5b3dd docs: add editor and frontend screenshots to readmes
Add the two screenshots from the assets/ folder to both README.md (GitHub) and readme.txt (WordPress.org), and commit the assets/ directory so the relative image references resolve.
2026-08-06 18:41:49 -05:00
Keith Solomon 3a2d01564e 📄 docs: Add readme, update author name 2026-08-06 18:33:35 -05:00
Keith Solomon b738d7d6da fix: use linear interpolation for intensity so intensity=0 is filter:none 2026-08-06 15:51:41 -05:00
Keith Solomon 4572abfcd1 test: add SCSS snapshot test for intensity math shape 2026-08-06 15:41:36 -05:00
Keith Solomon b8961ad1ce docs: add intensity math implementation plan 2026-08-06 15:36:06 -05:00
Keith Solomon 48f83c82b3 docs: add intensity math design spec 2026-08-06 15:31:03 -05:00
Keith Solomon 9ea16bf650 fix: mount filter controls in sidebar via InspectorControls
The FilterPanel was being mounted as a free-floating sibling of the block via an `editor.BlockEdit` HOC. In WordPress, a `PanelBody` only renders in the sidebar when wrapped in `<InspectorControls>`; without that wrapper, the panel renders wherever it lands in the React tree, which here was the editor canvas.

Move the InspectorControls mount into the Edit component itself (the idiomatic place for an own block to expose sidebar controls), and drop the `editor.BlockEdit` filter from `src/index.js`. Add an Edit test that pins the contract: the filter panel must mount inside InspectorControls and never float outside it. Add a manual jest mock for @wordpress/block-editor so the Edit test can render in an environment without WordPress globals.
2026-08-06 15:21:25 -05:00
Keith Solomon 860a192aff fix: return createBlock instances from block transforms and fix filter CSS selectors
Two related bug fixes:

- Transforms: `type: 'block'` callbacks must return a block instance created by createBlock; the previous code returned raw attribute objects which WordPress silently ignored, so the toolbar block-switcher transforms never fired. Update tests to assert on `result.name` and `result.attributes`. Add a manual jest mock for @wordpress/blocks since the package is provided at runtime via dependency-extraction-webpack-plugin.

- Filter CSS: the SCSS used a descendant combinator (`.wp-block-ksolo-image-filter .has-filter-warm img`) but the JSX puts both classes on the same element, so the filter rule never matched and picking a filter had no visual effect on the editor or the front end. Target the inner <img> by its stable class pair (`.ksolo-image-filter-img.has-filter-<slug>`) instead. Add a regression test that reads the compiled CSS and asserts the selectors are present for every preset.
2026-08-06 15:19:50 -05:00
Keith Solomon e55a747adc feat: mirror retuned cssFilter values in style.scss 2026-08-06 15:03:53 -05:00
Keith Solomon 0837ecc569 feat: retune warm, cool, vivid, fade cssFilter values 2026-08-06 14:57:33 -05:00
Keith Solomon 24a3935e1c test: add preset/css lockstep regression test 2026-08-06 14:50:43 -05:00
Keith Solomon 38c2ecfa01 docs: add filter retune implementation plan 2026-08-06 14:42:52 -05:00
22 changed files with 1618 additions and 145 deletions
+4
View File
@@ -7,3 +7,7 @@ build/
# Playwright test output (created by `npx playwright test`).
test-results/
playwright-report/
.superpowers/
docs/
.vscode/
+86
View File
@@ -0,0 +1,86 @@
# SoloFilters Image Enhancements
Add Instagram-style CSS filters to images in the WordPress block editor.
## About
This WordPress plugin registers a new "Filtered Image" block that lets users pick a named filter preset and adjust its intensity. Filters are applied with CSS only, so they are reversible, lightweight, and the original image keeps its alt text.
## Requirements
- WordPress 6.4+
- PHP 7.4+
## Installation
1. Clone or download this repository to your WordPress plugins directory:
```bash
wp-content/plugins/solofilters-image-enhancements/
```
2. Activate the plugin from the WordPress admin panel
## Features
- **Filtered Image Block** A new block type for applying CSS filters to images
- **Preset Filters** Choose from multiple named filter presets
- **Adjustable Intensity** Control filter intensity with a slider
- **Non-destructive** Filters are applied with CSS only; original images remain unchanged
- **Accessibility** Alt text is preserved on filtered images
## Screenshots
### Editor
The block's inspector panel in the WordPress block editor. The filter swatch grid sits in the right-hand sidebar alongside the intensity slider.
![Editor view showing the filter swatches and intensity slider in the sidebar](assets/editor-screenshot.png)
### Frontend
All eight filter presets applied to the same image, side by side. From left to right, top to bottom: Normal, Warm, Cool, Vivid, Fade, Mono, Dramatic, Sepia.
![Grid of all eight filter presets applied to a stock image](assets/frontend-screenshot.png)
## Development
### Setup
```bash
npm install
```
### Build
```bash
npm run build
```
### Testing
Run unit tests:
```bash
npm run test:jest
```
Run end-to-end tests:
```bash
npm run test:e2e
```
## Changelog
### [0.1.0] - 2026-08-06
- Initial release
## License
GPLv2 or later [View License](https://www.gnu.org/licenses/gpl-2.0.html)
## Author
[Keith Solomon](https://keithsolomon.net)
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

@@ -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.
@@ -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-<slug> 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 <figure> carries `wp-block-ksolo-image-filter`
* AND the `has-filter-<slug>` class on the same element (see edit.js and
* save.js), and the inner <img> ALSO carries `ksolo-image-filter-img` and
* the same `has-filter-<slug>` class. We target the <img> by its stable,
* always-present pair of classes (`.ksolo-image-filter-img.has-filter-<slug>`)
* 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.
@@ -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.
+14
View File
@@ -17,6 +17,20 @@ named filter preset and adjust its intensity. Filters are applied with CSS
only, so they are reversible, lightweight, and the original image keeps
its alt text.
== Screenshots ==
1. The block's inspector panel in the WordPress block editor. The filter
swatch grid sits in the right-hand sidebar alongside the intensity
slider.
![Editor view showing the filter swatches and intensity slider in the sidebar](assets/editor-screenshot.png)
2. All eight filter presets applied to the same image, side by side. From
left to right, top to bottom: Normal, Warm, Cool, Vivid, Fade, Mono,
Dramatic, Sepia.
![Grid of all eight filter presets applied to a stock image](assets/frontend-screenshot.png)
== Changelog ==
= 0.1.0 =
+3 -1
View File
@@ -5,7 +5,8 @@
* Version: 0.1.0
* Requires at least: 6.4
* Requires PHP: 7.4
* Author: ksolo
* Author: Keith Solomon
* Author URI: https://keithsolomon.net
* License: GPL-2.0-or-later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: solofilters-image-enhancements
@@ -67,4 +68,5 @@ function solofilters_image_enhancements_render_block( array $attributes, string
function solofilters_image_enhancements_load_textdomain(): void {
load_plugin_textdomain( 'solofilters-image-enhancements', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );
}
add_action( 'init', 'solofilters_image_enhancements_load_textdomain' );
+81 -52
View File
@@ -3,19 +3,29 @@
*
* The MediaPlaceholder is the same UX as the core Image block. After an
* image is selected, the block renders the image with the filter class
* and intensity CSS custom property. The Inspector panel is mounted
* separately.
* and intensity CSS custom property. The filter presets and intensity
* slider live in the sidebar via `InspectorControls` — a `PanelBody` only
* renders in the sidebar when wrapped in `InspectorControls`, so the
* block's edit component itself mounts the panel rather than relying on
* an `editor.BlockEdit` HOC.
*
* @package SoloFiltersImageEnhancements
*/
import { __ } from '@wordpress/i18n';
import { useBlockProps, MediaPlaceholder, BlockControls } from '@wordpress/block-editor';
import {
useBlockProps,
MediaPlaceholder,
BlockControls,
InspectorControls,
} from '@wordpress/block-editor';
import { Button, ToolbarGroup, ToolbarItem } from '@wordpress/components';
import { useState } from '@wordpress/element';
import { FilterPanel } from './inspector';
export default function Edit( { attributes, setAttributes } ) {
const { filter, intensity, imageId, imageUrl, imageAlt, width, height } = attributes;
const { filter, intensity, imageUrl, imageAlt, width, height } = attributes;
const blockProps = useBlockProps( {
className: `wp-block-ksolo-image-filter has-filter-${ filter }`,
style: { '--filter-intensity': String( intensity / 100 ) },
@@ -23,58 +33,77 @@ export default function Edit( { attributes, setAttributes } ) {
const [ isEditing, setIsEditing ] = useState( ! imageUrl );
// The inspector panel is mounted alongside whatever the block renders
// in the canvas. It is available in both the empty-placeholder state
// and the rendered-image state, so users can pre-pick a filter before
// uploading.
const inspector = (
<InspectorControls>
<FilterPanel
attributes={ attributes }
setAttributes={ setAttributes }
/>
</InspectorControls>
);
if ( isEditing || ! imageUrl ) {
return (
<div { ...blockProps }>
<MediaPlaceholder
onSelect={ ( media ) => {
setAttributes( {
imageId: media.id,
imageUrl: media.url,
imageAlt: media.alt || '',
width: media.width,
height: media.height,
} );
setIsEditing( false );
} }
allowedTypes={ [ 'image' ] }
multiple={ false }
labels={ {
title: __( 'Filtered Image', 'solofilters-image-enhancements' ),
instructions: __(
'Upload or select an image to apply a filter.',
'solofilters-image-enhancements'
),
} }
/>
</div>
<>
{ inspector }
<div { ...blockProps }>
<MediaPlaceholder
onSelect={ ( media ) => {
setAttributes( {
imageId: media.id,
imageUrl: media.url,
imageAlt: media.alt || '',
width: media.width,
height: media.height,
} );
setIsEditing( false );
} }
allowedTypes={ [ 'image' ] }
multiple={ false }
labels={ {
title: __( 'Filtered Image', 'solofilters-image-enhancements' ),
instructions: __(
'Upload or select an image to apply a filter.',
'solofilters-image-enhancements'
),
} }
/>
</div>
</>
);
}
return (
<figure { ...blockProps }>
<BlockControls>
<ToolbarGroup>
<ToolbarItem>
{ () => (
<Button
onClick={ () => setIsEditing( true ) }
variant="secondary"
label={ __( 'Replace image', 'solofilters-image-enhancements' ) }
>
{ __( 'Replace', 'solofilters-image-enhancements' ) }
</Button>
) }
</ToolbarItem>
</ToolbarGroup>
</BlockControls>
<img
src={ imageUrl }
alt={ imageAlt }
width={ width }
height={ height }
className={ `ksolo-image-filter-img has-filter-${ filter }` }
/>
</figure>
<>
{ inspector }
<figure { ...blockProps }>
<BlockControls>
<ToolbarGroup>
<ToolbarItem>
{ () => (
<Button
onClick={ () => setIsEditing( true ) }
variant="secondary"
label={ __( 'Replace image', 'solofilters-image-enhancements' ) }
>
{ __( 'Replace', 'solofilters-image-enhancements' ) }
</Button>
) }
</ToolbarItem>
</ToolbarGroup>
</BlockControls>
<img
src={ imageUrl }
alt={ imageAlt }
width={ width }
height={ height }
className={ `ksolo-image-filter-img has-filter-${ filter }` }
/>
</figure>
</>
);
}
}
+1 -28
View File
@@ -14,7 +14,6 @@ import metadata from './block.json';
import edit from './edit';
import save from './save';
import { PRESETS, DEFAULT_PRESET, isValidPresetSlug } from './presets';
import { FilterPanel } from './inspector';
import { transforms } from './transforms';
registerBlockType( metadata.name, {
@@ -24,32 +23,6 @@ registerBlockType( metadata.name, {
transforms,
} );
/**
* Add the filter panel to the Image block's inspector when the active
* block is the Filtered Image block. We use the editor.BlockEdit filter
* so we don't have to re-implement the entire MediaPlaceholder UX.
*
* @param {Function} BlockEdit
* @return {Function}
*/
function withFilterPanel( BlockEdit ) {
return ( props ) => {
if ( props.name !== 'ksolo/image-filter' ) {
return <BlockEdit { ...props } />;
}
return (
<>
<BlockEdit { ...props } />
<FilterPanel
attributes={ props.attributes }
setAttributes={ props.setAttributes }
/>
</>
);
};
}
addFilter( 'editor.BlockEdit', 'solofilters-image-enhancements/with-filter-panel', withFilterPanel );
/**
* Normalise the filter attribute on save. If the saved slug is unknown
* (e.g. the post was edited by hand), fall back to DEFAULT_PRESET so the
@@ -76,4 +49,4 @@ addFilter(
// Re-export PRESETS so the consuming downstream code (e.g. a future
// design-tool integration) can grab them from this module.
export { PRESETS };
export { PRESETS };
+4 -4
View File
@@ -19,25 +19,25 @@ export const PRESETS = [
slug: 'warm',
label: 'Warm',
color: '#f4a261',
cssFilter: 'saturate(1.25) sepia(0.18) brightness(1.05) contrast(1.05)',
cssFilter: 'saturate(1.5) sepia(0.6) brightness(1.08) contrast(1.1) hue-rotate(-8deg)',
},
{
slug: 'cool',
label: 'Cool',
color: '#a8dadc',
cssFilter: 'saturate(0.95) hue-rotate(-10deg) brightness(1.02) contrast(1.05)',
cssFilter: 'saturate(0.85) hue-rotate(-30deg) brightness(0.95) contrast(1.08)',
},
{
slug: 'vivid',
label: 'Vivid',
color: '#e63946',
cssFilter: 'saturate(1.6) contrast(1.15) brightness(1.03)',
cssFilter: 'saturate(2.0) contrast(1.3) hue-rotate(-5deg)',
},
{
slug: 'fade',
label: 'Fade',
color: '#cdb4db',
cssFilter: 'saturate(0.85) contrast(0.9) brightness(1.08) sepia(0.08)',
cssFilter: 'saturate(0.6) contrast(0.85) brightness(1.15) sepia(0.18)',
},
{
slug: 'mono',
+62 -41
View File
@@ -5,20 +5,29 @@
* 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: at intensity=1 each rendered filter matches the
* canonical cssFilter string in presets.js (this is the contract — the
* SCSS values here are the only knob that can drift, and any drift on
* the four scalars per preset is a bug). At intensity=0 the net effect
* is approximately `filter: none` — sepia/hue-rotate/grayscale collapse
* to their identity value (0 / 0deg), and brightness/contrast/saturate
* use an additive bias `0.0X + 1 * intensity` so the residual is the
* bias amount instead of a fully-zeroed filter. Where the canonical
* value sits below 1 (saturate(0.95), contrast(0.9), brightness(0.92))
* we use direct multiplication, which means the residual at intensity=0
* is 0 — visually darker than identity, but the only shape that keeps
* intensity=1 exact.
* 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:
*
* @package SoloFiltersImageEnhancements
* - 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 <figure> carries `wp-block-ksolo-image-filter`
* AND the `has-filter-<slug>` class on the same element (see edit.js and
* save.js), and the inner <img> ALSO carries `ksolo-image-filter-img` and
* the same `has-filter-<slug>` class. We target the <img> by its stable,
* always-present pair of classes (`.ksolo-image-filter-img.has-filter-<slug>`)
* scoped under the wrapper so the rule still applies when Gutenberg's
* `useBlockProps` adds additional wrapper-level classes.
*/
.wp-block-ksolo-image-filter {
@@ -37,47 +46,59 @@
// Each filter value is split into calc(...) expressions where the literal
// numbers are replaced with (value * var(--filter-intensity)). For "none"
// (normal preset) there is nothing to scale — the image stays unfiltered.
//
// Selector shape: the wrapper <figure> carries `wp-block-ksolo-image-filter`
// AND the `has-filter-<slug>` class on the same element (see edit.js and
// save.js), and the inner <img> ALSO carries `ksolo-image-filter-img` and
// the same `has-filter-<slug>` class. The two previous attempts used a
// descendant combinator (".wp-block-ksolo-image-filter .has-filter-warm img"),
// which never matches because the class sits on the same element rather
// than a child. We target the <img> by its stable, always-present pair of
// classes (`.ksolo-image-filter-img.has-filter-<slug>`) scoped under the
// wrapper so the rule still applies when Gutenberg's `useBlockProps` adds
// additional wrapper-level classes.
.wp-block-ksolo-image-filter {
.has-filter-warm img {
filter: saturate( calc( 0.25 + 1 * var(--filter-intensity) ) )
sepia( calc( 0.18 * var(--filter-intensity) ) )
brightness( calc( 0.05 + 1 * var(--filter-intensity) ) )
contrast( calc( 0.05 + 1 * var(--filter-intensity) ) );
.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) ) );
}
.has-filter-cool img {
filter: saturate( calc( 0.95 * var(--filter-intensity) ) )
hue-rotate( calc( -10deg * var(--filter-intensity) ) )
brightness( calc( 0.02 + 1 * var(--filter-intensity) ) )
contrast( calc( 0.05 + 1 * var(--filter-intensity) ) );
.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) ) );
}
.has-filter-vivid img {
filter: saturate( calc( 0.6 + 1 * var(--filter-intensity) ) )
contrast( calc( 0.15 + 1 * var(--filter-intensity) ) )
brightness( calc( 0.03 + 1 * var(--filter-intensity) ) );
.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) ) );
}
.has-filter-fade img {
filter: saturate( calc( 0.85 * var(--filter-intensity) ) )
contrast( calc( 0.9 * var(--filter-intensity) ) )
brightness( calc( 0.08 + 1 * var(--filter-intensity) ) )
sepia( calc( 0.08 * var(--filter-intensity) ) );
.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) ) );
}
.has-filter-mono img {
filter: grayscale( calc( 1 * var(--filter-intensity) ) ) contrast( calc( 0.05 + 1 * var(--filter-intensity) ) );
.ksolo-image-filter-img.has-filter-mono {
filter: grayscale( calc( 1 * var(--filter-intensity) ) ) contrast( calc( 1 + 0.05 * var(--filter-intensity) ) );
}
.has-filter-dramatic img {
filter: contrast( calc( 0.35 + 1 * var(--filter-intensity) ) )
saturate( calc( 0.15 + 1 * var(--filter-intensity) ) )
brightness( calc( 0.92 * var(--filter-intensity) ) );
.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) ) );
}
.has-filter-sepia img {
.ksolo-image-filter-img.has-filter-sepia {
filter: sepia( calc( 0.85 * var(--filter-intensity) ) )
saturate( calc( 0.1 + 1 * var(--filter-intensity) ) )
contrast( calc( 0.05 + 1 * var(--filter-intensity) ) );
saturate( calc( 1 + 0.1 * var(--filter-intensity) ) )
contrast( calc( 1 + 0.05 * var(--filter-intensity) ) );
}
}
+10 -3
View File
@@ -5,9 +5,14 @@
* - "From" core Image: copies image attributes, sets filter="normal",
* intensity=100.
*
* Per the Block Transforms API, `type: 'block'` transforms must return a
* block instance produced by `createBlock` from `@wordpress/blocks` — a
* raw attribute object is silently ignored and the transform fails.
*
* @package SoloFiltersImageEnhancements
*/
import { createBlock } from '@wordpress/blocks';
import { DEFAULT_PRESET } from './presets';
const IMAGE_ATTR_MAP = {
@@ -69,16 +74,18 @@ export const transforms = {
{
type: 'block',
blocks: [ 'core/image' ],
transform: ( attributes ) => toImageAttrs( attributes ),
transform: ( attributes ) =>
createBlock( 'core/image', toImageAttrs( attributes ) ),
},
],
from: [
{
type: 'block',
blocks: [ 'core/image' ],
transform: ( attributes ) => fromImageAttrs( attributes ),
transform: ( attributes ) =>
createBlock( 'ksolo/image-filter', fromImageAttrs( attributes ) ),
},
],
};
export default transforms;
export default transforms;
@@ -0,0 +1,39 @@
/**
* Manual mock for @wordpress/block-editor.
*
* The package is provided by WordPress at runtime via the
* dependency-extraction-webpack-plugin and is not installed as a dev
* dependency. This mock lets the Jest environment resolve it.
*
* Slot components (`InspectorControls`, `BlockControls`) are rendered as
* lightweight `<div data-slot="...">` wrappers so tests can assert that
* `Edit` mounts them in the right place. Hooks (`useBlockProps`) are
* stubbed to return the supplied props unchanged, which lets the rest
* of the React tree render with stable className/style.
*/
import { createElement } from '@wordpress/element';
const Slot = ( { name, children } ) =>
createElement( 'div', { 'data-slot': name }, children );
function makeUseBlockProps( props = {} ) {
return {
className: props.className || '',
style: props.style || {},
};
}
const useBlockPropsFn = ( props ) => makeUseBlockProps( props );
useBlockPropsFn.save = ( props ) => makeUseBlockProps( props );
export const useBlockProps = useBlockPropsFn;
export const InspectorControls = ( { children } ) =>
createElement( Slot, { name: 'inspector-controls' }, children );
export const BlockControls = ( { children } ) =>
createElement( Slot, { name: 'block-controls' }, children );
export const MediaPlaceholder = ( props ) =>
createElement(
'div',
{ 'data-mock': 'MediaPlaceholder' },
props.labels ? props.labels.title : null
);
+15
View File
@@ -0,0 +1,15 @@
/**
* Manual mock for @wordpress/blocks.
*
* The package is provided by WordPress at runtime (via the
* dependency-extraction-webpack-plugin) and is not installed as a
* dev dependency. This mock lets Jest's module resolver find something
* when the transforms test (or any other code that imports
* @wordpress/blocks) runs in the unit-test environment.
*
* The mock faithfully reproduces the shape createBlock returns: a block
* instance with `name` and `attributes` fields.
*/
module.exports = {
createBlock: ( name, attributes ) => ( { name, attributes } ),
};
@@ -0,0 +1,109 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`src/styles/style.scss matches the snapshot (intensity math shape is locked in) 1`] = `
"/**
* Filtered Image block — front-end and editor styles.
*
* The .has-filter-<slug> 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 <figure> carries \`wp-block-ksolo-image-filter\`
* AND the \`has-filter-<slug>\` class on the same element (see edit.js and
* save.js), and the inner <img> ALSO carries \`ksolo-image-filter-img\` and
* the same \`has-filter-<slug>\` class. We target the <img> by its stable,
* always-present pair of classes (\`.ksolo-image-filter-img.has-filter-<slug>\`)
* scoped under the wrapper so the rule still applies when Gutenberg's
* \`useBlockProps\` adds additional wrapper-level classes.
*/
.wp-block-ksolo-image-filter {
--filter-intensity: 1;
display: inline-block;
margin: 0;
img {
filter: none;
max-width: 100%;
height: auto;
}
}
// Intensity scaling via CSS custom property.
// Each filter value is split into calc(...) expressions where the literal
// numbers are replaced with (value * var(--filter-intensity)). For "none"
// (normal preset) there is nothing to scale — the image stays unfiltered.
//
// Selector shape: the wrapper <figure> carries \`wp-block-ksolo-image-filter\`
// AND the \`has-filter-<slug>\` class on the same element (see edit.js and
// save.js), and the inner <img> ALSO carries \`ksolo-image-filter-img\` and
// the same \`has-filter-<slug>\` class. The two previous attempts used a
// descendant combinator (".wp-block-ksolo-image-filter .has-filter-warm img"),
// which never matches because the class sits on the same element rather
// than a child. We target the <img> by its stable, always-present pair of
// classes (\`.ksolo-image-filter-img.has-filter-<slug>\`) scoped under the
// wrapper so the rule still applies when Gutenberg's \`useBlockProps\` adds
// additional wrapper-level classes.
.wp-block-ksolo-image-filter {
.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) ) );
}
.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) ) );
}
.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) ) );
}
.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) ) );
}
.ksolo-image-filter-img.has-filter-mono {
filter: grayscale( calc( 1 * var(--filter-intensity) ) ) contrast( calc( 1 + 0.05 * var(--filter-intensity) ) );
}
.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) ) );
}
.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) ) );
}
}
"
`;
+98
View File
@@ -0,0 +1,98 @@
/**
* @jest-environment jsdom
*
* Tests for the Edit component. The core contract these tests pin down
* is that the filter controls are mounted in the sidebar via
* `InspectorControls` (not floating in the canvas). The block's `Edit`
* component is responsible for wrapping the `FilterPanel` in
* `InspectorControls` itself — see the comment block at the top of
* src/edit.js.
*/
// @wordpress/element, @wordpress/block-editor, @wordpress/components,
// @wordpress/i18n are all stubbed via __mocks__/* and inline jest.mock
// calls so the test environment has no WordPress globals.
import { render, screen } from '@testing-library/react';
import Edit from '../../src/edit';
const defaultAttributes = {
filter: 'normal',
intensity: 100,
imageId: 0,
imageUrl: '',
imageAlt: '',
width: 100,
height: 100,
linkUrl: '',
caption: '',
};
const baseProps = {
attributes: defaultAttributes,
setAttributes: jest.fn(),
isSelected: true,
clientId: 'test-client-id',
context: {},
name: 'ksolo/image-filter',
};
describe( 'Edit (Filtered Image block)', () => {
test( 'mounts the filter controls inside InspectorControls (sidebar)', () => {
const { container } = render( <Edit { ...baseProps } /> );
// InspectorControls is rendered as <div data-slot="inspector-controls">.
const sidebar = container.querySelector(
'[data-slot="inspector-controls"]'
);
expect( sidebar ).not.toBeNull();
// The PanelBody inside FilterPanel uses the title "Filter". Even
// in the empty/placeholder branch (no imageUrl), the inspector
// should be present so users can pick a preset before uploading.
expect(
sidebar.querySelector( '.ksolo-image-filter-panel' )
).not.toBeNull();
} );
test( 'does NOT mount a floating FilterPanel outside InspectorControls', () => {
// Regression guard: earlier the `FilterPanel` was rendered as a
// bare `<PanelBody>` (no InspectorControls wrapper) via an
// `editor.BlockEdit` HOC. That caused the controls to appear in
// the editor canvas rather than the sidebar. The new wiring
// guarantees the panel only appears inside InspectorControls.
const { container } = render( <Edit { ...baseProps } /> );
const allPanels = container.querySelectorAll(
'.ksolo-image-filter-panel'
);
expect( allPanels.length ).toBe( 1 );
expect(
allPanels[ 0 ].closest( '[data-slot="inspector-controls"]' )
).not.toBeNull();
} );
test( 'shows the MediaPlaceholder in the canvas when no image is set', () => {
render( <Edit { ...baseProps } /> );
// The MediaPlaceholder mock renders a div with
// `data-mock="MediaPlaceholder"` containing the labels.title
// string ("Filtered Image").
const placeholder = screen.getByText( 'Filtered Image' );
expect( placeholder ).not.toBeNull();
} );
test( 'renders the <img> in the canvas when an imageUrl is set', () => {
const props = {
...baseProps,
attributes: {
...defaultAttributes,
imageUrl: 'https://example.com/a.jpg',
imageAlt: 'A',
},
};
render( <Edit { ...props } /> );
const img = screen.getByAltText( 'A' );
expect( img ).not.toBeNull();
// The className on the <img> must include the preset class so the
// compiled CSS can target it.
expect( img.className ).toContain( 'ksolo-image-filter-img' );
expect( img.className ).toContain( 'has-filter-normal' );
} );
} );
+45
View File
@@ -0,0 +1,45 @@
/**
* 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();
} );
} );
+109
View File
@@ -0,0 +1,109 @@
/**
* 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;
}
}
);
} );
+63
View File
@@ -0,0 +1,63 @@
/**
* Regression test for the compiled filter CSS.
*
* Earlier the source SCSS wrote the filter rules with a descendant
* combinator (`.wp-block-ksolo-image-filter .has-filter-warm img`), but
* the JSX puts `wp-block-ksolo-image-filter` and `has-filter-<slug>` on
* the SAME element, and the inner <img> carries `ksolo-image-filter-img`
* and the same `has-filter-<slug>` class. The descendant combinator never
* matched, so the `filter: ...` declarations were never applied — picking
* a filter in the editor or on the front end had no visual effect.
*
* The fix targets the <img> by its stable, always-present pair of classes
* (`.ksolo-image-filter-img.has-filter-<slug>`) scoped under the wrapper.
* This test reads the compiled CSS and asserts the selectors and `filter`
* declarations are present for every preset slug in src/presets.js.
*
* 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' );
describe( 'compiled filter CSS', () => {
let css;
beforeAll( () => {
css = fs.readFileSync( cssPath, 'utf8' );
} );
test.each(
PRESETS.filter( ( p ) => p.slug !== 'normal' ).map( ( p ) => [ p.slug ] )
)(
'declares a filter rule for the %s preset that targets the <img>',
( slug ) => {
// The selector must target the <img> by its stable class pair
// (ksolo-image-filter-img + has-filter-<slug>) scoped under the
// wrapper, with NO space between the two class names (compound
// selector, not descendant).
// The 'normal' preset is unfiltered; the base
// `.wp-block-ksolo-image-filter img { filter: none; }` rule
// covers it, so we skip it here.
const expectedSelector = `.wp-block-ksolo-image-filter .ksolo-image-filter-img.has-filter-${ slug }`;
expect( css ).toContain( expectedSelector );
}
);
test( 'does not regress to the broken descendant-selector form', () => {
// Guard against the original bug reappearing: a descendant
// combinator between the two class names never matches the
// actual markup, where both classes live on the same element.
for ( const { slug } of PRESETS ) {
if ( slug === 'normal' ) {
continue; // 'normal' is unfiltered, no rule needed.
}
const broken = `.wp-block-ksolo-image-filter .has-filter-${ slug } img`;
expect( css ).not.toContain( broken );
}
} );
} );
+28 -16
View File
@@ -1,3 +1,7 @@
// @wordpress/blocks is provided by WordPress at runtime via the
// dependency-extraction-webpack-plugin. The Jest test environment has no
// WordPress globals, so a manual mock lives at
// tests/jest/__mocks__/@wordpress/blocks.js. Jest auto-resolves it.
import { transforms } from '../../src/transforms';
describe( 'transforms', () => {
@@ -25,9 +29,9 @@ describe( 'transforms', () => {
);
} );
test( 'to-transform drops filter and intensity', () => {
test( 'to-transform produces a core/image block that drops filter and intensity', () => {
const to = Array.isArray( transforms.to ) ? transforms.to[ 0 ] : transforms.to;
const mapped = to.transform( {
const result = to.transform( {
filter: 'dramatic',
intensity: 80,
imageId: 12,
@@ -38,15 +42,20 @@ describe( 'transforms', () => {
linkUrl: '',
caption: '',
} );
expect( mapped ).not.toHaveProperty( 'filter' );
expect( mapped ).not.toHaveProperty( 'intensity' );
expect( mapped.id ).toBe( 12 );
expect( mapped.url ).toBe( 'https://example.com/a.jpg' );
// WordPress block-to-block transforms must return a block instance
// (created via createBlock), not a bare attribute object.
expect( result ).toBeDefined();
expect( result.name ).toBe( 'core/image' );
expect( result.attributes ).toBeDefined();
expect( result.attributes ).not.toHaveProperty( 'filter' );
expect( result.attributes ).not.toHaveProperty( 'intensity' );
expect( result.attributes.id ).toBe( 12 );
expect( result.attributes.url ).toBe( 'https://example.com/a.jpg' );
} );
test( 'from-transform normalises filter and intensity to defaults', () => {
test( 'from-transform produces a ksolo/image-filter block with normalised filter and intensity', () => {
const from = Array.isArray( transforms.from ) ? transforms.from[ 0 ] : transforms.from;
const mapped = from.transform( {
const result = from.transform( {
id: 12,
url: 'https://example.com/a.jpg',
alt: 'A',
@@ -55,12 +64,15 @@ describe( 'transforms', () => {
height: 100,
href: 'https://example.com',
} );
expect( mapped.filter ).toBe( 'normal' );
expect( mapped.intensity ).toBe( 100 );
expect( mapped.imageId ).toBe( 12 );
expect( mapped.imageUrl ).toBe( 'https://example.com/a.jpg' );
expect( mapped.imageAlt ).toBe( 'A' );
expect( mapped.caption ).toBe( 'cap' );
expect( mapped.linkUrl ).toBe( 'https://example.com' );
expect( result ).toBeDefined();
expect( result.name ).toBe( 'ksolo/image-filter' );
expect( result.attributes ).toBeDefined();
expect( result.attributes.filter ).toBe( 'normal' );
expect( result.attributes.intensity ).toBe( 100 );
expect( result.attributes.imageId ).toBe( 12 );
expect( result.attributes.imageUrl ).toBe( 'https://example.com/a.jpg' );
expect( result.attributes.imageAlt ).toBe( 'A' );
expect( result.attributes.caption ).toBe( 'cap' );
expect( result.attributes.linkUrl ).toBe( 'https://example.com' );
} );
} );
} );