styleproof 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,47 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.3.1]
11
+
12
+ ### Changed
13
+
14
+ - The before/after composite no longer draws coloured accent strips (grey for
15
+ before, blue for after) above each crop. They were a font-free before/after
16
+ cue, but a strip that differs between the two sides reads as a _second_ change
17
+ in the pair. Now the only thing that differs across the composite is the actual
18
+ change; before/after stays conveyed by position (left = before) and the caption.
19
+
20
+ ## [1.3.0]
21
+
22
+ Clearer reports: one section per screenshot.
23
+
24
+ ### Changed
25
+
26
+ - **The report is organised by crop, not by surface.** Each changed region of the
27
+ page is its own section, headed by the element it is anchored on
28
+ (`` `who-grid` · 7 elements restyled ``), and its before|after screenshot is
29
+ followed by **only** the property changes that screenshot shows. A page with two
30
+ unrelated changes (say a restructured grid and a lone button) used to render one
31
+ screenshot, then another, then a single wall of tables you could not map back to
32
+ either image; now every table sits under the crop it belongs to, and crops read
33
+ top-to-bottom in page order.
34
+ - **Approval is per crop.** Because each crop is its own `###` section, the approval
35
+ checkbox the Action injects is per visual region — sign off the grid and the
36
+ button independently. A change that is identical across widths still collapses to
37
+ one section, as before.
38
+ - **The property tables fold under a toggle, behind a one-line essence.** The
39
+ screenshot and the approval checkbox always stay visible; below them a scannable
40
+ one-liner names the top deltas (and flags hover/focus/active changes, which a
41
+ static screenshot can't show), and the full before→after tables sit inside a
42
+ `<details>`. New `foldDetailsAt` report option: the row count at which tables fold
43
+ (default `0` = always; set `5` to keep small changes inline and fold only verbose
44
+ ones, `Infinity` to never fold).
45
+
46
+ ### Fixed
47
+
48
+ - Two distinct changes that shared a representative surface could collide on one crop
49
+ image filename; crop images are now uniquely numbered across the whole report.
50
+
10
51
  ## [1.2.0]
11
52
 
12
53
  Visual-review approval gate. StyleProof can now act as a per-PR review gate
package/README.md CHANGED
@@ -1,703 +1,149 @@
1
1
  # StyleProof
2
2
 
3
- > **Prove a CSS refactor changed nothing.** Capture the browser's _computed styles_ and diff before against after. If the diff is empty, the refactor is certified: not "looks the same", but _resolves byte-for-byte the same_.
3
+ **Know exactly what every PR changes visually, and sign it off.** StyleProof captures the browser's _computed_ styles (not pixels), diffs your PR's HEAD against its base branch, and posts a per-change report on the PR, so a styling change never ships without someone confirming it was intended.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/styleproof.svg)](https://www.npmjs.com/package/styleproof)
6
- [![CI](https://github.com/BenSheridanEdwards/styleproof/actions/workflows/ci.yml/badge.svg)](https://github.com/BenSheridanEdwards/styleproof/actions/workflows/ci.yml)
7
- [![license: MIT](https://img.shields.io/npm/l/styleproof.svg)](./LICENSE)
6
+ [![CI](https://github.com/BenSheridanEdwards/styleproof/actions/workflows/ci.yml/badge.svg)](https://github.com/BenSheridanEdwards/styleproof/actions)
7
+ [![license](https://img.shields.io/npm/l/styleproof.svg)](https://github.com/BenSheridanEdwards/styleproof/blob/main/LICENSE)
8
8
 
9
- StyleProof captures every resolved CSS longhand on every element, every
10
- pseudo-element (`::before`/`::after`/`::marker`/`::placeholder`), and every forced
11
- `:hover`/`:focus`/`:active` state, swept across your breakpoints, keyed by DOM
12
- structure rather than class names. Then it diffs two captures and tells you the exact
13
- element, property, and state that drifted, or certifies that nothing did.
9
+ ## Why
14
10
 
15
- Built for CSS-to-Tailwind migrations, design-system swaps, stylesheet consolidation,
16
- and any refactor where "trust me, it's identical" isn't good enough.
11
+ Pixel-snapshot tools miss most CSS regressions: they can't force `:hover` / `:focus` / `:active`, can't see hidden or off-screen elements, can't reach between-breakpoint rules, and blur away sub-pixel drift. StyleProof reads the **computed style** of every element instead — every resolved longhand, every pseudo-element, the deltas `:hover` / `:focus` / `:active` apply (forced via CDP, no mouse), swept across each `@media` breakpoint.
17
12
 
18
- ```
19
- home@1280: 1 element(s) differ
20
- body > main:nth-child(2) > section:nth-child(5) > a:nth-child(1) (.cta)
21
- border-bottom-color: rgb(95, 202, 219) → rgb(229, 231, 235)
22
- border-bottom-style: none → solid
13
+ ## What the gate does
23
14
 
24
- [:hover] body > nav:nth-child(1) > a:nth-child(3)
25
- border-color: rgb(95, 202, 219) → (state no longer changes it)
15
+ On every PR, StyleProof captures a `StyleMap` from the HEAD and from the base branch, diffs them, and posts a Markdown comment:
26
16
 
27
- 1 DOM change(s), 2 computed-style difference(s), 1 state-delta difference(s) across 12 surfaces
28
- ```
17
+ - A summary line, then **one section per distinct change**, with a side-by-side before/after cropped screenshot and the property changes folded under a toggle.
18
+ - An **approval checkbox per change**, driving a `StyleProof` commit status: red until every change is signed off, green when there are none.
19
+ - No committed baseline to maintain — the diff is HEAD-vs-base, so the report is _exactly what this PR changes_.
29
20
 
30
- ---
31
-
32
- ## Why computed styles certify a refactor where pixels cannot
33
-
34
- Pixel diffing is the right tool for _catching_ visual drift, but it cannot _certify_ a
35
- refactor, because most of a stylesheet is invisible to a screenshot:
36
-
37
- - **Hover, focus, and active states.** A deleted `:hover` rule renders identically
38
- until a human points a mouse at it. This tool forces each pseudo-class through the
39
- Chrome DevTools Protocol (`CSS.forcePseudoState`) and records exactly what each
40
- state changes, including parent-state rules that restyle descendants. (`:focus`
41
- forces `:focus` and `:focus-visible` together.)
42
- - **Hidden elements.** A closed mobile menu is `display: none` in every screenshot,
43
- but its panel, items, and animations still have computed styles, and they're
44
- compared.
45
- - **Between-breakpoint rules.** Screenshots sample two or three viewports. The style
46
- map sweeps one width per `@media` band, so a dropped `max-width: 680px` override is
47
- caught even if your screenshot widths never land in that band.
48
- - **Sub-threshold drift.** Every pixel comparison needs a tolerance for antialiasing,
49
- and a tolerance is a place for a real 1px change to hide. Computed values need no
50
- tolerance: `13.5px` either equals `13.5px` or it doesn't.
51
- - **Declared motion.** Transitions and animations are captured as declared longhands
52
- (then frozen so every other value is a settled end state), so changing
53
- `transition: all .2s` to `.3s` is a diff even though no still image could see it.
54
-
55
- Screenshots and style maps complement each other: pixels catch what you forgot to
56
- model, the style map certifies what pixels can't see. Use both.
21
+ ## What a report looks like
57
22
 
58
- ## Install
23
+ One change — the hero CTA recoloured cyan → amber — posts as a single section: a side-by-side before/after cropped screenshot, a one-line summary, then the exact property change folded under a toggle.
59
24
 
60
- ```sh
61
- # from npm (peer dep: @playwright/test):
62
- npm i -D styleproof @playwright/test
63
- ```
25
+ ![A StyleProof report: the CTA button before (cyan) and after (amber), side by side](docs/demo-composite.png)
64
26
 
65
- Until the first npm release you can install straight from GitHub:
27
+ As it renders in the PR comment (colours become live swatches; the full table sits inside the toggle):
66
28
 
67
- ```sh
68
- npm i -D github:BenSheridanEdwards/styleproof @playwright/test
69
- ```
29
+ ```text
30
+ ### `a.btn-solid` · 1 element restyled
31
+ _landing @ 1280_
70
32
 
71
- Requirements: Node 18+, `@playwright/test` >= 1.40, a Chromium-capable Playwright
72
- install (`npx playwright install chromium`). The only runtime dependency is
73
- [`pngjs`](https://www.npmjs.com/package/pngjs) (pure JS, no native build).
33
+ `background-color` `rgb(95, 202, 219)` `rgb(245, 158, 11)`
74
34
 
75
- A runnable spec lives in [`example/`](example/).
76
-
77
- ## 60-second quickstart
78
-
79
- **1. Write a capture spec** listing your **surfaces** — each one a deterministic page
80
- state plus the viewport widths to sweep. `npx styleproof-init` scaffolds the file below
81
- (with a `settle()` helper and a `playwright.config.ts` if you don't have one); or write
82
- it by hand:
83
-
84
- ```ts
85
- // e2e/styleproof.spec.ts
86
- import { defineStyleMapCapture, type Surface } from 'styleproof';
87
-
88
- const SURFACES: Surface[] = [
89
- {
90
- key: 'home',
91
- go: async (page) => {
92
- await page.goto('/', { waitUntil: 'networkidle' });
93
- await page.evaluate(() => document.fonts.ready);
94
- },
95
- ignore: ['.live-feed'], // nondeterministic regions, skipped entirely
96
- widths: [1280, 768, 390], // one per @media band of the route's CSS
97
- },
98
- {
99
- key: 'home-menu-open', // states matter: model them as surfaces
100
- go: async (page) => {
101
- await page.goto('/', { waitUntil: 'networkidle' });
102
- await page.getByRole('button', { name: 'Menu' }).click();
103
- },
104
- widths: [390],
105
- },
106
- ];
107
-
108
- defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
35
+ Show the property change
36
+ | Property | Before | After |
37
+ | background-color | rgb(95, 202, 219) | rgb(245, 158, 11) |
109
38
  ```
110
39
 
111
- The spec is **inert** unless `STYLEMAP_DIR` is set, so it costs nothing in a normal
112
- test run.
40
+ ## Works with any styling system
113
41
 
114
- **2. Capture a baseline and commit it** **always against a production build**, dev
115
- servers inject their own styles:
42
+ StyleProof reads the browser's **computed styles** — the values it actually resolves — never your source CSS. Tailwind, CSS Modules, styled-components, Sass, vanilla CSS, inline styles: all produce the same computed output, and that's what it diffs. Elements are keyed by **DOM structure, not class name**, so a refactor that rewrites every `class` still lines up element-for-element.
116
43
 
117
- ```sh
118
- STYLEMAP_DIR=baseline npx playwright test styleproof # writes __stylemaps__/baseline/*.json.gz + *.png
119
- git add e2e/__stylemaps__/baseline && git commit -m "stylemap baseline"
120
- ```
44
+ ## Certify a refactor
121
45
 
122
- The `.json.gz` maps are small (a content-heavy page gzips to ~80 KB); the `.png`
123
- full-page screenshots let the report crop changed regions later.
46
+ The same engine has a second mode that proves a change touched _nothing_ visual: with `fail-on-diff: true`, any difference at all fails the job. It's the job StyleProof was born for — certifying a CSS-to-Tailwind migration rendered byte-for-byte identical. Reach for it on any change whose whole promise is "the output is unchanged": a utility-class migration, a design-system swap, a dependency or build-tooling bump. Zero diff is the contract; one drifting longhand is a regression to investigate, not a change to approve.
124
47
 
125
- **3. Let CI diff every push** against that committed baseline:
48
+ ## Install
126
49
 
127
- ```sh
128
- STYLEMAP_DIR=ci npx playwright test styleproof
129
- npx styleproof-diff e2e/__stylemaps__/baseline e2e/__stylemaps__/ci
50
+ ```bash
51
+ npm install -D styleproof @playwright/test
52
+ npx playwright install chromium
130
53
  ```
131
54
 
132
- An empty diff exits `0` (certified). A non-empty diff exits `1` and names every
133
- element, property, and state that drifted. After an _intentional_ change, regenerate
134
- the baseline and commit it with your diff — the gate is a ratchet, not a freeze.
135
-
136
- ## Core concepts
137
-
138
- ### StyleMap
139
-
140
- A `StyleMap` is the JSON snapshot of one page state at one width. It has three layers:
55
+ Requires **Node 18** (ESM), **`@playwright/test` 1.40** (peer dep). Forced states are Chromium-only.
141
56
 
142
- - **`elements`** — every element's computed style, pruned against per-tag UA defaults
143
- (measured live in a stylesheet-free iframe) so files stay small, plus its
144
- pseudo-elements and its document-space bounding box (so reports can crop the
145
- screenshot around it).
146
- - **`states`** — for every interactive element (`a, button, input, textarea, select,
147
- summary, [role="button"], [tabindex]`), the delta that `:hover`, `:focus` /
148
- `:focus-visible`, and `:active` apply, forced via CDP with no real mouse or focus,
149
- captured over the element's whole subtree so parent-state descendant rules are seen.
150
- - **`defaults`** — the per-tag UA baseline used to prune `elements`, kept so the diff
151
- can resolve a pruned property back to its default when only one side set it.
57
+ ## Quickstart
152
58
 
153
- ### Surfaces
154
-
155
- A **surface** is one deterministic page state worth certifying. You give it a `key`, a
156
- `go(page)` function that navigates and drives the page to that state (ending
157
- _settled_: fonts loaded, entrance animations done), an optional `ignore` list of
158
- selectors for nondeterministic regions, and the `widths` to sweep. Distinct states
159
- (menu open, dialog open, selected tab, form error) are distinct surfaces.
160
-
161
- ### DOM-structure keys
162
-
163
- Every element is keyed by its structural path, `body > nav:nth-child(3) >
164
- a:nth-child(2)`, **never by class name**. A migration can rewrite every `class`
165
- attribute freely while the map stays comparable. If the DOM itself changes, the diff
166
- says so loudly under a `DOM` finding: a CSS-only refactor must not touch structure.
167
-
168
- Custom properties (`--*`) are deliberately ignored: they are inputs, not outcomes.
169
- Every visual effect of a variable lands in a real longhand that _is_ compared, so
170
- renaming a token is invisible while changing what an element resolves to is not.
171
- (This also silences Tailwind's `--tw-*` machinery.)
172
-
173
- ### Derived / reflow-noise filtering
174
-
175
- The **certification differ** (`styleproof-diff`) compares everything, including
176
- layout-derived longhands (`width`, `height`, `top`, `transform-origin`…), because a
177
- reflow _is_ a change to certify. The **report** filters those by default: on a reflow
178
- they change all the way up the ancestor chain (`body`, `main`, `section`…), and an
179
- element whose _only_ changes are derived is a reflow casualty, not a styling change,
180
- so it must not anchor a crop region (that would zoom the crop to the whole page).
181
- Pass `includeLayoutNoise: true` to keep them in the report too.
182
-
183
- ### The readable report
184
-
185
- When a diff is _intentional_, you want to look at it, not read a wall of longhands.
186
- `styleproof-report` crops the before/after screenshots around the **outermost changed
187
- element** (descendants fold into their ancestor, nearby regions merge), stitches each
188
- pair into one labelled side-by-side image (grey bar = before, blue bar = after), and
189
- writes the exact changes as a per-element table. It collapses the noise: shorthand
190
- families (`padding: 26px 24px → 28px`), logical / `currentColor` duplicates dropped,
191
- repeated tokens folded (`368px ×3`), identical sibling elements grouped (`×5`),
192
- labelled by their semantic class, colours in their own cells so GitHub renders
193
- swatches.
194
-
195
- ![before ◀ │ ▶ after](docs/demo-composite.png)
196
-
197
- <sub>◀ before · after ▶ — grey bar = before, blue bar = after</sub>
198
-
199
- > **`div.who-grid`**
200
- >
201
- > | Property | Before | After |
202
- > | ----------------------- | -------------------- | -------------------- |
203
- > | `grid-template-columns` | `368px ×3` | `548px ×2` |
204
- > | `gap` | `1px` | `12px` |
205
- > | `border-color` | `rgb(150, 172, 205)` | `rgb(229, 231, 235)` |
206
- >
207
- > **`h3`** ×5 · **`div.who`** ×3 …
208
-
209
- The whole imaging pipeline is Playwright + Node, **no browser interaction**. Captures
210
- save a full-page screenshot next to each map by default, so the committed baseline
211
- carries both the facts and the pixels; generating a report never rebuilds the old code.
212
-
213
- ## API reference
214
-
215
- Everything is exported from the package root:
59
+ **1. Scaffold the capture spec** (`npx styleproof-init` writes `e2e/styleproof.spec.ts`), then describe your surfaces:
216
60
 
217
61
  ```ts
218
- import {
219
- captureStyleMap,
220
- saveStyleMap,
221
- loadStyleMap,
222
- defineStyleMapCapture,
223
- diffStyleMaps,
224
- diffStyleMapDirs,
225
- findingLabel,
226
- generateStyleMapReport,
227
- summarizeProps,
228
- prettyLabel,
229
- } from 'styleproof';
230
- ```
231
-
232
- | Export | Signature | Description |
233
- | ------------------------ | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
234
- | `captureStyleMap` | `(page: Page, options?: CaptureOptions) => Promise<StyleMap>` | Capture the page's complete style map. Drive the page to the target state first; it reads whatever is in front of it. Records elements, pseudo-elements, forced `:hover`/`:focus`/`:active` deltas, declared motion, and each element's bounding box. |
235
- | `saveStyleMap` | `(filePath: string, map: StyleMap) => void` | Write a map to disk; gzipped when the path ends in `.gz`. Creates parent dirs. |
236
- | `loadStyleMap` | `(filePath: string) => StyleMap` | Read a map written by `saveStyleMap` (`.json` or `.json.gz`). |
237
- | `defineStyleMapCapture` | `(opts: DefineOptions) => void` | Generate one Playwright test per surface × width, saving `<baseDir>/<dir>/<key>@<width>.json.gz` (+ `.png`). Skips entirely when `dir` is undefined, so the spec is inert in normal runs. Call at module top level in a `.spec.ts`. |
238
- | `diffStyleMaps` | `(a: StyleMap, b: StyleMap) => Finding[]` | Structured diff of two maps for the same surface: DOM, style, and state findings. |
239
- | `diffStyleMapDirs` | `(dirA: string, dirB: string) => { surfaces: SurfaceDiff[]; counts: DiffCounts }` | Diff every same-named capture between two directories. Throws if neither dir has any `.json(.gz)` captures. |
240
- | `findingLabel` | `(path: string, cls: string) => string` | Human label: structural path plus a truncated `.class` hint (first 3 classes). |
241
- | `generateStyleMapReport` | `(opts: ReportOptions) => ReportResult` | Crop the before/after screenshots around changed regions and write `report.md`, `report.json`, and `crops/*.png`. |
242
- | `summarizeProps` | `(props: PropChange[]) => PropChange[]` | The report's property collapser: dedupe logical aliases, fold `currentColor` echoes, collapse shorthand families (`padding: 26px 24px`), round and run-length values. Exported for downstream report builders. |
243
- | `prettyLabel` | `(path: string, cls: string) => string` | Label an element by its semantic marker class (`div.who-grid`) rather than its structural path. |
244
-
245
- ### Key exported types
246
-
247
- ```ts
248
- type Rect = [number, number, number, number]; // document-space [x, y, w, h], rounded
249
-
250
- type StyleMap = {
251
- defaults: Record<string, Props>; // per-tag UA baseline
252
- elements: Record<string, ElementEntry>; // keyed by structural path
253
- states: Record<string, Record<string, Record<string, Props>>>; // path → state → subpath → props
254
- };
255
-
256
- type ElementEntry = {
257
- tag: string;
258
- cls: string;
259
- rect?: Rect;
260
- style: Props; // Record<string, string>
261
- pseudo?: Record<string, Props>; // '::before' | '::after' | '::marker' | '::placeholder'
262
- };
263
-
264
- type CaptureOptions = {
265
- ignore?: string[]; // selectors skipped with their subtrees
266
- captureStates?: boolean; // capture forced :hover/:focus/:active deltas (default true)
267
- maxInteractive?: number; // cap forced-state elements per surface (default 800)
268
- };
269
-
270
- type Surface = {
271
- key: string; // unique file-name prefix
272
- go: (page: Page) => Promise<void>;
273
- ignore?: string[];
274
- widths: number[]; // one per @media band
275
- height?: number | ((width: number) => number); // default 800
276
- };
277
-
278
- type DefineOptions = {
279
- surfaces: Surface[];
280
- dir: string | undefined; // capture label; undefined = skip
281
- baseDir?: string; // default '__stylemaps__'
282
- screenshots?: boolean; // default true
283
- };
284
-
285
- type PropChange = { prop: string; before: string; after: string };
286
-
287
- type Finding =
288
- | { kind: 'dom'; path: string; cls: string; change: 'added' | 'removed' | 'retagged'; detail?: string }
289
- | { kind: 'style'; path: string; cls: string; pseudo: string | null; props: PropChange[] }
290
- | { kind: 'state'; path: string; cls: string; state: string; sub: string; props: PropChange[] };
291
-
292
- type SurfaceDiff = { surface: string; missing?: 'before' | 'after'; findings: Finding[] };
293
- type DiffCounts = { dom: number; style: number; state: number };
294
-
295
- type ReportOptions = {
296
- beforeDir: string;
297
- afterDir: string;
298
- outDir: string;
299
- imageBaseUrl?: string; // prefix for image URLs in report.md (default: relative paths)
300
- pad?: number; // padding around changed rects (default 24)
301
- minWidth?: number; // default 320
302
- minHeight?: number; // default 180
303
- maxHeight?: number; // crops clamped to this (default 1600)
304
- maxCrops?: number; // regions per surface before collapsing to one (default 6)
305
- includeLayoutNoise?: boolean; // keep size/position-derived longhands (default false)
306
- };
307
-
308
- type ReportResult = {
309
- changedSurfaces: number;
310
- totalFindings: number;
311
- reportMdPath: string;
312
- reportJsonPath: string;
313
- };
314
- ```
315
-
316
- ```ts
317
- // Example: programmatic use, same engines as the CLIs.
318
- const map = await captureStyleMap(page, { ignore: ['.live-feed'] });
319
- saveStyleMap('maps/home@1280.json.gz', map);
320
- const before = loadStyleMap('maps/home@1280.json.gz');
321
-
322
- const findings = diffStyleMaps(before, map);
323
- const { surfaces, counts } = diffStyleMapDirs('maps/before', 'maps/after');
324
- generateStyleMapReport({ beforeDir: 'maps/before', afterDir: 'maps/after', outDir: 'report' });
325
- ```
326
-
327
- ## CLI reference
328
-
329
- The three bins are installed on your PATH (run via `npx` or an npm script). They read
330
- the built `dist/`, so they work from the installed package out of the box. Each takes
331
- `-h`/`--help`.
332
-
333
- ### `styleproof-init` — scaffold a capture spec
334
-
335
- ```
336
- styleproof-init [--dir <path>] [--base-url <url>] [--force]
337
- ```
338
-
339
- | Flag | Default | Description |
340
- | ------------------ | ------------------------ | ----------------------------------------------------------------------- |
341
- | `--dir <path>` | `e2e/styleproof.spec.ts` | Where to write the starter spec (with a `settle()` helper + a Surface). |
342
- | `--base-url <url>` | `http://localhost:3000` | `baseURL` for a generated `playwright.config.ts` (only if none exists). |
343
- | `--force` | off | Overwrite the spec if it already exists. Idempotent without it. |
344
-
345
- Exit `0` on success (or nothing to do), `2` on a usage error. An existing
346
- `playwright.config.ts` is never touched.
347
-
348
- ### `styleproof-diff` — the certification gate
349
-
350
- ```
351
- styleproof-diff <beforeDir> <afterDir> [--max N] [--json <file>]
62
+ import { defineStyleMapCapture } from 'styleproof';
63
+
64
+ defineStyleMapCapture({
65
+ dir: process.env.STYLEMAP_DIR, // inert until set, so it lives safely beside other tests
66
+ surfaces: [
67
+ {
68
+ key: 'landing',
69
+ go: async (page) => {
70
+ await page.goto('/');
71
+ await page.waitForLoadState('networkidle');
72
+ await page.evaluate(() => document.fonts.ready);
73
+ },
74
+ widths: [1280, 768, 390], // one viewport per @media band
75
+ },
76
+ ],
77
+ });
352
78
  ```
353
79
 
354
- | Flag | Default | Description |
355
- | ------------------------------------ | ------- | --------------------------------------------------------------------------------------------- |
356
- | `<beforeDir>` | — | Directory of baseline `.json(.gz)` captures (required). |
357
- | `<afterDir>` | — | Directory of fresh `.json(.gz)` captures (required). |
358
- | `--max N` (or `--max=N`) | `40` | Max diff lines printed per surface before truncating. (The GitHub Action passes `--max=200`.) |
359
- | `--json <file>` (or `--json=<file>`) | — | Also write the structured `{ counts, surfaces }` result to `<file>`. |
80
+ **2. Wire CI to capture base and head, then hand both to the Action:**
360
81
 
361
- | Exit code | Meaning |
362
- | --------- | ---------------------------------------------------------------------------------------------- |
363
- | `0` | Identical — every computed style, pseudo-element, and state matches. **Certified.** |
364
- | `1` | Differences found (DOM, style, and/or state). |
365
- | `2` | Usage or capture error (bad args, missing dir, no captures, non-finite `--max`, unknown flag). |
82
+ ```yaml
83
+ # .github/workflows/styleproof.yml
84
+ name: StyleProof
85
+ on: pull_request
366
86
 
367
- ### `styleproof-report` — the reviewable visual report
87
+ jobs:
88
+ styleproof:
89
+ runs-on: ubuntu-latest
90
+ permissions:
91
+ contents: write # push the report branch
92
+ pull-requests: write # post/update the comment
93
+ statuses: write # set the StyleProof status
94
+ steps:
95
+ - uses: actions/checkout@v4
96
+ with:
97
+ fetch-depth: 0 # need the base branch too
368
98
 
369
- ```
370
- styleproof-report <beforeDir> <afterDir> --out <dir> [options]
371
- ```
99
+ # capture the base branch
100
+ - run: git checkout ${{ github.event.pull_request.base.sha }}
101
+ - run: npm ci && npm run build && (npm run serve &) # your framework's build + serve
102
+ - run: npx wait-on http://localhost:3000
103
+ - run: STYLEMAP_DIR=base npx playwright test e2e/styleproof.spec.ts
372
104
 
373
- | Flag | Default | Description |
374
- | -------------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
375
- | `<beforeDir>` | — | Baseline captures (`.json.gz` + `.png` for crops). |
376
- | `<afterDir>` | — | Fresh captures (`.json.gz` + `.png` for crops). |
377
- | `--out <dir>` (or `--out=<dir>`) | `styleproof-report` | Output directory: writes `report.md`, `report.json`, `crops/*.png`. |
378
- | `--image-base-url <url>` (or `=<url>`) | relative paths | Prefix for image URLs in `report.md` (e.g. a raw GitHub URL). Omit to keep relative paths that render from a committed file. |
379
- | `--pad <px>` | `24` | Padding around the changed rects when cropping. |
380
- | `--max-crops <n>` | `6` | Max crop regions per surface before collapsing to one union crop. |
381
- | `--min-width <px>` | `320` | Minimum crop width, for context around tiny changes. |
382
- | `--min-height <px>` | `180` | Minimum crop height. |
383
- | `--include-layout-noise` | off | Keep size/position-derived longhands (`height`, `width`, `transform-origin`, `top`…) in the report instead of filtering them. |
384
-
385
- | Exit code | Meaning |
386
- | --------- | ----------------------------------------------------------------------------- |
387
- | `0` | No changes — an empty report was written. |
388
- | `1` | Report generated (one or more changed surfaces). |
389
- | `2` | Usage error (wrong arg count, unknown/non-numeric flag, or capture/IO error). |
390
-
391
- Numeric flags also accept the `--flag=value` form. The remaining `maxHeight` knob
392
- (crop height clamp, default 1600) is available through the programmatic
393
- `generateStyleMapReport` API.
394
-
395
- ## GitHub Action
396
-
397
- The repo ships a composite action that diffs two capture dirs and, on changes, commits a
398
- compact report to an orphan branch and posts it to the PR. The comment updates in place on
399
- every push and flips to ✓ when clean. It runs in one of two modes — **certify a refactor**
400
- (default) or **review visual changes** with per-change sign-off (`require-approval`); see
401
- [Two modes](#two-modes-certify-a-refactor-or-review-visual-changes) below.
105
+ # capture the PR head
106
+ - run: git checkout ${{ github.event.pull_request.head.sha }}
107
+ - run: npm ci && npm run build && (npm run serve &)
108
+ - run: npx wait-on http://localhost:3000
109
+ - run: STYLEMAP_DIR=head npx playwright test e2e/styleproof.spec.ts
402
110
 
403
- ```yaml
404
- - name: Capture style maps
405
- run: STYLEMAP_DIR=ci npx playwright test styleproof
406
-
407
- - name: Style-map report
408
- uses: BenSheridanEdwards/styleproof@v1
409
- with:
410
- baseline-dir: e2e/__stylemaps__/baseline
411
- fresh-dir: e2e/__stylemaps__/ci
412
- # report-branch: styleproof-reports # default (orphan; created on first run)
413
- # inline-images: auto # auto | always | never
414
- # fail-on-diff: 'true' # default
111
+ # report + gate
112
+ - uses: BenSheridanEdwards/styleproof@v1
113
+ with:
114
+ baseline-dir: base
115
+ fresh-dir: head
116
+ require-approval: true # review-gate mode (omit / use fail-on-diff: true to certify)
415
117
  ```
416
118
 
417
- ### Inputs
418
-
419
- | Input | Required | Default | Description |
420
- | ------------------ | -------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
421
- | `baseline-dir` | yes | — | Directory with the committed baseline captures (`.json.gz` + `.png`). |
422
- | `fresh-dir` | yes | — | Directory with the freshly captured maps to compare. |
423
- | `report-branch` | no | `styleproof-reports` | Orphan branch that stores reports (created on first run). One `pr-<n>/` folder per PR; never pruned. |
424
- | `inline-images` | no | `auto` | `auto` \| `always` \| `never`. `auto` embeds composites in the comment for public repos and links the report for private repos. |
425
- | `github-token` | no | `${{ github.token }}` | Token used to push the report branch and post the comment. |
426
- | `fail-on-diff` | no | `'true'` | Legacy refactor mode: fail the job on any diff. Ignored when `require-approval` is `'true'`. |
427
- | `require-approval` | no | `'false'` | Review-gate mode: set a `StyleProof` commit status (red until each change is approved) instead of failing the job. See below. |
428
- | `status-context` | no | `StyleProof` | Name of the commit status set in review-gate mode (must match the approve workflow + branch protection). |
429
-
430
- ### Outputs
431
-
432
- | Output | Description |
433
- | ------------ | ------------------------------------------------------------------- |
434
- | `changed` | `"true"` when any computed style, pseudo-element, or state changed. |
435
- | `report-url` | Blob URL of the committed report (when changed). |
436
-
437
- ### Two modes: certify a refactor, or review visual changes
438
-
439
- - **Certify (default, `fail-on-diff: true`).** Any computed-style change fails the job.
440
- Right for a refactor you expect to be a no-op (a CSS-to-Tailwind migration); "approving"
441
- a change means regenerating the committed baseline and pushing it.
442
- - **Review gate (`require-approval: true`).** The report is the product: every PR shows
443
- _what changed visually_, and a reviewer signs each change off against the PR's stated
444
- intent. Instead of failing the job, the Action sets a **`StyleProof` commit status** —
445
- green when nothing changed, red until approved — and posts **one approval checkbox per
446
- change**. The gate goes green only when **every** box is ticked.
447
-
448
- To enable the review gate:
449
-
450
- 1. Set `require-approval: 'true'` on the Action, and grant the job `statuses: write`
451
- (plus the existing `contents: write` / `pull-requests: write`).
452
- 2. Copy [`example/styleproof-approve.yml`](example/styleproof-approve.yml) to
453
- `.github/workflows/` **on your default branch** (`issue_comment` workflows only run
454
- from the default branch).
455
- 3. Add a branch-protection rule that **requires the `StyleProof` status** to pass.
456
-
457
- A reviewer with write access walks the report change-by-change and ticks each box; the
458
- status updates ("2 of 3 approved") and flips green at full approval. A new push that
459
- changes styles re-opens the gate. The approval is bound to the exact reviewed commit and
460
- acts only on a human edit of the bot's own comment, so it can't be flipped green by a
461
- later push, by the bot's own comment updates, or by anyone without write access. Pair it
462
- naturally with a head-vs-**base-branch** diff (capture the base branch into `baseline-dir`)
463
- so each PR's report is exactly _what this PR changes_.
464
-
465
- Two things to know about the trust model:
466
-
467
- - **Single-reviewer by design.** Any write-access user can tick the boxes, **including the
468
- PR author on their own PR** — the sign-off is "I confirm this is intentional," not
469
- multi-party review. If you need a different person to approve, add a branch-protection
470
- rule requiring a review from someone other than the author (CODEOWNERS); this gate
471
- doesn't enforce that itself.
472
- - **Use the default `GITHUB_TOKEN`.** The approve workflow only acts on a comment authored
473
- by a bot (so an attacker-authored comment can never trigger it). If you post the report
474
- with a personal access token (a `User`), the workflow won't fire and the gate can never
475
- go green. A GitHub App token works; a PAT does not.
476
-
477
- ### Why two image modes (and why it's automatic)
478
-
479
- The two ways an image can appear on a PR have **opposite** privacy behaviour, so
480
- `inline-images: auto` picks per repo:
481
-
482
- | Placement | How GitHub fetches it | Private repo |
483
- | ----------------------------------------------------- | -------------------------------------- | --------------------------------------------------- |
484
- | **comment body** `![](url)` | anonymously, via the Camo proxy | private URL **404s → broken**; needs a public URL |
485
- | **committed file** (`report.md` + relative `crops/…`) | through **your authenticated session** | **renders inline** — like a private README's images |
486
-
487
- - **Public repo** → composites are embedded **directly in the comment** via the public
488
- `raw.githubusercontent.com/…` URL; you see the side-by-side without clicking.
489
- - **Private repo** → the comment **links** the committed `report.md`; one click shows
490
- the crops inline, no public hosting, no browser.
491
-
492
- (Embedding an image _directly in a private comment body_ is genuinely impossible from
493
- CI — GitHub's only private-friendly image URL is `user-attachments`, whose upload
494
- endpoint rejects API tokens with HTTP 422 and needs a logged-in browser session.)
495
-
496
- ### Orphan-branch layout and stable links
497
-
498
- The report branch is an **orphan** (reports only, never your code), so its root is one
499
- folder per PR plus a README:
500
-
501
- ```
502
- styleproof-reports (orphan)
503
- ├── README.md
504
- ├── pr-9/ report.md + crops/*-composite.png
505
- └── pr-12/ …
506
- ```
119
+ **3. Copy [`example/styleproof-approve.yml`](https://github.com/BenSheridanEdwards/styleproof/blob/main/example/styleproof-approve.yml) to `.github/workflows/` on your default branch** — GitHub only runs `issue_comment` workflows from there, so the checkboxes do nothing until it's merged.
507
120
 
508
- - **Stable links.** Each run overwrites `pr-<n>/`, so
509
- `…/blob/styleproof-reports/pr-9/report.md` is permanent for the life of the PR.
510
- Reports are **never pruned**; per-run history lives in the branch's git commits.
511
- - **Compact.** Only the **composite** is committed (alpha dropped, max deflate,
512
- adaptive filtering): ~30–90 KB per change, a few hundred KB per PR.
513
- - **Concurrency-safe.** Different PRs touch different folders; a rejected push just
514
- rebase-replays (the action retries).
515
- - **Reclaiming history.** Git keeps every overwritten image, so a busy repo's branch
516
- history grows. The _tree_ (what serves the links) stays small; to shrink `.git`,
517
- squash the orphan branch when it's quiet — current reports keep their URLs:
518
- ```sh
519
- git checkout --orphan tmp styleproof-reports && git commit -qm "squash reports" \
520
- && git branch -M tmp styleproof-reports && git push -f origin styleproof-reports
521
- ```
522
-
523
- ## CI recipes
524
-
525
- Every recipe is the same shape: produce a **production build**, serve it, point
526
- `BASE_URL` (or your Playwright `baseURL`) at it, capture, diff against the committed
527
- baseline. Use the Action step from above to also post a PR comment.
528
-
529
- ### Next.js
121
+ **4. Require the `StyleProof` status** in branch protection. Now an unsigned visual change can't merge.
530
122
 
531
- ```yaml
532
- - uses: actions/checkout@v4
533
- - uses: actions/setup-node@v4
534
- with: { node-version: 20, cache: npm }
535
- - run: npm ci
536
- - run: npx playwright install --with-deps chromium
537
- - run: npm run build
538
- - run: npx next start -p 3000 &
539
- - run: npx wait-on http://localhost:3000
540
- - run: BASE_URL=http://localhost:3000 STYLEMAP_DIR=ci npx playwright test styleproof
541
- - run: npx styleproof-diff e2e/__stylemaps__/baseline e2e/__stylemaps__/ci
542
- ```
123
+ > Capture both sides in the **same environment** (same machine, same env vars): if env vars change _what renders_, base and head will diff on DOM no PR touched.
543
124
 
544
- ### Vite / SPA
125
+ ## Reference
545
126
 
546
- ```yaml
547
- - run: npm ci && npx playwright install --with-deps chromium
548
- - run: npm run build # → dist/
549
- - run: npx vite preview --port 4173 & # serves the production build
550
- - run: npx wait-on http://localhost:4173
551
- - run: BASE_URL=http://localhost:4173 STYLEMAP_DIR=ci npx playwright test styleproof
552
- - run: npx styleproof-diff e2e/__stylemaps__/baseline e2e/__stylemaps__/ci
553
- ```
127
+ **Action `BenSheridanEdwards/styleproof@v1`** — key inputs:
554
128
 
555
- ### Plain static site
129
+ | Input | Default | Purpose |
130
+ | ------------------ | ------------ | -------------------------------------------------------------------------- |
131
+ | `baseline-dir` | _required_ | Base-branch captures. |
132
+ | `fresh-dir` | _required_ | PR-head captures to compare. |
133
+ | `require-approval` | `false` | Review-gate mode: set the `StyleProof` status instead of failing. |
134
+ | `fail-on-diff` | `true` | Certify mode: fail on any diff. Ignored when `require-approval` is true. |
135
+ | `status-context` | `StyleProof` | Commit-status name. Must match the approve workflow and branch protection. |
556
136
 
557
- ```yaml
558
- - run: npm ci && npx playwright install --with-deps chromium
559
- - run: npm run build # → public/ or dist/
560
- - run: npx serve -l 5000 dist & # any static server
561
- - run: npx wait-on http://localhost:5000
562
- - run: BASE_URL=http://localhost:5000 STYLEMAP_DIR=ci npx playwright test styleproof
563
- - run: npx styleproof-diff e2e/__stylemaps__/baseline e2e/__stylemaps__/ci
564
- ```
137
+ Outputs: `changed` (`"true"` when anything changed), `report-url`. Other inputs (`report-branch`, `inline-images`, `github-token`) have sensible defaults — see [`action.yml`](https://github.com/BenSheridanEdwards/styleproof/blob/main/action.yml).
565
138
 
566
- ### Generic build serve capture diff
139
+ **CLIs** (every flag accepts `--flag value` and `--flag=value`; `--help` lists all):
567
140
 
568
- ```sh
569
- <your build command> # produce a PRODUCTION build
570
- <your static/app server> & # serve it on a known port
571
- npx wait-on http://localhost:<port> # wait until it answers
572
- BASE_URL=http://localhost:<port> STYLEMAP_DIR=ci npx playwright test styleproof
573
- npx styleproof-diff e2e/__stylemaps__/baseline e2e/__stylemaps__/ci
574
- ```
141
+ - `styleproof-init` — scaffold the capture spec (and a starter `playwright.config.ts` if none exists).
142
+ - `styleproof-diff <beforeDir> <afterDir>` the certify gate; exits `1` on any difference.
143
+ - `styleproof-report <beforeDir> <afterDir> --out <dir>` render the diff to a Markdown report with before/after crops.
575
144
 
576
- Your `playwright.config.ts` reads `BASE_URL` for `use.baseURL`; surfaces use relative
577
- paths (`page.goto('/')`).
578
-
579
- ## Baselines and the env-parity gotcha
580
-
581
- A baseline is the committed `before` of your refactor and the certified state CI
582
- compares against. **Capture it in the same environment CI uses.** Anything that
583
- changes what renders must match between the baseline capture and CI's fresh capture:
584
-
585
- - **Feature flags** that show or hide a panel.
586
- - **API tokens** that gate a section (e.g. a live-data widget only present when a key
587
- is set).
588
- - **Env-dependent copy** (a `mailto:` line built from an env var, a conditional embed).
589
-
590
- If your local `.env` makes the page render _more_ than CI will, baselines captured
591
- locally can never pass on the runner: the extra elements exist on one side only and
592
- show up as DOM findings. The fix is to capture the baseline with the **same** env the
593
- runner has (typically no `.env.local`), then commit it. After an _intentional_ style
594
- change, regenerate the committed baseline from a production build in that same
595
- environment and commit it with your diff.
596
-
597
- ## Determinism
598
-
599
- Captures read whatever is in front of them, so the page must be **settled and
600
- repeatable** before `captureStyleMap` runs. The differ has zero tolerance, so any
601
- nondeterminism becomes a false diff. In your surface's `go`:
602
-
603
- - **Fonts.** `await page.evaluate(() => document.fonts.ready)` before capturing —
604
- unloaded fonts change `font-family` fallbacks and metrics.
605
- - **Animations / transitions.** The capture freezes them so every value is a settled
606
- end state, but _entrance_ animations driven by JS or IntersectionObserver must
607
- already have finished. Scroll the page to trigger reveals, then wait; or inject CSS
608
- forcing your reveal classes to their final values
609
- (`.reveal{opacity:1!important;transform:none!important}`).
610
- - **Scroll-reveal.** Walk the page top to bottom (see `example/styleproof.spec.ts`'s
611
- `settle` helper) so every observer fires, then scroll back to `0`.
612
- - **Live / nondeterministic regions.** List them in `ignore` (live feeds, ads,
613
- third-party embeds, timestamps); the elements and their subtrees are skipped.
614
- - **Same build state.** Layout-derived values are part of the map by design, so if
615
- text content differs between captures, expect diffs. Capture the same build.
616
-
617
- ## Limitations
618
-
619
- - **Chromium only for forced-state capture.** The `:hover`/`:focus`/`:active` deltas
620
- use CDP (`CSS.forcePseudoState`), which is Chromium-only. Base element and
621
- pseudo-element capture work in any Playwright browser.
622
- - **Same machine, same browser version for before/after.** Computed values are far
623
- less platform-sensitive than pixels, but font metrics can still differ across OSes
624
- and browser builds. Capture both sides on the same runner image.
625
- - **No shadow-DOM piercing.** Capture walks `document.querySelectorAll('body *')`; it
626
- does not descend into shadow roots, so a refactor inside a web component's shadow
627
- tree would be falsely certified identical. Capture emits a one-time warning counting
628
- the shadow hosts it skipped, so the gap is loud, not silent.
629
- - **No iframe piercing.** Iframe content (same- or cross-origin) is not traversed for
630
- the same reason; same-origin frames are counted in the same warning. To certify a
631
- frame, point a separate surface at its document directly.
632
- - **Layout-derived values are included** (used track sizes, element heights, offsets).
633
- This is intentional for the certification differ, but it means a content or reflow
634
- change produces diffs; the report filters these unless `includeLayoutNoise: true`.
635
- - **Forced-state capture is O(interactive elements × 3 states).** A content-heavy page
636
- takes a few seconds per surface.
637
-
638
- ## Troubleshooting
639
-
640
- | Symptom | Likely cause / fix |
641
- | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
642
- | `npx playwright test styleproof` does nothing / spec skipped | `STYLEMAP_DIR` is unset. The capture spec is inert by design until you set it (`STYLEMAP_DIR=before …`). |
643
- | `no .json(.gz) captures found in …` | You diffed before capturing, or pointed at the wrong dir. Check `<baseDir>/<label>/`. |
644
- | `styleproof: interactive-element count skew …; skipping forced … capture` | The DOM changed between the CDP query and the page evaluate (e.g. a late-rendering widget). The base + pseudo capture still succeed; only the forced-state layer is skipped for that surface. Settle the surface fully, or `ignore` the unstable region, then re-run. |
645
- | Capture warns about shadow hosts / iframes | Styles inside shadow roots and frames are not captured (see Limitations). Point a separate surface at the frame's document, or accept the gap. |
646
- | Diffs you didn't expect, all `width`/`height`/`top`/… | A real reflow (content or layout changed). The differ keeps these; use the _report_ (which filters them) to see the styling intent, or capture the identical build state. |
647
- | Baseline passes locally, fails in CI with DOM findings | Env parity: your local env renders elements CI doesn't (or vice versa). Capture the baseline in CI's environment — see _Baselines and the env-parity gotcha_. |
648
- | Diffs every run, fonts-related | Fonts weren't ready at capture. Await `document.fonts.ready` (and any web-font load) in `go`. |
649
- | Private-repo PR comment shows no images, only a link | Expected: GitHub can't render images in a private comment body. Click the link — the committed report renders the crops inline through your session. |
650
- | `styleproof-diff: command not found` from a fresh clone | The bins import the built `dist/`. Run `npm run build` first (the published npm package ships a prebuilt `dist/`). |
651
-
652
- ## How this compares to pixel snapshot tools
653
-
654
- | | **StyleProof** | **Percy / Chromatic** | **Playwright `toHaveScreenshot`** |
655
- | -------------------------------------------------------------------------- | ------------------------------- | ------------------------- | --------------------------------- |
656
- | Compares | computed CSS longhands | rendered pixels | rendered pixels |
657
- | Certifies invisible state (hover/focus/active, hidden, between-breakpoint) | **yes** | no | no |
658
- | Tolerance needed | **none** (exact values) | antialiasing threshold | `maxDiffPixels` threshold |
659
- | Cross-browser / cross-OS fidelity | values only (no real render) | **strong** (real renders) | good |
660
- | Catches what it wasn't told to model | no (model your surfaces) | **yes** (whole frame) | yes (the frame) |
661
- | Hosted dashboard / approvals UI | git-based (commit the baseline) | **yes** (SaaS) | local/CI files |
662
- | Cost | free, MIT, self-hosted | paid SaaS (free tiers) | free |
663
-
664
- They solve different halves of the problem. Pixel tools _catch_ drift you didn't think
665
- to check, across real browser renders. The style map _certifies_ that a refactor
666
- changed nothing, including the states and rules a screenshot can never see, with no
667
- tolerance to hide a real change in. **Run both:** screenshots for discovery, the style
668
- map for proof.
669
-
670
- ## Battle-tested: what it caught
671
-
672
- This tool was extracted from a real CSS-to-Tailwind migration (~680 lines of bespoke
673
- CSS across four stylesheets, certified to zero diff). Every one of these was caught by
674
- the style map and invisible or ambiguous to pixels:
675
-
676
- - **A base-layer reset eating button borders.** `button { border: none }` in the
677
- global stylesheet meant `border` + `border-color` utilities (width and colour only)
678
- rendered _no border at all_. Every bordered button needed an explicit
679
- `border-solid`. The diff named all of them.
680
- - **`grid-cols-2` is not `1fr 1fr`.** Tailwind's `repeat(2, minmax(0, 1fr))` removes
681
- the min-content floor. One panel had been quietly overflowing its grid track by 8px;
682
- the utility version clamped it, reflowing 50 elements. On `display: none` elements
683
- the two forms even serialize differently.
684
- - **`outline-none` is not `outline: none`.** Tailwind's utility is a 2px transparent
685
- outline (an accessibility affordance). The forced-`:focus` capture flagged three new
686
- longhands the original never set.
687
- - **Shorthand resets.** `border-bottom: none` resets style _and_ colour to initial;
688
- zeroing just the width (`border-b-0`) leaves the preflight's gray `solid` behind.
689
- Same story for `hover:shadow-*` (ring placeholders), `rounded-full` (`9999px` vs
690
- `50%`), `items-start` (`flex-start` vs `start`), and named font utilities (dropped
691
- fallbacks from the stack).
692
- - **A dropped `:hover` rule on a link** that every screenshot tool sailed past, because
693
- nothing hovers in a screenshot.
694
-
695
- ## Contributing
696
-
697
- Issues and PRs welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the dev setup, the
698
- build/test loop, and the release process. Changes are recorded in
699
- [CHANGELOG.md](CHANGELOG.md).
145
+ A programmatic API (`captureStyleMap`, `diffStyleMaps`, `generateStyleMapReport`, …) is also exported. For the capture internals, the approve-workflow trust model, and how to contribute, see [CONTRIBUTING](https://github.com/BenSheridanEdwards/styleproof/blob/main/CONTRIBUTING.md) and the [`example/`](https://github.com/BenSheridanEdwards/styleproof/tree/main/example) workflows.
700
146
 
701
147
  ## License
702
148
 
703
- MIT © Ben Sheridan-Edwards. See [LICENSE](LICENSE).
149
+ MIT © Ben Sheridan-Edwards
@@ -21,6 +21,8 @@ options:
21
21
  --image-base-url <url> prefix for image URLs in report.md (default: relative)
22
22
  --pad <px> padding around changed rects when cropping (default: 24)
23
23
  --max-crops <n> max crop regions per surface before collapsing (default: 6)
24
+ --fold-details-at <n> row count at which a crop's property tables fold under a
25
+ <details> toggle (default: 0 = always; 'Infinity' = never)
24
26
  --min-width <px> minimum crop width, for context (default: 320)
25
27
  --min-height <px> minimum crop height, for context (default: 180)
26
28
  --include-layout-noise keep size/position-derived longhands (height, width,
@@ -36,6 +38,7 @@ const args = [];
36
38
  const flags = { out: 'styleproof-report', imageBaseUrl: '' };
37
39
  let pad;
38
40
  let maxCrops;
41
+ let foldDetailsAt;
39
42
  let minWidth;
40
43
  let minHeight;
41
44
  let includeLayoutNoise = false;
@@ -52,6 +55,8 @@ for (let i = 0; i < argv.length; i++) {
52
55
  else if (a.startsWith('--pad=')) pad = Number(a.slice(6));
53
56
  else if (a === '--max-crops') maxCrops = Number(argv[++i]);
54
57
  else if (a.startsWith('--max-crops=')) maxCrops = Number(a.slice(12));
58
+ else if (a === '--fold-details-at') foldDetailsAt = Number(argv[++i]);
59
+ else if (a.startsWith('--fold-details-at=')) foldDetailsAt = Number(a.slice(18));
55
60
  else if (a === '--min-width') minWidth = Number(argv[++i]);
56
61
  else if (a.startsWith('--min-width=')) minWidth = Number(a.slice(12));
57
62
  else if (a === '--min-height') minHeight = Number(argv[++i]);
@@ -78,6 +83,11 @@ for (const [name, val] of [
78
83
  process.exit(2);
79
84
  }
80
85
  }
86
+ // foldDetailsAt allows Infinity ("never fold"), so it gets a NaN-only check.
87
+ if (foldDetailsAt !== undefined && Number.isNaN(foldDetailsAt)) {
88
+ console.error('--fold-details-at must be a number (or Infinity)');
89
+ process.exit(2);
90
+ }
81
91
 
82
92
  let result;
83
93
  try {
@@ -88,6 +98,7 @@ try {
88
98
  imageBaseUrl: flags.imageBaseUrl || undefined,
89
99
  pad,
90
100
  maxCrops,
101
+ foldDetailsAt,
91
102
  minWidth,
92
103
  minHeight,
93
104
  includeLayoutNoise,
package/dist/report.d.ts CHANGED
@@ -25,6 +25,13 @@ export type ReportOptions = {
25
25
  maxHeight?: number;
26
26
  /** Max crop regions per surface before collapsing into one union crop (default 6). */
27
27
  maxCrops?: number;
28
+ /**
29
+ * Row count at which a crop's property tables fold under a `<details>` toggle
30
+ * (default 0 = always fold; the essence line and screenshot stay visible). Set
31
+ * to e.g. 5 to keep small changes inline and fold only verbose ones, or
32
+ * `Infinity` to never fold.
33
+ */
34
+ foldDetailsAt?: number;
28
35
  /**
29
36
  * Include size/position-derived longhands (height, width, transform-origin…)
30
37
  * in the report. Off by default: on a reflow they change up the whole ancestor
package/dist/report.js CHANGED
@@ -80,28 +80,26 @@ function fillRect(png, x, y, w, h, [r, g, b]) {
80
80
  }
81
81
  }
82
82
  /**
83
- * One labelled before|after image: the two equal-size crops on a dark canvas,
84
- * a divider between them, and a top accent bar per side (grey = before,
85
- * blue = after) as a font-free before/after cue. Left is always before.
83
+ * One before|after image: the two equal-size crops on a dark canvas with a
84
+ * neutral divider between them. Left is always before; before/after is labelled
85
+ * by the caption under the image. The divider is identical on both sides, so the
86
+ * ONLY thing that differs across the pair is the actual change — no extra chrome
87
+ * (e.g. a coloured accent strip) that reads as a second diff.
86
88
  */
87
89
  function compositePair(before, after) {
88
90
  const PAD = 20;
89
91
  const GAP = 28;
90
- const BAR = 6; // accent strip height
91
92
  const w = Math.max(before.width, after.width);
92
93
  const h = Math.max(before.height, after.height);
93
94
  const width = PAD + w + GAP + w + PAD;
94
- const height = PAD + BAR + h + PAD;
95
+ const height = PAD + h + PAD;
95
96
  const canvas = new PNG({ width, height });
96
97
  fillRect(canvas, 0, 0, width, height, [13, 17, 23]); // GitHub dark
97
98
  const leftX = PAD;
98
99
  const rightX = PAD + w + GAP;
99
- const top = PAD + BAR;
100
- fillRect(canvas, leftX, PAD, w, BAR, [110, 118, 129]); // before: grey
101
- fillRect(canvas, rightX, PAD, w, BAR, [88, 166, 255]); // after: blue
102
- PNG.bitblt(before, canvas, 0, 0, before.width, before.height, leftX, top);
103
- PNG.bitblt(after, canvas, 0, 0, after.width, after.height, rightX, top);
104
- fillRect(canvas, PAD + w + GAP / 2 - 1, PAD, 2, BAR + h, [48, 54, 61]); // divider
100
+ PNG.bitblt(before, canvas, 0, 0, before.width, before.height, leftX, PAD);
101
+ PNG.bitblt(after, canvas, 0, 0, after.width, after.height, rightX, PAD);
102
+ fillRect(canvas, PAD + w + GAP / 2 - 1, PAD, 2, h, [48, 54, 61]); // divider
105
103
  return canvas;
106
104
  }
107
105
  function readPng(file) {
@@ -337,6 +335,18 @@ function groupTitle(findings) {
337
335
  parts.push(`${n(restyled.size, 'element')} restyled`);
338
336
  return parts.join(', ') || `${n(new Set(findings.map((f) => f.path)).size, 'element')} changed`;
339
337
  }
338
+ /**
339
+ * A crop's heading: the element it's anchored on, then what happened inside it —
340
+ * `` `who-grid` · 5 elements restyled ``. Naming the anchor is what ties the
341
+ * table of changes below to the screenshot above it.
342
+ */
343
+ function regionHeading(regionPaths, findings) {
344
+ const anchors = [...regionPaths].sort((a, b) => a.split(' > ').length - b.split(' > ').length);
345
+ const clsFor = (p) => findings.find((f) => f.path === p)?.cls ?? '';
346
+ const head = prettyLabel(anchors[0] ?? '', clsFor(anchors[0] ?? ''));
347
+ const label = anchors.length > 1 ? `\`${head}\` + ${anchors.length - 1} more` : `\`${head}\``;
348
+ return `${label} · ${groupTitle(findings)}`;
349
+ }
340
350
  // A diff state row whose value is one of these placeholders means "this state
341
351
  // has no effect here" — meaningless to show, so render it as an em dash.
342
352
  const STATE_PLACEHOLDER = new Set(['(state does not change it)', '(state no longer changes it)', '(unset)']);
@@ -425,6 +435,59 @@ function renderElements(findings, maxElements = 40) {
425
435
  }
426
436
  return out;
427
437
  }
438
+ /**
439
+ * A scannable one-liner of what a crop changed, shown ABOVE the folded tables so a
440
+ * reviewer can judge without expanding: its top property deltas with values, the
441
+ * rest as a count, and a flag when the change reaches into hover/focus/active — the
442
+ * one kind of change a static before|after screenshot can't show.
443
+ */
444
+ function changeEssence(findings) {
445
+ const verbs = [];
446
+ for (const c of ['added', 'removed', 'retagged']) {
447
+ const k = findings.filter((f) => f.kind === 'dom' && f.change === c).length;
448
+ if (k)
449
+ verbs.push(`${k} ${c}`);
450
+ }
451
+ const rows = findings.flatMap((f) => (f.kind === 'dom' ? [] : summarizeProps(f.props)));
452
+ const top = rows.slice(0, 3).map((r) => `\`${r.prop}\` ${cell(r.before)} → ${cell(r.after)}`);
453
+ const more = rows.length > top.length ? `+${rows.length - top.length} more` : '';
454
+ const line = [...verbs, ...top, more].filter(Boolean).join(' · ') || '_see changes_';
455
+ return findings.some((f) => f.kind === 'state') ? `${line} _· incl. hover/focus/active_` : line;
456
+ }
457
+ /** Plain-text `<summary>` affordance — GitHub renders markdown inside `<summary>`
458
+ * literally, so no backticks or bold here. */
459
+ function foldSummary(findings) {
460
+ const n = findings.flatMap((f) => (f.kind === 'dom' ? [] : summarizeProps(f.props))).length;
461
+ if (!n)
462
+ return 'Show details';
463
+ return n === 1 ? 'Show the property change' : `Show all ${n} property changes`;
464
+ }
465
+ /** Render a crop's changes: the essence line, then the property tables — folded
466
+ * under a toggle once they would be a wall (the screenshot and approval checkbox
467
+ * above always stay visible). Blank lines around the table block are mandatory or
468
+ * GitHub prints the tables as literal text. `foldAt` is the row count at which the
469
+ * tables collapse; ≤ 0 folds always, Infinity never. */
470
+ function renderCropChanges(findings, foldAt) {
471
+ const tables = renderElements(findings);
472
+ if (!tables.length)
473
+ return [];
474
+ const rows = findings.flatMap((f) => (f.kind === 'dom' ? [] : summarizeProps(f.props))).length;
475
+ // Small enough to read at a glance: the tables speak for themselves, no essence
476
+ // line (it would just echo a one- or two-row table).
477
+ if (rows < foldAt)
478
+ return tables;
479
+ // Folded: the essence line is the visible stand-in for what the toggle hides.
480
+ return [
481
+ '',
482
+ changeEssence(findings),
483
+ '',
484
+ '<details>',
485
+ `<summary>${foldSummary(findings)}</summary>`,
486
+ ...tables,
487
+ '',
488
+ '</details>',
489
+ ];
490
+ }
428
491
  // Computed values that follow from an element's box size or position rather than
429
492
  // its styling. On any reflow they change all the way up the ancestor chain
430
493
  // (body, main, section…), so an element whose ONLY changes are these is a reflow
@@ -455,7 +518,7 @@ const DERIVED_PROPS = new Set([
455
518
  const hasRealChange = (f) => f.kind === 'dom' || f.props.some((p) => !DERIVED_PROPS.has(p.prop));
456
519
  const stripDerived = (f) => f.kind === 'dom' ? f : { ...f, props: f.props.filter((p) => !DERIVED_PROPS.has(p.prop)) };
457
520
  export function generateStyleMapReport(opts) {
458
- const { beforeDir, afterDir, outDir, imageBaseUrl = '', pad: padBy = 24, minWidth = 320, minHeight = 180, maxHeight = 1600, maxCrops = 6, } = opts;
521
+ const { beforeDir, afterDir, outDir, imageBaseUrl = '', pad: padBy = 24, minWidth = 320, minHeight = 180, maxHeight = 1600, maxCrops = 6, foldDetailsAt = 0, } = opts;
459
522
  const includeNoise = opts.includeLayoutNoise ?? false;
460
523
  const { surfaces } = diffStyleMapDirs(beforeDir, afterDir);
461
524
  fs.mkdirSync(path.join(outDir, 'crops'), { recursive: true });
@@ -513,13 +576,10 @@ export function generateStyleMapReport(opts) {
513
576
  `across ${changeGroups.length} distinct change(s) in ${surfaceCount} surface(s).`);
514
577
  }
515
578
  let totalFindings = 0;
579
+ let cropSeq = 0;
516
580
  for (const cg of changeGroups) {
517
581
  const { sd, findings: surfaceFindings } = cg.rep;
518
582
  totalFindings += surfaceFindings.length;
519
- md.push('', `### ${groupTitle(surfaceFindings)}`);
520
- md.push('', cg.surfaces.length > 1
521
- ? `_Identical across ${cg.surfaces.length} surfaces: ${formatSurfaceList(cg.surfaces)}_`
522
- : `_${formatSurfaceList(cg.surfaces)}_`);
523
583
  const mapA = loadStyleMap(findCapture(beforeDir, sd.surface));
524
584
  const mapB = loadStyleMap(findCapture(afterDir, sd.surface));
525
585
  const pngA = readPng(path.join(beforeDir, `${sd.surface}.png`));
@@ -535,23 +595,33 @@ export function generateStyleMapReport(opts) {
535
595
  })),
536
596
  ];
537
597
  }
598
+ // Read top-to-bottom: one section per crop, in page order.
599
+ const topY = (g) => (visible(g.after) ? g.after.y : visible(g.before) ? g.before.y : Infinity);
600
+ groups.sort((a, b) => topY(a) - topY(b));
601
+ const surfaceList = cg.surfaces.length > 1
602
+ ? `_Identical across ${cg.surfaces.length} surfaces: ${formatSurfaceList(cg.surfaces)}_`
603
+ : `_${formatSurfaceList(cg.surfaces)}_`;
538
604
  const surfaceJson = {
539
605
  surfaces: cg.surfaces,
540
606
  representative: sd.surface,
541
607
  regions: [],
542
608
  };
543
- let n = 0;
544
609
  for (const g of groups) {
545
- n++;
610
+ cropSeq++;
611
+ // Exactly the findings whose element lives inside THIS crop, so the tables
612
+ // sit directly under the screenshot that shows them — never a wall of
613
+ // changes spanning several crops with no way to tell which is which.
614
+ const regionFindings = surfaceFindings.filter((f) => g.paths.some((root) => f.path === root || f.path.startsWith(root + ' > ')));
615
+ md.push('', `### ${regionHeading(g.paths, regionFindings)}`, '', surfaceList);
546
616
  const region = visible(g.after) ? g.after : g.before;
547
617
  let images = {};
548
618
  if (region && pngA && pngB) {
549
619
  // Same crop dimensions on both sides so the pair reads as a pair.
550
620
  const w = Math.max(minWidth, visible(g.before) ? g.before.w : 0, visible(g.after) ? g.after.w : 0);
551
621
  const h = Math.min(maxHeight, Math.max(minHeight, visible(g.before) ? g.before.h : 0, visible(g.after) ? g.after.h : 0));
552
- // Path-safe stem: a surface key like `hero@1280` becomes `hero-1280`
553
- // so relative image links resolve cleanly in any markdown host.
554
- const stem = `crops/${sd.surface.replace(/[^a-z0-9-]/gi, '-')}-${n}`;
622
+ // Path-safe, report-unique stem: `hero@1280` `hero-1280-3` so relative
623
+ // image links resolve cleanly and two crops never collide on one filename.
624
+ const stem = `crops/${sd.surface.replace(/[^a-z0-9-]/gi, '-')}-${cropSeq}`;
555
625
  const before = cropPng(pngA, visible(g.before) ? g.before : region, w, h);
556
626
  const after = cropPng(pngB, visible(g.after) ? g.after : region, w, h);
557
627
  const composite = compositePair(before, after);
@@ -559,7 +629,7 @@ export function generateStyleMapReport(opts) {
559
629
  writePng(path.join(outDir, `${stem}-after.png`), after);
560
630
  writePng(path.join(outDir, `${stem}-composite.png`), composite);
561
631
  images = { before: `${stem}-before.png`, after: `${stem}-after.png`, composite: `${stem}-composite.png` };
562
- // One side-by-side image per change: clean inline render, single upload.
632
+ // One side-by-side image per crop: clean inline render, single upload.
563
633
  md.push('', `![before ◀ │ ▶ after](${img(images.composite)})`, '', `<sub>◀ before · after ▶ — ${sd.surface}</sub>`);
564
634
  }
565
635
  else if (!region) {
@@ -568,10 +638,11 @@ export function generateStyleMapReport(opts) {
568
638
  else {
569
639
  md.push('', '_No screenshots in these capture sets (run captures with `screenshots: true` for side-by-side crops)._');
570
640
  }
641
+ // What this crop changed: a scannable essence line, then the property
642
+ // tables — folded under a toggle once they'd be a wall (foldDetailsAt).
643
+ md.push(...renderCropChanges(regionFindings, foldDetailsAt));
571
644
  surfaceJson.regions.push({ paths: g.paths, before: g.before, after: g.after, images });
572
645
  }
573
- // The findings, grouped per element, rendered once for the whole group.
574
- md.push(...renderElements(surfaceFindings));
575
646
  surfaceJson.findings = surfaceFindings;
576
647
  json.push(surfaceJson);
577
648
  }
Binary file
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "1.2.0",
4
- "description": "Certify CSS refactors with the browser's computed styles: capture every resolved longhand, pseudo-element, and forced hover/focus/active state, then diff before against after.",
3
+ "version": "1.3.1",
4
+ "description": "Catch every CSS change before it ships — review PRs and certify refactors by the browser's computed styles, not pixels. Works with any styling system.",
5
5
  "keywords": [
6
6
  "playwright",
7
7
  "css",