styleproof 3.21.0 → 4.0.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/README.md CHANGED
@@ -12,23 +12,20 @@ report. Intentional visual changes get approved; unexpected ones block or fail.
12
12
  ## Contents
13
13
 
14
14
  - [Why](#why)
15
- - [The short version](#the-short-version)
16
- - [What it catches](#what-it-catches)
17
- - [What you own](#what-you-own)
15
+ - [How it works](#how-it-works)
16
+ - [Quickstart](#quickstart)
18
17
  - [What the PR gets](#what-the-pr-gets)
19
- - [Auto-discovery](#auto-discovery)
20
- - [What a report looks like](#what-a-report-looks-like)
21
- - [Works with any styling system](#works-with-any-styling-system)
22
- - [Breakpoints, detected automatically](#breakpoints-detected-automatically)
23
- - [Certify a refactor](#certify-a-refactor)
18
+ - [Two modes: review or certify](#two-modes-review-or-certify)
19
+ - [What a green certifies](#what-a-green-certifies)
20
+ - [Coverage: what you own, what's discovered](#coverage-what-you-own-whats-discovered)
21
+ - [Declaring surfaces](#declaring-surfaces)
22
+ - [Deterministic by default](#deterministic-by-default)
23
+ - [Any styling system, real breakpoints](#any-styling-system-real-breakpoints)
24
24
  - [Match a design pixel-for-pixel](#match-a-design-pixel-for-pixel)
25
- - [Install](#install)
26
- - [Quickstart](#quickstart)
27
25
  - [Forks and Dependabot](#forks-and-dependabot)
28
26
  - [Optional: content layer](#optional-content-layer-advisory)
29
27
  - [Optional: React component layer](#optional-react-component-layer-advisory)
30
28
  - [Optional: selective remap](#optional-selective-remap-advisory)
31
- - [Newly-added elements show their full style](#newly-added-elements-show-their-full-style)
32
29
  - [Reference](#reference)
33
30
  - [Blocking without branch protection](#blocking-without-branch-protection)
34
31
  - [Contributing](#contributing)
@@ -41,42 +38,21 @@ layout, or hidden/open UI states and you want CI to say whether the browser's
41
38
  rendered styles actually changed. Unit and e2e tests prove behavior; StyleProof
42
39
  proves the visual contract for the states you declared.
43
40
 
44
- It is useful for:
45
-
46
- - certifying a "no visual change" refactor such as CSS Modules to Tailwind;
47
- - reviewing intentional visual changes with exact before/after style evidence;
48
- - catching accidental changes that only appear at one breakpoint;
49
- - catching open-state regressions in dialogs, dropdowns, listboxes, popovers, and
50
- toasts;
51
- - failing CI when a required route, component, or UI state exists but has no
52
- capture.
41
+ It catches:
53
42
 
54
- The important boundary: StyleProof only certifies states it can reach. If a state
55
- matters, list it as a surface, variant, popup, live state, or component-catalog
56
- surface. `expected` turns that inventory into a guard: a key it lists that is
57
- neither captured nor excluded fails as missing coverage instead of silently
58
- passing. What's guarded depends on how `expected` is fed
59
-
60
- - **Next.js:** auto-covered. `styleproof-init` derives both `surfaces` and
61
- `expected` from the same `discoverNextRoutes()` call, so a new static route lands
62
- in both at once — captured and expected together, with nothing to keep in sync.
63
- The guard then fails only on genuine divergence between the two: a dynamic route
64
- (pre-excluded, so its reason surfaces), a hand-maintained registry, or a route
65
- dropped from `surfaces` while still in `expected`.
66
- - **Link-crawled SPAs:** pass `expected` to `defineCrawlCapture` and the crawl
67
- reconciles it against the _rendered nav_ (the route universe for such an app),
68
- both directions — a new linked route with no `expected` entry fails, and an
69
- `expected` route the nav stopped linking fails. This runs inside the capture, so
70
- it fires when you capture (unlike the Next guard, which runs in your plain suite).
71
- - **Other frameworks:** point `expected` at your own route registry.
72
- - **Modals, dropdowns, toasts:** guarded only for the state keys you enumerate in
73
- `expected` (e.g. `dashboard-dialog-open`) — nothing discovers UI states for you.
43
+ - a button recoloured by a token, utility class, CSS module, inline style, or
44
+ design-system change;
45
+ - a layout shift at one breakpoint but not another;
46
+ - a dropped `:hover`, `:focus`, or `:active` style;
47
+ - a modal, menu, listbox, popover, sheet, or toast whose open state changed;
48
+ - a supposedly no-op refactor, such as CSS-to-Tailwind, that changed rendered
49
+ output;
50
+ - a required route, component, or UI state that exists but has no capture.
74
51
 
75
- A surface that exists only on the PR head is still reviewable:
76
- in review-gate mode it holds the status red until approved, then becomes part of
77
- the baseline once merged.
52
+ The end-to-end catch contract lives in
53
+ [docs/what-it-catches.md](docs/what-it-catches.md).
78
54
 
79
- ## The short version
55
+ ## How it works
80
56
 
81
57
  1. A **surface** is one UI state to certify: a route, tab, modal-open state,
82
58
  dropdown-open state, toast-visible state, loading state, etc.
@@ -94,37 +70,123 @@ can see the change, but the gate compares browser-computed CSS: resolved
94
70
  longhands, pseudo-elements, layout boxes, motion longhands, and forced
95
71
  `:hover`/`:focus`/`:active` deltas.
96
72
 
97
- ## What it catches
73
+ Maps travel via the SHA-keyed `styleproof-maps` branch (or a CI artifact for
74
+ forks) — **never as files committed to the PR branch**. Committed maps show up
75
+ as changed files in every review, and because every PR writes the same paths,
76
+ each merge forces every other open PR to rebase. `.styleproof/` and
77
+ `stylemaps/` are gitignored to keep that door shut.
78
+
79
+ ## Quickstart
80
+
81
+ ### 0. Install
82
+
83
+ ```bash
84
+ npm install -D styleproof @playwright/test
85
+ npx playwright install chromium
86
+ ```
87
+
88
+ Requires **Node ≥ 18** (ESM), **`@playwright/test` ≥ 1.40** (peer dep). Forced states are Chromium-only.
98
89
 
99
- - A button recoloured by a token, utility class, CSS module, inline style, or
100
- design-system change.
101
- - A layout shift at one breakpoint but not another.
102
- - A dropped `:hover`, `:focus`, or `:active` style.
103
- - A modal, menu, listbox, popover, sheet, or toast whose open state changed.
104
- - A supposedly no-op refactor, such as CSS-to-Tailwind, that changed rendered
105
- output.
90
+ ### 1. Scaffold the gate
106
91
 
107
- ## What you own
92
+ ```bash
93
+ npx styleproof-init
94
+ ```
108
95
 
109
- StyleProof can only certify states it reaches. You own the app-specific list of
110
- states that matter:
96
+ `styleproof-init` detects your app and wires **surface discovery** for you — there is nothing to hand-list:
111
97
 
112
- - routes and views belong in `surfaces`;
113
- - open states belong in `variants` or `popups`;
114
- - loading/loaded/empty/error states belong in `liveStates`;
115
- - component catalogs can be wired through `discoverComponentFiles`;
116
- - required-but-not-yet-captured states belong in `expected`, where the coverage
117
- guard fails until they are captured or explicitly excluded with a reason.
98
+ - **Next.js** — it discovers your routes (`app/` + `pages/`) at run time and derives _both_ the captured surfaces and the coverage guard from them, so a route you add later is captured automatically, never a guard failure.
99
+ - **Any other app** it scaffolds a **nav crawl**: StyleProof loads `/`, reads the rendered `<a href>` links, and captures every same-origin surface they point to. The surface set _is_ the nav, so it can't drift from it.
118
100
 
119
- Auto-discovery is a key feature, not a side path: StyleProof can discover
120
- Next.js routes, crawl real nav links, inventory component files, harvest
121
- one-step variants, click safe popups, detect breakpoints, and flag live or
122
- volatile regions. You review the app-specific leftovers; StyleProof keeps the
123
- mechanical inventory from drifting.
101
+ Either way the generated spec runs as-is. It also wires everything around it so the gate behaves the same locally and in CI:
124
102
 
125
- That boundary is deliberate. StyleProof should not guess destructive flows,
126
- auth-only fixtures, or which product state your component needs. It should make
127
- missing coverage loud.
103
+ - a dedicated **`playwright.styleproof.config.ts`** that builds and serves a **production build** (never a flaky dev server), scopes discovery to the StyleProof spec, and captures surfaces **in parallel** (`fullyParallel`) without disturbing your app's existing Playwright config;
104
+ - widths you never set **omit `widths`** and StyleProof sweeps your app's real `@media` breakpoints automatically;
105
+ - determinism you never set up — network settle, frozen clock, animation freeze, and framework-noise filtering are all on by default (see [Deterministic by default](#deterministic-by-default));
106
+ - `.gitignore` entries for `.styleproof/`, `test-results/`, and `playwright-report/`;
107
+ - a **cache-first CI workflow** that restores reusable maps from the `styleproof-maps` branch and generates the report without a browser when both maps are already built;
108
+ - a **pre-push hook** (`.husky/` if present, else `.githooks/`) that captures each pushed commit and publishes the bundle to the `styleproof-maps` branch — CI's hot path stays report-only, and maps never get committed to the PR branch;
109
+ - the **approval workflow** (`styleproof-approve.yml`) that turns the `StyleProof` status green when a reviewer ticks **Approve all changes** — so the review gate is complete, not half-wired (it activates once the init PR merges, since GitHub runs `issue_comment` workflows only from your default branch).
110
+
111
+ ### 2. Capture, then diff
112
+
113
+ ```bash
114
+ npx styleproof-map # capture this commit's computed styles
115
+ npx styleproof-diff # compare against the base branch
116
+ ```
117
+
118
+ `styleproof-map` captures the current commit into `.styleproof/maps/current`,
119
+ writes a manifest, and uploads the bundle
120
+ to the dedicated `styleproof-maps` branch when the working tree was clean and a
121
+ git remote is available. Nothing under `.styleproof/` belongs in the PR branch.
122
+ HAR recordings are removed before upload by default so private API responses do
123
+ not land in the map store. Keep them locally only for an explicit record/replay
124
+ workflow with `styleproof-map --keep-har` (or `STYLEPROOF_KEEP_HAR=1`).
125
+
126
+ `styleproof-diff` restores the base and head maps from `styleproof-maps`
127
+ automatically: in GitHub Actions it uses the PR base/head SHAs; locally it checks
128
+ `branch.<name>.gh-merge-base`, then the current GitHub PR base via `gh pr view`
129
+ (handy for stacked PRs), then `origin/main`, `origin/master`, `main`, and
130
+ `master`. Pin the base with `styleproof-diff main` or `styleproof-diff master`.
131
+
132
+ **That's the whole loop.** The map is built outside CI by default: the
133
+ pre-push hook `styleproof-init` installs runs `styleproof-map` on every push
134
+ that can affect render (skip one with `STYLEPROOF_SKIP_CAPTURE=1 git push`).
135
+ On the PR, CI first restores the base/head bundles and only generates the
136
+ report — no build, no browser. If either bundle is missing or incompatible,
137
+ CI recaptures both sides in the same pinned environment before reporting.
138
+ Correctness wins over a stale cache, but the hot path is report-only.
139
+
140
+ > **Same-environment note.** Computed styles depend on the browser build and installed fonts, so maps are only comparable when captured in the same runtime environment. StyleProof records a compatibility key to select the right cached bundle and refuses to compare maps captured under different browser/platform settings; CI then recaptures both sides instead of producing a bogus report. Each capture also records the **real browser build** (`browser().version()`) in its manifest — the npm `@playwright/test` version is only a proxy, and the actual Chromium binary can change while it holds constant (a `playwright install` re-download, a different `PLAYWRIGHT_BROWSERS_PATH`, a CI image bump). When both sides carry it, a differing build refuses to compare (exit 2, both builds named) instead of walling the PR with false diffs. This guard needs a `styleproof-manifest.json` on **both** sides. Since **v4** a two-directory `styleproof-diff`/`styleproof-report` where a side ships maps but no manifest **refuses to compare**: it exits `2` (usage/capture error) naming the bare side(s), because the environment can't be verified and captures from different browser builds or platforms would diff as false changes. Re-capture with current StyleProof — `styleproof-map`, or `styleproof-capture` for a one-shot design diff (both write a manifest); maps without one are unsupported. (A dir with _no maps at all_ is "no baseline yet", not a bare bundle — that stays the first-adoption review path, exit `3`.) **Installed fonts are your responsibility:** they are noisy across machines (user-installed families, OS updates, and no cheap cross-platform enumeration), so StyleProof does not fingerprint them — capture both sides on the same fonts, which is what CI's pinned image already gives you.
141
+
142
+ **Want the local side-by-side report** (not just a pass/fail diff)? Run `npx
143
+ styleproof-report` after `styleproof-map`; it uses the same inferred base ref and
144
+ the same cached-map defaults as `styleproof-diff`. Pin the base with
145
+ `styleproof-report main` or keep the manual form with `styleproof-report before
146
+ after --out report`.
147
+
148
+ ### 3. Wire it by hand instead (optional)
149
+
150
+ `styleproof-init` scaffolds **both** the report workflow _and_ the
151
+ `styleproof-approve.yml` handler that flips the `StyleProof` status when a
152
+ reviewer ticks the box. GitHub only runs `issue_comment` workflows from the
153
+ default branch, so the checkbox goes live the moment you merge the init PR — no
154
+ manual copy. If you wire it by hand instead, restore or capture two dirs first,
155
+ then use the Action on those dirs:
156
+
157
+ ```yaml
158
+ # .github/workflows/styleproof.yml
159
+ - uses: actions/checkout@v4
160
+ - run: npx styleproof-map --restore --sha "${{ github.event.pull_request.base.sha }}" --dir base --base-dir __stylemaps__
161
+ - run: npx styleproof-map --restore --sha "${{ github.event.pull_request.head.sha }}" --dir head --base-dir __stylemaps__
162
+ - uses: BenSheridanEdwards/StyleProof@v4
163
+ with:
164
+ baseline-dir: __stylemaps__/base
165
+ fresh-dir: __stylemaps__/head
166
+ require-approval: true # review-gate mode (omit / use fail-on-diff: true to certify)
167
+ ```
168
+
169
+ Only for this hand-wired path: 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 approval checkbox is inert until it's merged). `styleproof-init` writes this file for you, so you can skip this step if you used it.
170
+
171
+ **Prefer to always capture in CI?** For a repo with many outside contributors on different machines, StyleProof can capture **both** base and head in CI and diff them there. See **[Forks and Dependabot](#forks-and-dependabot)** for that flow (it's also the fork-safe split). The default cache-first flow is faster for same-repo teams because the pre-push hook builds the head map before CI starts.
172
+
173
+ **Want to skip work safely?** Skip the **whole** StyleProof workflow only for
174
+ changes that cannot affect rendered output, such as docs-only edits, using your
175
+ CI provider's native path filters. Do not skip individual surfaces from a
176
+ StyleProof run based on a changed-file guess: shared CSS, tokens, resets,
177
+ themes, layout primitives, and runtime styling can repaint any surface, and a
178
+ missed surface would certify green without being measured. If you want faster
179
+ feedback, order the highest-signal surfaces first in your spec, but still let
180
+ the full sweep finish before treating the gate as passed.
181
+
182
+ ```yaml
183
+ on:
184
+ pull_request:
185
+ paths-ignore:
186
+ - '**/*.md'
187
+ - 'docs/**'
188
+ - '.github/ISSUE_TEMPLATE/**'
189
+ ```
128
190
 
129
191
  ## What the PR gets
130
192
 
@@ -140,23 +202,112 @@ full report. The report groups each distinct visual change with:
140
202
  In review-gate mode, one **Approve all changes** checkbox turns the `StyleProof`
141
203
  status green for that commit. Clean runs still leave a receipt: `No visual
142
204
  changes detected.` New surfaces are shown as new baselines and require approval;
143
- coverage gaps are handled by `expected`.
205
+ coverage gaps are handled by `expected`. When a PR **adds** an element, the
206
+ report shows its **full resting computed style** (background, padding, font,
207
+ radius, …), value-only, in addition to any interaction-state deltas — so a new
208
+ element's whole look is reviewable, not just its `:hover` changes.
209
+
210
+ ### What a report looks like
211
+
212
+ One change — the hero CTA recoloured cyan → amber — appears as a single section in the report: a side-by-side before/after cropped screenshot, the same crop again with magenta boxes marking exactly what changed, a one-line summary, then the exact property change folded under a toggle. A change too small to see at 1:1 (say a 2px icon tweak) also gets a magnified zoom crop, so a sub-pixel change can't slip past a reviewer.
213
+
214
+ ![A StyleProof report: the CTA button before (cyan) and after (amber), side by side](https://raw.githubusercontent.com/BenSheridanEdwards/StyleProof/main/docs/demo-composite.png)
215
+
216
+ 📄 **[See a full live report](docs/demo/report.md)** — rendered by the current code with real images (clean before/after, the highlighted twin, a magnified zoom for a sub-pixel change, and a `🆕 new surface`). It's regenerated and verified on every PR (`npm run demo:report`), so it always reflects exactly what StyleProof produces today.
217
+
218
+ As it renders in the committed report (a plain-English bullet first — naming the theme token and showing the hex with a live colour swatch — then the exact table inside the toggle). The PR comment itself stays lean — a summary plus the approval box — and links here:
219
+
220
+ ```text
221
+ ### `a.btn-solid` · 1 element restyled
222
+ _landing @ 1280_
223
+
224
+ - **`a.btn-solid`** — background `brand-cyan` (`#5fcadb`) → `brand-amber` (`#f59e0b`)
225
+
226
+ ▾ Show the property change
227
+ | Property | Before | After |
228
+ | background-color | #5fcadb | #f59e0b |
229
+ ```
144
230
 
145
- ## Auto-discovery
231
+ ## Two modes: review or certify
232
+
233
+ **Review-gate mode** (`require-approval: true`) is for normal feature work:
234
+ every visual change is reported with evidence, the `StyleProof` status stays red
235
+ until a reviewer ticks **Approve all changes**, and approved changes become the
236
+ new baseline on merge. A surface that exists only on the PR head is still
237
+ reviewable: it holds the status red until approved, then becomes part of the
238
+ baseline once merged.
239
+
240
+ **Certify mode** (`fail-on-diff: true`) proves a change touched _nothing_
241
+ visual: any difference at all fails the job. It's the job StyleProof was born
242
+ for — certifying a CSS-to-Tailwind migration rendered byte-for-byte identical.
243
+ Reach for it on any change whose whole promise is "the output is unchanged": a
244
+ utility-class migration, a design-system swap, a dependency or build-tooling
245
+ bump. Zero diff is the contract; one drifting longhand is a regression to
246
+ investigate, not a change to approve.
247
+
248
+ There's also a third, spec-less use — pointing the one-shot capture at a design
249
+ mockup and diffing your build against it until the number hits zero. See
250
+ [Match a design pixel-for-pixel](#match-a-design-pixel-for-pixel).
251
+
252
+ ## What a green certifies
253
+
254
+ A passing check is more than "no style changed". Four gates qualify it, and the
255
+ report leads with their verdicts:
256
+
257
+ - **Coverage** — the `expected` registry travels with the captured bundle as a
258
+ ledger, so the gate states its completeness basis: `✓ coverage complete`,
259
+ `✗ coverage INCOMPLETE` (blocks — a registered surface wasn't captured, even
260
+ on an empty diff), or `⚠ completeness NOT asserted` (no registry declared).
261
+ - **Determinism** — the ledger records how each capture proved itself
262
+ (`self-checked` / `replayed`); a green from an `unproven` capture blocks,
263
+ because a clean diff of two nondeterministic reads could just be luck.
264
+ - **Inventory** — with `inventory: true` (on in `styleproof-init` scaffolds),
265
+ each capture harvests the surface's navigable affordances (links, tabs, menu
266
+ items, keyed by stable identity, not label text). A removal that makes a
267
+ feature unreachable **gates** — in the `styleproof-diff` CLI and in the
268
+ Action, both modes — until acknowledged in `styleproof.inventory.json`
269
+ (`{"<key>": "<why>"}`); a stale acknowledgement is flagged so the ledger
270
+ can't rot. Details and the keying rules:
271
+ [docs/inventory-guard.md](docs/inventory-guard.md). Make it advisory in the
272
+ Action with `"gateInventoryRemovals": false` in `styleproof.config.json`.
273
+ - **Data residue** — a data-boundary request that **failed** during capture
274
+ means the fallback branch was captured, not the state its responses drive.
275
+ Gating is the default (`dataResidue: 'gate'`): an unacknowledged failing
276
+ endpoint blocks until acknowledged in `styleproof.data-residue.json`, and a
277
+ stale acknowledgement also fails. Opt down with `dataResidue: 'warn'`. See
278
+ [Data residue](#data-residue-a-failed-data-request-is-named-not-swallowed).
279
+
280
+ ## Coverage: what you own, what's discovered
281
+
282
+ The important boundary: **StyleProof only certifies states it can reach.** It
283
+ diffs the surfaces your spec lists or discovers — so a page nobody added to
284
+ either set is invisible to the gate. Its change has no base capture _and_ no
285
+ head capture, so it never appears in any diff, and the status goes green having
286
+ never looked at it. This is the one thing the captures can't catch on their
287
+ own: a capture that was never taken.
288
+
289
+ Auto-discovery keeps the boring inventory out of your hands where it can be
290
+ inferred safely: Next.js routes, crawlable links, component files, semantic
291
+ popups, one-step variants, breakpoints, and volatile/live candidates. You own
292
+ the app-specific list of states that matter:
146
293
 
147
- StyleProof's auto-discovery keeps the boring inventory out of your hands where it
148
- can be inferred safely: Next.js routes, crawlable links, component files,
149
- semantic popups, one-step variants, breakpoints, and volatile/live candidates.
150
- The key loop is simple: discover what the app exposes, capture what is safe, and
151
- fail loudly for anything that still needs an owner.
294
+ - routes and views belong in `surfaces`;
295
+ - open states belong in `variants` or `popups`;
296
+ - loading/loaded/empty/error states belong in `liveStates`;
297
+ - component catalogs can be wired through `discoverComponentFiles`;
298
+ - required-but-not-yet-captured states belong in `expected`, where the coverage
299
+ guard fails until they are captured or explicitly excluded with a reason.
152
300
 
153
- StyleProof diffs the surfaces your spec lists or discovers — so a page nobody
154
- added to either set is invisible to the gate. Its change has no base capture
155
- _and_ no head capture, so it never appears in any diff, and the status goes
156
- green having never looked at it. This is the one thing the captures can't catch
157
- on their own: a capture that was never taken.
301
+ That boundary is deliberate. StyleProof should not guess destructive flows,
302
+ auth-only fixtures, or which product state your component needs. It should make
303
+ missing coverage loud.
158
304
 
159
- Declare your app's route/view universe in `expected` and StyleProof emits a coverage-guard test in your **normal** suite (it runs even without `STYLEMAP_DIR` — it's a static check, no browser). It fails when `expected` and your captured surfaces diverge — a route you listed in `expected` with no surface and no `exclude` entry fails as missing coverage, so a registry entry can't quietly ship uncaptured:
305
+ `expected` is what makes it loud. Declare your app's route/view universe in
306
+ `expected` and StyleProof emits a coverage-guard test in your **normal** suite
307
+ (it runs even without `STYLEMAP_DIR` — it's a static check, no browser). It
308
+ fails when `expected` and your captured surfaces diverge — a route you listed in
309
+ `expected` with no surface and no `exclude` entry fails as missing coverage, so
310
+ a registry entry can't quietly ship uncaptured:
160
311
 
161
312
  ```ts
162
313
  import { defineStyleMapCapture } from 'styleproof';
@@ -172,7 +323,65 @@ defineStyleMapCapture({
172
323
 
173
324
  A route that's neither a captured surface nor an `exclude` entry fails the guard; an `exclude` key that isn't in `expected` (a renamed/removed route) fails too, so the opt-out ledger can't quietly rot. Captured surfaces beyond `expected` are fine — one route can have several states (`landing`, `landing-nav-open`). Omit `expected` and behaviour is unchanged.
174
325
 
175
- **Next.js: wired for you.** Run `styleproof-init` in a Next.js project and the generated spec discovers your routes (App Router `app/` + Pages Router `pages/`) at run time and derives **both** the surfaces and `expected` from that same `discoverNextRoutes()` call. Because they share one source, a static route you add later is captured and expected in the same step auto-covered, never a guard failure, with nothing to keep in sync. The guard exists for the cases where the two genuinely diverge: a dynamic `[param]` route (it can't be navigated without a value, so it's placed in `exclude` with a reason rather than captured), a registry you hand-maintain instead of the live call, or a route you drop from `surfaces` while it's still `expected`:
326
+ What's guarded depends on how `expected` is fed
327
+
328
+ - **Next.js:** auto-covered. `styleproof-init` derives both `surfaces` and
329
+ `expected` from the same `discoverNextRoutes()` call, so a new static route lands
330
+ in both at once — captured and expected together, with nothing to keep in sync.
331
+ - **Link-crawled SPAs:** pass `expected` to `defineCrawlCapture` and the crawl
332
+ reconciles it against the _rendered nav_ (the route universe for such an app),
333
+ both directions — a new linked route with no `expected` entry fails, and an
334
+ `expected` route the nav stopped linking fails. This runs inside the capture, so
335
+ it fires when you capture (unlike the Next guard, which runs in your plain suite).
336
+ - **Other frameworks:** point `expected` at your own route registry.
337
+ - **Modals, dropdowns, toasts:** guarded only for the state keys you enumerate in
338
+ `expected` (e.g. `dashboard-dialog-open`) — nothing discovers UI states for you.
339
+
340
+ ### The un-exercised-state gap: an honest green gate can still miss a real restyle
341
+
342
+ The sharpest form of the boundary, observed end-to-end on a real consumer: a PR
343
+ restyled a view's **conditional render branch** (a fault overlay repainting
344
+ indicators green→amber when a probe reports a fault) and shipped through a
345
+ fully-wired gate with every layer passing _honestly_. The capture spec served no
346
+ fixture that put the view into the fault state, so the changed branch never
347
+ rendered on either side — an honest recapture produced **byte-identical maps**,
348
+ the diff was trivially clean, and the green gate certified a restyle it never
349
+ saw. No component lied. The gap is structural: **maps prove only the states the
350
+ spec exercises.** A restyle confined to an un-exercised conditional state
351
+ (fault / error / empty / permission branches behind data) is invisible to any
352
+ amount of honest recapturing, and the coverage guard cannot substitute — it
353
+ checks that declared keys have captures, not that your branches have keys.
354
+
355
+ So the rule is: **every conditional render branch whose styling matters needs a
356
+ surface that exercises it.** The recommended wiring is two-part:
357
+
358
+ 1. a **dedicated capture surface** (a `liveState` or variant) driving the
359
+ branch via a **per-surface fixture override** — `page.route` in that
360
+ surface's `setup`, per-surface rather than global, so other surfaces reading
361
+ the same endpoint keep their own state (exactly the shape of the
362
+ [`liveStates` example](#live-ui-states-capture-each-state-not-an-average));
363
+ 2. a **browserless guard test** tied to the branch's source, failing loudly if
364
+ the surface, fixture, or state assertion is removed while the conditional
365
+ branch still exists — so the coverage can't silently rot.
366
+
367
+ This pairs with the [data-residue guard](#data-residue-a-failed-data-request-is-named-not-swallowed),
368
+ which names the _failing_ half of the same blind spot: a data request that
369
+ errors during capture is flagged as residue. An endpoint that **succeeds** with
370
+ healthy data — so the fault branch simply never renders — is this gap, and only
371
+ a fixture-driven surface closes it.
372
+
373
+ ## Declaring surfaces
374
+
375
+ Discovery captures every route your app links to. It deliberately **won't
376
+ guess** app-specific states — a modal's open state, an auth-gated view, a
377
+ destructive flow, a loading/error render — because guessing one wrong is worse
378
+ than flagging it missing. Those are the only things you list by hand, and you
379
+ add them to the spec `styleproof-init` already generated. This section covers
380
+ each kind.
381
+
382
+ ### Next.js routes: wired for you
383
+
384
+ Run `styleproof-init` in a Next.js project and the generated spec discovers your routes (App Router `app/` + Pages Router `pages/`) at run time and derives **both** the surfaces and `expected` from that same `discoverNextRoutes()` call. Because they share one source, a static route you add later is captured and expected in the same step — auto-covered, never a guard failure, with nothing to keep in sync. The guard exists for the cases where the two genuinely diverge: a dynamic `[param]` route (it can't be navigated without a value, so it's placed in `exclude` with a reason rather than captured), a registry you hand-maintain instead of the live call, or a route you drop from `surfaces` while it's still `expected`:
176
385
 
177
386
  ```ts
178
387
  import { defineStyleMapCapture, discoverNextRoutes } from 'styleproof';
@@ -197,7 +406,9 @@ defineStyleMapCapture({
197
406
 
198
407
  `discoverNextRoutes(cwd?)` reads the filesystem only (route groups `(group)` and `@slots` stripped, `[param]`/`[...catchall]` flagged `dynamic`) — a heuristic, not a router; edit the generated spec for exotic routing. For any other framework, point `expected` at your own route registry as above.
199
408
 
200
- **Single-route SPAs: crawl the nav instead.** Filesystem discovery can't see a surface that isn't a page — a tab SPA where every view is `/?tab=overview` on one `app/page.tsx`, or anything client-routed. There the surfaces exist only in the rendered nav, as its links. `defineCrawlCapture` discovers them at run time: it loads a root URL, reads its same-origin `<a href>`s, and captures each — so the surface set _is_ the nav, with no list to hand-maintain (and so none to drift).
409
+ ### Single-route SPAs: crawl the nav
410
+
411
+ Filesystem discovery can't see a surface that isn't a page — a tab SPA where every view is `/?tab=overview` on one `app/page.tsx`, or anything client-routed. There the surfaces exist only in the rendered nav, as its links. `defineCrawlCapture` discovers them at run time: it loads a root URL, reads its same-origin `<a href>`s, and captures each — so the surface set _is_ the nav, with no list to hand-maintain (and so none to drift).
201
412
 
202
413
  ```ts
203
414
  import { defineCrawlCapture } from 'styleproof';
@@ -223,11 +434,12 @@ defineCrawlCapture({
223
434
  });
224
435
  ```
225
436
 
226
- **Component inventory: fail when the catalog misses a component.** StyleProof
227
- cannot render arbitrary component files by itself across frameworks; props,
228
- providers, loaders, portals, and app shell context are app-owned. What it can do
229
- reliably is inventory component files and make your catalog/story route prove it
230
- has a capture for each one:
437
+ ### Component inventory: fail when the catalog misses a component
438
+
439
+ StyleProof cannot render arbitrary component files by itself across frameworks;
440
+ props, providers, loaders, portals, and app shell context are app-owned. What it
441
+ can do reliably is inventory component files and make your catalog/story route
442
+ prove it has a capture for each one:
231
443
 
232
444
  ```ts
233
445
  import { componentCatalogSurfaces, defineStyleMapCapture, discoverComponentFiles } from 'styleproof';
@@ -255,10 +467,12 @@ Use Storybook, Ladle, a framework route, or a tiny app-specific catalog for
255
467
  `expected`, so a new component file appears immediately and CI fails until it has
256
468
  a rendered surface or an explicit exclusion.
257
469
 
258
- **Dialogs, popovers and menus: capture the open state as a variant.** StyleProof
259
- cannot guess which app-specific button opens a modal, but once you tell it the
260
- interaction, it compares matching states on base and head (`home-dialog-open` to
261
- `home-dialog-open`). Keep these under the route/view that owns them:
470
+ ### Dialogs, popovers and menus: capture the open state as a variant
471
+
472
+ StyleProof cannot guess which app-specific button opens a modal, but once you
473
+ tell it the interaction, it compares matching states on base and head
474
+ (`home-dialog-open` to `home-dialog-open`). Keep these under the route/view that
475
+ owns them:
262
476
 
263
477
  ```ts
264
478
  const SURFACES: Surface[] = [
@@ -289,6 +503,8 @@ Non-live `variants` add captures; the owning surface still captures too. Use
289
503
  `liveStates` instead when the default live state is too fuzzy and only pinned
290
504
  states such as `loading`, `loaded`, `empty`, or `error` should be compared.
291
505
 
506
+ ### Popups, discovered automatically
507
+
292
508
  When `popups: true` is enabled, StyleProof also tries visible safe triggers and
293
509
  captures opened dialogs, menus, listboxes, modal roots, popovers, tooltips, and
294
510
  toast/status roots. Each saved map includes `overlays` proof metadata for
@@ -307,12 +523,14 @@ why — instead of capturing contaminated state or keying a popup under the wron
307
523
  trigger. Dismiss the leaking overlay in the surface's `go()`, or capture it as an
308
524
  explicit variant.
309
525
 
310
- **Harvest one-step variants.** Routes are not the whole UI: drawers, tabs,
311
- dialogs, empty form errors, selects, and other one-step states need their own
312
- captures. `styleproof-variants` opens a running app, tries semantic controls
313
- (`[aria-expanded]`, tabs, summaries, selects, required forms, etc.), captures a
314
- baseline and post-action StyleMap, and keeps only actions that change computed
315
- styles. It also reports live-state candidates that need fixtures or opt-outs.
526
+ ### Harvest one-step variants
527
+
528
+ Routes are not the whole UI: drawers, tabs, dialogs, empty form errors, selects,
529
+ and other one-step states need their own captures. `styleproof-variants` opens a
530
+ running app, tries semantic controls (`[aria-expanded]`, tabs, summaries,
531
+ selects, required forms, etc.), captures a baseline and post-action StyleMap, and
532
+ keeps only actions that change computed styles. It also reports live-state
533
+ candidates that need fixtures or opt-outs.
316
534
 
317
535
  ```bash
318
536
  styleproof-variants --base-url http://localhost:3000 --route / --route settings=/settings
@@ -370,16 +588,30 @@ defineStyleMapCapture({
370
588
 
371
589
  Destructive labels are skipped, duplicate computed-style outcomes are deduped,
372
590
  and `--strict` exits non-zero when live-state fixtures or skipped candidates
373
- remain unresolved.
374
-
375
- **Live UI states: capture each state, not an average.** StyleProof automatically
376
- detects semantic live-state candidates (`aria-live`, `role=status`, `role=alert`,
377
- `aria-busy=true`) and keeps stable ones in the normal diff. If a stream, poll, or
378
- live region represents product states you want certified (`loading`, `loaded`,
379
- `empty`, `error`), list only those pinned states with `liveStates`. StyleProof
380
- writes separate captures such as `dashboard-loading@1440` and
381
- `dashboard-loaded@1440`, so the base branch's loading state compares to the
382
- feature branch's loading state, and loaded compares to loaded.
591
+ remain unresolved. The harvester only finds states **reachable by interacting**
592
+ — it clicks, selects, and expands. A data-driven conditional branch (a fault
593
+ overlay, an empty render) has no control to click, so it never appears in the
594
+ manifest; those need `liveStates` fixtures, per the
595
+ [un-exercised-state gap](#the-un-exercised-state-gap-an-honest-green-gate-can-still-miss-a-real-restyle).
596
+
597
+ ### Live UI states: capture each state, not an average
598
+
599
+ StyleProof automatically detects semantic live-state candidates (`aria-live`,
600
+ `role=status`, `role=alert`, `aria-busy=true`) and keeps stable ones in the
601
+ normal diff. If a stream, poll, or live region represents product states you
602
+ want certified (`loading`, `loaded`, `empty`, `error`), list only those pinned
603
+ states with `liveStates`. StyleProof writes separate captures such as
604
+ `dashboard-loading@1440` and `dashboard-loaded@1440`, so the base branch's
605
+ loading state compares to the feature branch's loading state, and loaded
606
+ compares to loaded.
607
+
608
+ This is also how you close the
609
+ [un-exercised-state gap](#the-un-exercised-state-gap-an-honest-green-gate-can-still-miss-a-real-restyle):
610
+ a conditional branch that only renders under specific data (a fault overlay, an
611
+ empty list, a permission wall) needs its own pinned state here, with the
612
+ fixture in **that surface's** `setup` — per-surface, not a global route
613
+ override, which would leak the faulty payload into every other surface reading
614
+ the same endpoint. Note both fixtures in the example below are scoped this way.
383
615
 
384
616
  ```ts
385
617
  defineStyleMapCapture({
@@ -408,38 +640,79 @@ defineStyleMapCapture({
408
640
  });
409
641
  ```
410
642
 
411
- ## What a report looks like
643
+ ## Deterministic by default
412
644
 
413
- One change the hero CTA recoloured cyan amber appears as a single section in the report: a side-by-side before/after cropped screenshot, the same crop again with magenta boxes marking exactly what changed, a one-line summary, then the exact property change folded under a toggle. A change too small to see at 1:1 (say a 2px icon tweak) also gets a magnified zoom crop, so a sub-pixel change can't slip past a reviewer.
645
+ A style diff only means something if both sides saw the same inputs; otherwise live-data drift (a backend blip, a `5m ago` timestamp, a status chip that flips) reads as a style change on a PR that touched no CSS. StyleProof handles this for you **no fixtures required**:
414
646
 
415
- ![A StyleProof report: the CTA button before (cyan) and after (amber), side by side](https://raw.githubusercontent.com/BenSheridanEdwards/StyleProof/main/docs/demo-composite.png)
647
+ - **Record / replay.** The base capture records each surface's data responses (anything matching `**/api/**`) to a HAR; the head capture replays them, so the head renders _its_ code against the _base's_ data — the app's own JS/CSS still load live. Backend down during a run? Both sides replay the same recording, so there's no phantom diff. Point the head capture at the base's recording with `STYLEPROOF_REPLAY_FROM=<base dir>` (set on the head capture); tune the data boundary with `STYLEPROOF_REPLAY_URL` / `replayUrl` if your API isn't under `/api`.
648
+ - **Frozen clock.** `Date.now()` / `new Date()` are pinned to a fixed instant, so time-derived styling (`stale > 1h → red`) can't drift. Timers keep running, so settling still works.
649
+ - **Self-check** — captures each surface twice and fails if they differ, so a replay gap or unseeded randomness surfaces as a clear _"non-deterministic capture"_ error, never as a phantom change on an unrelated PR. **On by default while recording** (where live nondeterminism shows up); off on the replay run, which renders against the recorded HAR and is deterministic by construction. `STYLEPROOF_SELFCHECK=1` forces it on for both; `selfCheck: false` opts out.
650
+ - **Framework noise is skipped by default.** Non-visual and framework-injected elements never count as a change — `<meta>`/`<title>`/`<script>`/`<style>`/… (which Next.js streams into the body then hoists) and live regions like Next's `next-route-announcer`. A real stylesheet change still shows up in the affected elements' computed styles, not in the `<style>` tag. Add your own selectors with `ignore` — they extend this default, they don't replace it.
651
+ - **Layout-equivalent margin noise is normalised.** If the browser reports
652
+ horizontal auto-centering margins (`margin-left`/`margin-right` and logical
653
+ equivalents) differently but the captured document-space rectangle is
654
+ identical, StyleProof treats that as the same rendered layout, including in
655
+ forced `:hover`/`:focus`/`:active` deltas. The suppression only fires when the
656
+ sides drift **together** (no demonstrable px imbalance between a side and its
657
+ opposite): a one-sided change like `margin-left: 0 → 40px` still reports even
658
+ when something else compensates and the box doesn't move, and if the box moves
659
+ or resizes, any margin change reports.
416
660
 
417
- 📄 **[See a full live report](docs/demo/report.md)** rendered by the current code with real images (clean before/after, the highlighted twin, a magnified zoom for a sub-pixel change, and a `🆕 new surface`). It's regenerated and verified on every PR (`npm run demo:report`), so it always reflects exactly what StyleProof produces today.
661
+ > Replay covers data the page _fetches_. If your app **server-renders** differently per environment (SSR feature flags, locale), still capture both sides with the same server env so the rendered HTML matches.
418
662
 
419
- As it renders in the committed report (a plain-English bullet first naming the theme token and showing the hex with a live colour swatchthen the exact table inside the toggle). The PR comment itself stays lean a summary plus the approval box and links here:
663
+ **Live pages just work when the intended state is deterministic.** Before each capture, StyleProof settles the page, and the settle is **network-aware**: it holds while the page's data requests are in flight (excluding long-lived `EventSource`/WebSocket streams, which never finish) _and_ until the computed-style map stops changing. So async content (a fetch backfilling a grid, an SSE stream) is captured **loaded, not mid-load** and, crucially, it **can't false-settle on the loading state before a slow backend's response arrives**. That's the failure mode of a fixed wait: against a slow server (e.g. a dev server under CI load) a timer settles on the loading skeleton one run and the loaded deck the next — a phantom diff / self-check flake. Waiting on the actual request removes it.
420
664
 
421
- ```text
422
- ### `a.btn-solid` · 1 element restyled
423
- _landing @ 1280_
665
+ Anything still moving on its own after that is detected as a volatile region and excluded from direct element comparison, so a stream or ticker never reads as a change just because its value changed. That is not the same as certifying every state of the live UI: an ignored or volatile subtree can still change `html`/`body` layout if its height changes. When those states matter, make them deterministic `liveStates` (`loading`, `loaded`, `empty`, `error`) and capture each on both branches. Self-check and reports automatically mention detected live-state candidates when volatile layout drift appears. `defineStyleMapCapture` arms the request tracker before each `go()` automatically; for a direct `captureStyleMap` call, arm one before you navigate with `trackInflightRequests(page)` and pass `{ pendingRequests }`. Disable or tune with `{ stabilize: false }` / `{ stabilize: { quietFor, timeout, waitForRequests } }`.
424
666
 
425
- - **`a.btn-solid`**background `brand-cyan` (`#5fcadb`) `brand-amber` (`#f59e0b`)
667
+ **At a glance almost everything is automatic.** The few knobs exist only for what StyleProof can't know about your app, and each says why:
426
668
 
427
- Show the property change
428
- | Property | Before | After |
429
- | background-color | #5fcadb | #f59e0b |
430
- ```
669
+ | Handled for you — zero config | How |
670
+ | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
671
+ | In-flight data, fonts, late layout | network-aware settle holds until requests finish _and_ the computed styles stop changing |
672
+ | Animations, transitions, real hover/focus, caret | frozen / neutralised before the map is read; forced states are captured separately |
673
+ | Clock-derived styling (`stale > 1h → red`) | `Date.now()` / `new Date()` frozen to a fixed instant |
674
+ | Framework & non-visual noise (`<script>`, route announcers) | skipped by default |
675
+ | Layout-equivalent horizontal auto margins | ignored only when the rectangle is unchanged **and** the sides drift together — a one-sided change still reports |
676
+ | Semantic live-state candidates (`aria-live`, `role=status`) | auto-detected and kept in the diff when stable |
677
+ | Live / volatile regions (tickers, third-party embeds) | auto-detected as still-moving and excluded from direct element comparison |
678
+ | Non-deterministic capture (replay gap, unseeded randomness) | self-check flags it _while recording_, with a named error |
431
679
 
432
- ## Works with any styling system
680
+ | You set this only because it's app-specific | Why it exists |
681
+ | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
682
+ | `STYLEPROOF_REPLAY_FROM` (record / replay) | Base and head capture at different times against a live backend; replaying the base's recorded data pins the head to the same inputs, so the diff is **your code, not data drift**. The one piece of real setup. |
683
+ | `replayUrl` / `STYLEPROOF_REPLAY_URL` | Your data endpoints aren't under `**/api/**`. |
684
+ | `ignore: ['.selector']` | You want a region gone **explicitly** — auto-exclude already handles most live regions, but a known-noisy element reads clearer named. |
685
+ | `liveStates: [{ key, setup, go }]` | A live feature has real states to certify. Capture each state on base and head (`surface-loading`, `surface-loaded`) instead of relying on a single moving page state. |
686
+ | `variants: [{ key, setup, go }]` | Non-live deterministic variants, such as nav-open, modal-open, toast-visible, or overlay-expanded states. |
687
+ | `popups: true` | Visible click-triggered overlays should be discovered automatically. Captures each matching trigger's persistent dialogs, modal roots, popovers, menus, listboxes, toast/status roots, and open data-state overlays as `surface-popup-XX`; keep hover-only or destructive states as explicit `variants`. |
688
+ | `clockTime` | Your styling keys off a **specific** date, not just "now". |
689
+ | `stabilize: { quietFor, timeout }` | An unusually slow surface needs a longer quiet window before the map is read. |
433
690
 
434
- 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.
691
+ ### Data residue: a failed data request is named, not swallowed
692
+
693
+ A subtler gap than a _missing_ surface is a surface that renders the **wrong** state, silently. If a surface requests a data endpoint that nothing routes — no fixture, no `liveStates` — the request falls through and **fails during capture**, so the view paints its _fallback_ branch. Every capture then embeds that fallback; the state its real responses would drive is never captured, and a restyle confined to it ships green. StyleProof used to watch that request fail and say nothing.
435
694
 
436
- ## Breakpoints, detected automatically
695
+ Now it names it. During a spec-driven capture, any request matching the data boundary (`replayUrl`, default `**/api/**`) that **fails** — a network error, or a 4xx/5xx — is:
437
696
 
438
- Omit `widths` on a surface and StyleProof reads your app's real `@media` breakpoints from the **loaded CSSOM** at capture time and sweeps one viewport per band — no config. It's framework-agnostic for the same reason the diff is: it reads the rules the browser actually parsed, not your source, so Tailwind / CSS Modules / Sass / vanilla all resolve to the same `@media` boundaries. And it's authoritative **or it fails** an unreadable cross-origin stylesheet throws rather than silently miss a band; it never guesses. Pin `widths` explicitly when you want a fixed sweep, or to cover a JS-only (`matchMedia`) breakpoint that has no CSS rule.
697
+ - **warned on stderr, always**, naming the surface and endpoint, what it means (the fallback branch was captured; the response-driven states are unproven), and what to dofixture it with `page.route`/`liveStates`, or acknowledge it;
698
+ - **recorded on the capture** (`StyleMap.dataResidue`) so `styleproof-diff` and the report's certification block surface it, deduped per surface·endpoint across widths and the self-check re-run;
699
+ - **gated by default.** An _unacknowledged_ failing endpoint blocks the diff (exit 1): a silently-failing endpoint means the fallback branch shipped as the certified state, so gating is the correct default, not an opt-in. Acknowledge intentional ones in `styleproof.data-residue.json` (`{"<surface·endpoint>": "why"}`) — they render as visible opt-outs — and a **stale** acknowledgement (the endpoint no longer fails or isn't present) also fails, so the ledger can't rot. The same `exclude`-ledger discipline as the [inventory guard](docs/inventory-guard.md). Set `dataResidue: 'warn'` to opt down to record-and-warn without gating.
439
700
 
440
- ## Certify a refactor
701
+ A 2xx endpoint that merely wasn't fixtured is **never** flagged: in recording mode every live response is legitimately recorded, so a blanket "uncontrolled" flag would fire on every healthy record run. Only _failures_ are residue. And StyleProof never synthesises the missing state for you — declaring an app's data states stays app-owned (see the un-exercised-state gap this pairs with). A capture with no failing data request is byte-identical whichever mode you run, so a clean app is unaffected.
441
702
 
442
- 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.
703
+ ```ts
704
+ // Gate by default — an unacknowledged failing data endpoint blocks the diff.
705
+ defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
706
+
707
+ // Opt down to record-and-warn without gating:
708
+ defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR, dataResidue: 'warn' });
709
+ ```
710
+
711
+ ## Any styling system, real breakpoints
712
+
713
+ 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.
714
+
715
+ Breakpoints are detected the same way: omit `widths` on a surface and StyleProof reads your app's real `@media` breakpoints from the **loaded CSSOM** at capture time and sweeps one viewport per band — no config. It's framework-agnostic for the same reason the diff is: it reads the rules the browser actually parsed, not your source, so Tailwind / CSS Modules / Sass / vanilla all resolve to the same `@media` boundaries. And it's authoritative **or it fails** — an unreadable cross-origin stylesheet throws rather than silently miss a band; it never guesses. Pin `widths` explicitly when you want a fixed sweep, or to cover a JS-only (`matchMedia`) breakpoint that has no CSS rule.
443
716
 
444
717
  ## Match a design pixel-for-pixel
445
718
 
@@ -452,7 +725,7 @@ styleproof-diff design .styleproof/maps/current # design vs build — zero dif
452
725
 
453
726
  You watch one number as you implement: the diff starts large and shrinks toward zero, and it hits zero the moment the built page matches the design. It's the objective version of putting the mockup and the app side by side and squinting.
454
727
 
455
- (`styleproof-map` is the spec-driven flow for your own app's surfaces, with the coverage guard, map store, and record/replay; `styleproof-capture` is the one-shot for a page you just point at.) It writes `design/pricing@1440.json.gz` (+ `.png`), the same shape any capture writes, so `styleproof-diff` compares it against anything. Omit `--widths` to auto-detect the page's own `@media` breakpoints; pin them for a page whose CSS is cross-origin (a font stylesheet, say), since detection reads every sheet and fails loudly rather than guess. `--wait <selector>` holds until the intended state is on screen; `--ignore <selector>` skips a live region. Capture both sides in the same browser + fonts, since that's what "identical" is measured against.
728
+ (`styleproof-map` is the spec-driven flow for your own app's surfaces, with the coverage guard, map store, and record/replay; `styleproof-capture` is the one-shot for a page you just point at.) It writes `design/pricing@1440.json.gz` (+ `.png`) and a `styleproof-manifest.json`, the same shape any capture writes, so `styleproof-diff` compares it against anything — the manifest is what lets the two-directory compare verify both sides came from the same environment (v4 refuses a manifest-less side). Omit `--widths` to auto-detect the page's own `@media` breakpoints; pin them for a page whose CSS is cross-origin (a font stylesheet, say), since detection reads every sheet and fails loudly rather than guess. `--wait <selector>` holds until the intended state is on screen; `--ignore <selector>` skips a live region. Capture both sides in the same browser + fonts, since that's what "identical" is measured against.
456
729
 
457
730
  ### Crawl the whole interactive design: `--crawl`
458
731
 
@@ -467,7 +740,7 @@ It's **exhaustive by default**: the crawl stops when there is nothing left to dr
467
740
 
468
741
  What makes exhaustive affordable is that the sweep works **in place**: standing in a state, each control is clicked right where the page is, and a cheap DOM fingerprint decides what happened — a no-op click costs nothing, and only a state-changing click pays a reset (fresh navigation + replay of the click-path), which is then **verified by fingerprint** so children are never attributed to the wrong parent. New surfaces are captured at every width the moment they're reached — a deep or animated click-path is never re-driven to capture, so it can't be the thing that drops a surface. Progress streams as it goes, one line per captured surface. And it's **parallel by default** — `--workers <n>` (default 4) sweeps states concurrently on isolated browser contexts with the exact same surface set as a serial crawl (dedup is shared; children only enter the queue when their parent's sweep completes); `--workers 1` if you want byte-stable dup-key attribution.
469
742
 
470
- **And it proves nothing was missed.** After the crawl, StyleProof compares every class the page's own stylesheets define (read from the parsed CSSOM) against the classes actually rendered across the captured surfaces, and prints what — if anything — was never seen. `--require-full-coverage` turns any residue into exit code 4, so "the design is fully covered" is a CI-checkable property, not a judgement call. What's left is either dead CSS (delete it) or a state the crawl couldn't reach (drive it with a spec, or file the gap).
743
+ **And it proves nothing was missed.** After the crawl, StyleProof compares every class the page's own stylesheets define (read from the parsed CSSOM) against the classes actually rendered across the captured surfaces, and prints what — if anything — was never seen. `--require-full-coverage` turns any residue into exit code 4, so "the design is fully covered" is a CI-checkable property, not a judgement call. What's left is either dead CSS (delete it) or a state the crawl couldn't reach (drive it with a spec, or file the gap). When coverage is the goal rather than the map, `--until-covered` stops the crawl early the moment every class has rendered — the fast check, vs the exhaustive default.
471
744
 
472
745
  **Destructive-looking controls (delete, deploy, pay, revoke…) are never clicked** — mapping must not mutate; states gated behind one of those need a spec. Prefer the spec-driven `defineStyleMapCapture` when you want stable, named keys and the coverage guard; reach for `--crawl` to map a design (or a third-party page) you don't have a spec for.
473
746
 
@@ -512,122 +785,6 @@ The crawl's vocabulary is **click, select, neutral typing, scrolling, and your s
512
785
 
513
786
  The rule of thumb: **a rendered state is a function of props, data, and input.** Control all three — mock the data, script the input, mount the component — and every state a component can render is a capturable surface. The verifier tells you, by name, which ones you haven't controlled yet.
514
787
 
515
- ## Install
516
-
517
- ```bash
518
- npm install -D styleproof @playwright/test
519
- npx playwright install chromium
520
- ```
521
-
522
- Requires **Node ≥ 18** (ESM), **`@playwright/test` ≥ 1.40** (peer dep). Forced states are Chromium-only.
523
-
524
- ## Quickstart
525
-
526
- After installing (above), one command sets up the whole gate:
527
-
528
- ```bash
529
- npx styleproof-init
530
- ```
531
-
532
- It scaffolds:
533
-
534
- - a **capture spec** (`e2e/styleproof.spec.ts`) describing your surfaces (a Next.js app gets its routes _and_ the coverage guard wired automatically — see below);
535
- - a dedicated **`playwright.styleproof.config.ts`** that builds and serves a **production build** (never a flaky dev server), scopes discovery to the StyleProof spec, and captures surfaces **in parallel** (`fullyParallel`) without disturbing your app's existing Playwright config;
536
- - `.gitignore` entries for `.styleproof/`, `test-results/`, and `playwright-report/`;
537
- - a **cache-first CI workflow** that restores reusable maps from the `styleproof-maps` branch and generates the report without a browser when both maps are already built.
538
-
539
- Describe your surfaces — **omit `widths`** and StyleProof sweeps your real `@media` breakpoints automatically:
540
-
541
- ```ts
542
- import { defineStyleMapCapture } from 'styleproof';
543
-
544
- defineStyleMapCapture({
545
- surfaces: [
546
- {
547
- key: 'landing',
548
- go: (page) => page.goto('/'), // StyleProof settles the page (in-flight data, fonts, animations) before it reads
549
- // no `widths` → auto-detected from your @media bands; set `widths: [1280, 768, 390]` to pin them
550
- },
551
- ],
552
- dir: process.env.STYLEMAP_DIR,
553
- });
554
- ```
555
-
556
- The manual loop is deliberately three commands:
557
-
558
- ```bash
559
- npx styleproof-init
560
- npx styleproof-map
561
- npx styleproof-diff
562
- ```
563
-
564
- `styleproof-map` captures the current commit into `.styleproof/maps/current`,
565
- writes a manifest, and uploads the bundle
566
- to the dedicated `styleproof-maps` branch when the working tree was clean and a
567
- git remote is available. Nothing under `.styleproof/` belongs in the PR branch.
568
- HAR recordings are removed before upload by default so private API responses do
569
- not land in the map store. Keep them locally only for an explicit record/replay
570
- workflow with `styleproof-map --keep-har` (or `STYLEPROOF_KEEP_HAR=1`).
571
-
572
- `styleproof-diff` restores the base and head maps from `styleproof-maps`
573
- automatically: in GitHub Actions it uses the PR base/head SHAs; locally it checks
574
- `branch.<name>.gh-merge-base`, then the current GitHub PR base via `gh pr view`
575
- (handy for stacked PRs), then `origin/main`, `origin/master`, `main`, and
576
- `master`. Pin the base with `styleproof-diff main` or `styleproof-diff master`.
577
-
578
- **That's the whole loop.** Build the map outside CI when possible by running
579
- `styleproof-map` after committing. On the PR, CI first restores the base/head
580
- bundles and only generates the report — no build, no browser. If either bundle
581
- is missing or incompatible, CI recaptures both sides in the same pinned
582
- environment before reporting. Correctness wins over a stale cache, but the hot
583
- path is report-only.
584
-
585
- > **Same-environment note.** Computed styles depend on the browser build and installed fonts, so maps are only comparable when captured in the same runtime environment. StyleProof records a compatibility key to select the right cached bundle and refuses to compare maps captured under different browser/platform settings; CI then recaptures both sides instead of producing a bogus report. Each capture also records the **real browser build** (`browser().version()`) in its manifest — the npm `@playwright/test` version is only a proxy, and the actual Chromium binary can change while it holds constant (a `playwright install` re-download, a different `PLAYWRIGHT_BROWSERS_PATH`, a CI image bump). When both sides carry it, a differing build refuses to compare (exit 2, both builds named) instead of walling the PR with false diffs. **Installed fonts are your responsibility:** they are noisy across machines (user-installed families, OS updates, and no cheap cross-platform enumeration), so StyleProof does not fingerprint them — capture both sides on the same fonts, which is what CI's pinned image already gives you.
586
-
587
- **Want the local side-by-side report** (not just a pass/fail diff)? Run `npx
588
- styleproof-report` after `styleproof-map`; it uses the same inferred base ref and
589
- the same cached-map defaults as `styleproof-diff`. Pin the base with
590
- `styleproof-report main` or keep the manual form with `styleproof-report before
591
- after --out report`.
592
-
593
- **Want the side-by-side report + one-click approval**? `styleproof-init` scaffolds
594
- this for you. If you wire it by hand, restore or capture two dirs first, then use
595
- the Action on those dirs:
596
-
597
- ```yaml
598
- # .github/workflows/styleproof.yml
599
- - uses: actions/checkout@v4
600
- - run: npx styleproof-map --restore --sha "${{ github.event.pull_request.base.sha }}" --dir base --base-dir __stylemaps__
601
- - run: npx styleproof-map --restore --sha "${{ github.event.pull_request.head.sha }}" --dir head --base-dir __stylemaps__
602
- - uses: BenSheridanEdwards/StyleProof@v3
603
- with:
604
- baseline-dir: __stylemaps__/base
605
- fresh-dir: __stylemaps__/head
606
- require-approval: true # review-gate mode (omit / use fail-on-diff: true to certify)
607
- ```
608
-
609
- Then 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 approval checkbox is inert until it's merged).
610
-
611
- **Prefer to always capture in CI?** For a repo with many outside contributors on different machines, StyleProof can capture **both** base and head in CI and diff them there. See **[Forks and Dependabot](#forks-and-dependabot)** for that flow (it's also the fork-safe split). The default cache-first flow is faster for same-repo teams because local `styleproof-map` can build the head map before CI starts.
612
-
613
- **Want to skip work safely?** Skip the **whole** StyleProof workflow only for
614
- changes that cannot affect rendered output, such as docs-only edits, using your
615
- CI provider's native path filters. Do not skip individual surfaces from a
616
- StyleProof run based on a changed-file guess: shared CSS, tokens, resets,
617
- themes, layout primitives, and runtime styling can repaint any surface, and a
618
- missed surface would certify green without being measured. If you want faster
619
- feedback, order the highest-signal surfaces first in your spec, but still let
620
- the full sweep finish before treating the gate as passed.
621
-
622
- ```yaml
623
- on:
624
- pull_request:
625
- paths-ignore:
626
- - '**/*.md'
627
- - 'docs/**'
628
- - '.github/ISSUE_TEMPLATE/**'
629
- ```
630
-
631
788
  ## Forks and Dependabot
632
789
 
633
790
  If you **always capture in CI** rather than restoring maps from `styleproof-maps` (a better fit when many outside contributors push from different machines), the simplest setup runs the whole gate in one `pull_request` job that captures base + head and diffs them. That job needs a **write** token to push the report branch, post the comment, and set the `StyleProof` status. That's fine for same-repo PRs, but **fork and Dependabot PRs run with a read-only `GITHUB_TOKEN`** (GitHub's security default for untrusted PRs). So the job can't post the status — and a required `StyleProof` check then sits `pending` forever, blocking the PR even though a dependency or fork change usually touches no UI at all.
@@ -643,49 +800,6 @@ That last point is why this works where `pull_request_target` does not: StylePro
643
800
 
644
801
  Copy both `capture` and `report` files to `.github/workflows/` (the `report` one must be on your default branch, like `styleproof-approve.yml`), then require the `StyleProof` status in branch protection. A single combined `pull_request` job that captures base + head and diffs them is fine for repos that never see fork or bot PRs; this split is only needed for untrusted PRs.
645
802
 
646
- **Deterministic by default — no fixtures required.** A style diff only means something if both sides saw the same inputs; otherwise live-data drift (a backend blip, a `5m ago` timestamp, a status chip that flips) reads as a style change on a PR that touched no CSS. StyleProof handles this for you:
647
-
648
- - **Record / replay.** The base capture records each surface's data responses (anything matching `**/api/**`) to a HAR; the head capture replays them, so the head renders _its_ code against the _base's_ data — the app's own JS/CSS still load live. Backend down during a run? Both sides replay the same recording, so there's no phantom diff. Point the head capture at the base's recording with `STYLEPROOF_REPLAY_FROM=<base dir>` (set on the head capture); tune the data boundary with `STYLEPROOF_REPLAY_URL` / `replayUrl` if your API isn't under `/api`.
649
- - **Frozen clock.** `Date.now()` / `new Date()` are pinned to a fixed instant, so time-derived styling (`stale > 1h → red`) can't drift. Timers keep running, so settling still works.
650
- - **Self-check** — captures each surface twice and fails if they differ, so a replay gap or unseeded randomness surfaces as a clear _"non-deterministic capture"_ error, never as a phantom change on an unrelated PR. **On by default while recording** (where live nondeterminism shows up); off on the replay run, which renders against the recorded HAR and is deterministic by construction. `STYLEPROOF_SELFCHECK=1` forces it on for both; `selfCheck: false` opts out.
651
- - **Framework noise is skipped by default.** Non-visual and framework-injected elements never count as a change — `<meta>`/`<title>`/`<script>`/`<style>`/… (which Next.js streams into the body then hoists) and live regions like Next's `next-route-announcer`. A real stylesheet change still shows up in the affected elements' computed styles, not in the `<style>` tag. Add your own selectors with `ignore` — they extend this default, they don't replace it.
652
- - **Layout-equivalent margin noise is normalised.** If the browser reports
653
- horizontal auto-centering margins (`margin-left`/`margin-right` and logical
654
- equivalents) differently but the captured document-space rectangle is
655
- identical, StyleProof treats that as the same rendered layout, including in
656
- forced `:hover`/`:focus`/`:active` deltas. If the box moves or resizes, the
657
- margin change still reports.
658
-
659
- > Replay covers data the page _fetches_. If your app **server-renders** differently per environment (SSR feature flags, locale), still capture both sides with the same server env so the rendered HTML matches.
660
-
661
- **Live pages just work when the intended state is deterministic.** Before each capture, StyleProof settles the page, and the settle is **network-aware**: it holds while the page's data requests are in flight (excluding long-lived `EventSource`/WebSocket streams, which never finish) _and_ until the computed-style map stops changing. So async content (a fetch backfilling a grid, an SSE stream) is captured **loaded, not mid-load** — and, crucially, it **can't false-settle on the loading state before a slow backend's response arrives**. That's the failure mode of a fixed wait: against a slow server (e.g. a dev server under CI load) a timer settles on the loading skeleton one run and the loaded deck the next — a phantom diff / self-check flake. Waiting on the actual request removes it.
662
-
663
- Anything still moving on its own after that is detected as a volatile region and excluded from direct element comparison, so a stream or ticker never reads as a change just because its value changed. That is not the same as certifying every state of the live UI: an ignored or volatile subtree can still change `html`/`body` layout if its height changes. When those states matter, make them deterministic `liveStates` (`loading`, `loaded`, `empty`, `error`) and capture each on both branches. Self-check and reports automatically mention detected live-state candidates when volatile layout drift appears. `defineStyleMapCapture` arms the request tracker before each `go()` automatically; for a direct `captureStyleMap` call, arm one before you navigate with `trackInflightRequests(page)` and pass `{ pendingRequests }`. Disable or tune with `{ stabilize: false }` / `{ stabilize: { quietFor, timeout, waitForRequests } }`.
664
-
665
- **At a glance — almost everything is automatic.** The few knobs exist only for what StyleProof can't know about your app, and each says why:
666
-
667
- | Handled for you — zero config | How |
668
- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
669
- | In-flight data, fonts, late layout | network-aware settle holds until requests finish _and_ the computed styles stop changing |
670
- | Animations, transitions, real hover/focus, caret | frozen / neutralised before the map is read; forced states are captured separately |
671
- | Clock-derived styling (`stale > 1h → red`) | `Date.now()` / `new Date()` frozen to a fixed instant |
672
- | Framework & non-visual noise (`<script>`, route announcers) | skipped by default |
673
- | Layout-equivalent horizontal auto margins | ignored only when the captured element rectangle is unchanged |
674
- | Semantic live-state candidates (`aria-live`, `role=status`) | auto-detected and kept in the diff when stable |
675
- | Live / volatile regions (tickers, third-party embeds) | auto-detected as still-moving and excluded from direct element comparison |
676
- | Non-deterministic capture (replay gap, unseeded randomness) | self-check flags it _while recording_, with a named error |
677
-
678
- | You set this — only because it's app-specific | Why it exists |
679
- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
680
- | `STYLEPROOF_REPLAY_FROM` (record / replay) | Base and head capture at different times against a live backend; replaying the base's recorded data pins the head to the same inputs, so the diff is **your code, not data drift**. The one piece of real setup. |
681
- | `replayUrl` / `STYLEPROOF_REPLAY_URL` | Your data endpoints aren't under `**/api/**`. |
682
- | `ignore: ['.selector']` | You want a region gone **explicitly** — auto-exclude already handles most live regions, but a known-noisy element reads clearer named. |
683
- | `liveStates: [{ key, setup, go }]` | A live feature has real states to certify. Capture each state on base and head (`surface-loading`, `surface-loaded`) instead of relying on a single moving page state. |
684
- | `variants: [{ key, setup, go }]` | Non-live deterministic variants, such as nav-open, modal-open, toast-visible, or overlay-expanded states. |
685
- | `popups: true` | Visible click-triggered overlays should be discovered automatically. Captures each matching trigger's persistent dialogs, modal roots, popovers, menus, listboxes, toast/status roots, and open data-state overlays as `surface-popup-XX`; keep hover-only or destructive states as explicit `variants`. |
686
- | `clockTime` | Your styling keys off a **specific** date, not just "now". |
687
- | `stabilize: { quietFor, timeout }` | An unusually slow surface needs a longer quiet window before the map is read. |
688
-
689
803
  ## Optional: content layer (advisory)
690
804
 
691
805
  StyleProof is **computed-styles first**, and stays that way: a CSS-only refactor that also rewrites text is still certified identical, and live text (a clock, "2m ago") never reads as a change. But a pure-style diff is blind to copy, and copy isn't always cosmetic: **new or longer text can overflow or clip its box, silently breaking the layout.** A visual-confidence tool that can't see that isn't quite complete. So the content layer exists as an explicit **opt-in**, off by default, and **advisory** — it never feeds the certification or the gate.
@@ -721,7 +835,7 @@ Like the content layer it is **advisory**: never fed to the certification diff o
721
835
 
722
836
  ## Optional: selective remap (advisory)
723
837
 
724
- On a large app, capturing every surface on every PR is the slow part. `affectedSurfaces` answers the question that lets you skip most of it: **given the files a change touched, which declared surfaces could have rendered differently?** Everything it doesn't return can reuse its committed base map.
838
+ On a large app, capturing every surface on every PR is the slow part. `affectedSurfaces` answers the question that lets you skip most of it: **given the files a change touched, which declared surfaces could have rendered differently?** Everything it doesn't return can reuse its restored base map.
725
839
 
726
840
  It is **opt-in and never part of the default gate** — the gate still captures every surface and lets the map be the oracle. This is a helper for wiring a faster pre-push/CI path yourself, and it is built to be wrong only in the safe direction: when it cannot _prove_ a surface is unaffected, it returns the sentinel `'all'` (re-capture everything). A global stylesheet or token, a vanilla (unscoped) stylesheet, a `createGlobalStyle`, a design-system config, an unbounded `import(x)`, or a file it can't place — all resolve to `'all'`.
727
841
 
@@ -752,7 +866,7 @@ Two honest limits, both resolving to `'all'`: a computed `import(`../dir/${x}`)`
752
866
 
753
867
  ### Show the skip list, then wire the pre-push hook
754
868
 
755
- Before you trust a skip, print it. `explainAffectedSurfaces(result, allSurfaceKeys)` renders the verdict as reviewer-checkable lines — which surfaces re-capture and which reuse their committed base map — and takes an optional reason string for the `'all'` case:
869
+ Before you trust a skip, print it. `explainAffectedSurfaces(result, allSurfaceKeys)` renders the verdict as reviewer-checkable lines — which surfaces re-capture and which reuse their restored base map — and takes an optional reason string for the `'all'` case:
756
870
 
757
871
  ```ts
758
872
  import { affectedSurfaces, explainAffectedSurfaces } from 'styleproof';
@@ -779,7 +893,7 @@ selective remap: OFF → re-capture all 3 surface(s) — src/tokens.css is a glo
779
893
  ↻ pricing (re-capture)
780
894
  ```
781
895
 
782
- Wired into a pre-push hook, the whole recipe is: diff the changed files, produce the graph, ask `affectedSurfaces`, print the skip list, then capture only the subset and reuse the committed base maps for the rest.
896
+ Wired into a pre-push hook, the whole recipe is: diff the changed files, produce the graph, ask `affectedSurfaces`, print the skip list, then capture only the subset and reuse the base maps restored from `styleproof-maps` for the rest.
783
897
 
784
898
  ```sh
785
899
  #!/usr/bin/env sh
@@ -789,15 +903,11 @@ npx dependency-cruiser src --no-config --output-type json > dc.json
789
903
  node scripts/selective-remap.mjs "$CHANGED" dc.json # affectedSurfaces + explain + capture subset
790
904
  ```
791
905
 
792
- `scripts/selective-remap.mjs` is yours to own — it maps `dc.json` into `{ from, to }` edges (as above), calls `affectedSurfaces`, prints `explainAffectedSurfaces`, then captures only the returned keys and copies each reused surface's committed base map forward. `main` re-captures everything, so a PR-time miss is still caught at merge.
793
-
794
- ## Newly-added elements show their full style
795
-
796
- When a PR **adds** an element, StyleProof now reports its **full resting computed style** (background, padding, font, radius, …), value-only, in addition to any interaction-state deltas — previously an added element surfaced only its `:hover`/`:focus` changes. The new element already gates via its `added` finding; this only enriches what you see, in both the report and the `styleproof-diff` CLI.
906
+ `scripts/selective-remap.mjs` is yours to own — it maps `dc.json` into `{ from, to }` edges (as above), calls `affectedSurfaces`, prints `explainAffectedSurfaces`, then captures only the returned keys and copies each reused surface's restored base map forward. `main` re-captures everything, so a PR-time miss is still caught at merge.
797
907
 
798
908
  ## Reference
799
909
 
800
- **Action `BenSheridanEdwards/StyleProof@v3`** — key inputs:
910
+ **Action `BenSheridanEdwards/StyleProof@v4`** — key inputs:
801
911
 
802
912
  | Input | Default | Purpose |
803
913
  | ------------------ | ------------ | ----------------------------------------------------------------------------------- |
@@ -811,38 +921,42 @@ Outputs: `changed` (`"true"` when any existing surface changed, or a new surface
811
921
 
812
922
  **Policy file `styleproof.config.json`** (optional, at the repo root) — gate policy that isn't workflow plumbing:
813
923
 
814
- | Key | Default | Purpose |
815
- | ---------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
816
- | `blocking` | `false` | Review-gate mode only: on **unapproved** visual changes, also **fail the job** (red ✗), so the check blocks even without a branch-protection rule requiring the status. See below. |
924
+ | Key | Default | Purpose |
925
+ | ----------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
926
+ | `blocking` | `true` | Review-gate mode only: on **unapproved** visual changes, also **fail the job** (red ✗), so the check blocks even without a branch-protection rule requiring the status. On by default; set `false` for advisory-only (status red, job green). See below. |
927
+ | `gateInventoryRemovals` | `true` | Fail the Action on an **unacknowledged navigable removal** (see [What a green certifies](#what-a-green-certifies)). Set `false` to make inventory advisory. |
817
928
 
818
929
  ### Blocking without branch protection
819
930
 
820
- A commit status only _blocks a merge_ where a branch-protection rule requires it — which needs GitHub Pro or a public repo. On a free private repo the `StyleProof` status is advisory. Set `"blocking": true` in `styleproof.config.json` to also fail the report job on unapproved changes, so the PR shows a red check regardless:
931
+ A commit status only _blocks a merge_ where a branch-protection rule requires it — which needs GitHub Pro or a public repo. On a free private repo the `StyleProof` status is advisory. So review-gate mode is **blocking by default**: on unapproved changes it also fails the report job, and the PR shows a red check regardless of branch protection.
932
+
933
+ For advisory-only (the status goes red but the job stays green — useful when a branch-protection rule already requires the `StyleProof` status), opt out in `styleproof.config.json`:
821
934
 
822
935
  ```json
823
- { "blocking": true }
936
+ { "blocking": false }
824
937
  ```
825
938
 
826
939
  It's **asynchronous by design**: approval is a checkbox tick handled by a separate workflow, so to clear the red you tick **Approve all changes**, then **re-run the StyleProof job** — the re-run sees the sign-off on the commit status and passes. (A new push that changes styles re-opens it.)
827
940
 
828
941
  **Capture spec `defineStyleMapCapture({ surfaces, … })`** — determinism is on by default; you rarely set more than `surfaces` and `dir`:
829
942
 
830
- | Option | Default | Purpose |
831
- | ------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
832
- | `surfaces` | _required_ | Page states to certify — each `{ key, go, widths?, ignore?, height?, liveStates?, variants?, popups? }`. `go(page)` drives to a settled state. Omit `widths` to auto-detect the app's `@media` breakpoints and sweep one width per band. |
833
- | `liveStates` | _none_ | Optional pinned live product states. Each `{ key, setup?, go?, widths?, height?, ignore? }` becomes `<surface>-<state>` and is labeled as a live state in reports. |
834
- | `variants` | _none_ | Optional non-live deterministic states under a surface. The base surface still captures; each variant becomes `<surface>-<variant>` so base/head compare matching states. |
835
- | `popups` | `false` | Optional automatic popup capture. Set `true` or `{ max, triggers, overlays, timeoutMs }` to click visible safe triggers and save each opened overlay state as `<surface>-popup-XX`; maps include `overlays` proof metadata for captured semantic roots. |
836
- | `expected` | _none_ | Your route/view/state/component universe. Emits a coverage-guard test (runs without a capture dir) that fails when a required key has no surface and isn't excluded. |
837
- | `exclude` | `{}` | `key → reason` for routes deliberately not captured. Keeps the guard green for known gaps; a key absent from `expected` fails the guard, so the ledger can't go stale. |
838
- | `dir` | `STYLEMAP_DIR` | Output label (`base`/`head`); the spec is **inert until set**, so it sits safely beside your other specs. |
839
- | `replayFrom` | `STYLEPROOF_REPLAY_FROM` | Baseline dir whose recorded responses to replay. Unset → this run **records** its HAR for the comparison to use. |
840
- | `replayUrl` | `**/api/**` (`…REPLAY_URL`) | URL glob for the data boundary to record/replay; everything else (JS/CSS/fonts) loads live so the code runs. |
841
- | `freezeClock` | `true` | Pin `Date.now()`/`new Date()` so time-derived styling can't drift; timers keep running so settling still works. |
842
- | `clockTime` | `2025-01-01T00:00:00Z` | The frozen instant. |
843
- | `selfCheck` | on while recording | Capture each surface twice and fail on any difference — proves the capture is deterministic. Off on the replay run; `STYLEPROOF_SELFCHECK=1` forces both. |
844
- | `screenshots` | `true` | Save full-page screenshots for the report's before/after crops. |
845
- | `baseDir` | `__stylemaps__` | Output root directory. |
943
+ | Option | Default | Purpose |
944
+ | ------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
945
+ | `surfaces` | _required_ | Page states to certify — each `{ key, go, widths?, ignore?, height?, liveStates?, variants?, popups? }`. `go(page)` drives to a settled state. Omit `widths` to auto-detect the app's `@media` breakpoints and sweep one width per band. |
946
+ | `liveStates` | _none_ | Optional pinned live product states. Each `{ key, setup?, go?, widths?, height?, ignore? }` becomes `<surface>-<state>` and is labeled as a live state in reports. |
947
+ | `variants` | _none_ | Optional non-live deterministic states under a surface. The base surface still captures; each variant becomes `<surface>-<variant>` so base/head compare matching states. |
948
+ | `popups` | `false` | Optional automatic popup capture. Set `true` or `{ max, triggers, overlays, timeoutMs }` to click visible safe triggers and save each opened overlay state as `<surface>-popup-XX`; maps include `overlays` proof metadata for captured semantic roots. |
949
+ | `expected` | _none_ | Your route/view/state/component universe. Emits a coverage-guard test (runs without a capture dir) that fails when a required key has no surface and isn't excluded. |
950
+ | `exclude` | `{}` | `key → reason` for routes deliberately not captured. Keeps the guard green for known gaps; a key absent from `expected` fails the guard, so the ledger can't go stale. |
951
+ | `dir` | `STYLEMAP_DIR` | Output label (`base`/`head`); the spec is **inert until set**, so it sits safely beside your other specs. |
952
+ | `replayFrom` | `STYLEPROOF_REPLAY_FROM` | Baseline dir whose recorded responses to replay. Unset → this run **records** its HAR for the comparison to use. |
953
+ | `replayUrl` | `**/api/**` (`…REPLAY_URL`) | URL glob for the data boundary to record/replay; everything else (JS/CSS/fonts) loads live so the code runs. |
954
+ | `dataResidue` | `'gate'` | Name data-boundary (`replayUrl`) requests that **fail** during capture (network error / 4xx/5xx — the fallback branch got captured). Always warned + recorded; `'gate'` (the default) also blocks the diff on an unacknowledged one, `'warn'` is the opt-out that records + warns without gating. See [Data residue](#data-residue-a-failed-data-request-is-named-not-swallowed). |
955
+ | `freezeClock` | `true` | Pin `Date.now()`/`new Date()` so time-derived styling can't drift; timers keep running so settling still works. |
956
+ | `clockTime` | `2025-01-01T00:00:00Z` | The frozen instant. |
957
+ | `selfCheck` | on while recording | Capture each surface twice and fail on any difference — proves the capture is deterministic. Off on the replay run; `STYLEPROOF_SELFCHECK=1` forces both. |
958
+ | `screenshots` | `true` | Save full-page screenshots for the report's before/after crops. |
959
+ | `baseDir` | `__stylemaps__` | Output root directory. |
846
960
 
847
961
  Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<style>`/… and `next-route-announcer`) are skipped automatically; a surface's `ignore` adds to that default, it doesn't replace it.
848
962
 
@@ -858,16 +972,18 @@ Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<styl
858
972
  | `STYLEPROOF_SELFCHECK` | `1` to capture each surface twice and fail if the two differ. |
859
973
  | `STYLEPROOF_UPLOAD` | `1` to require map-store upload; `0` to capture locally only. |
860
974
  | `STYLEPROOF_CACHE_BRANCH` | Map store branch (default `styleproof-maps`). |
975
+ | `STYLEPROOF_SKIP_CAPTURE` | `1` to skip the scaffolded pre-push capture/publish hook for one push. |
861
976
  | `STYLEPROOF_CRAWL_BASE_URL` | App URL for the optional pre-map `styleproof-variants` crawl. |
862
977
  | `STYLEPROOF_CRAWL_ROUTES` | Comma-separated routes for the optional pre-map crawl, e.g. `/,settings=/settings`. |
863
978
  | `STYLEPROOF_CRAWL_STRICT` | `1` to fail the optional pre-map crawl on live-state fixtures or skipped candidates. |
864
979
 
865
980
  **CLIs** (every flag accepts `--flag value` and `--flag=value`; `--help` lists all):
866
981
 
867
- - `styleproof-init` — scaffold the gate: the capture spec, a dedicated `playwright.styleproof.config.ts` (production-build `webServer`, parallel capture), `.gitignore` cache entries, and the cache-first report workflow. One command. Generated commands follow the repo's lockfile (`bun.lock`/`bun.lockb`, `pnpm-lock.yaml`, `yarn.lock`, or npm by default), respect pnpm/Corepack version pins, and detect Vite/Next production preview commands instead of assuming every repo has `start`.
982
+ - `styleproof-init` — scaffold the gate: the capture spec (inventory guard on; Next.js repos get route discovery + the coverage guard, others a crawl-by-default spec), a dedicated `playwright.styleproof.config.ts` (production-build `webServer`, parallel capture), `.gitignore` cache entries, the cache-first report workflow, the approval workflow, and the pre-push publish hook. One command. Generated commands follow the repo's lockfile (`bun.lock`/`bun.lockb`, `pnpm-lock.yaml`, `yarn.lock`, or npm by default), respect pnpm/Corepack version pins, and detect Vite/Next production preview commands instead of assuming every repo has `start`.
868
983
  - `styleproof-map` — capture the current commit's computed-style map through Playwright. By default it writes `.styleproof/maps/current`, keeps screenshots for reports, writes a manifest, and uploads to `styleproof-maps` outside CI when the working tree was clean and a git remote exists. Pass `--crawl-base-url` plus repeated `--crawl-route` to run `styleproof-variants` before capture, `--no-upload`, `--restore --sha <commit>`, `--spec`, `--dir`, `--base-dir`, or `--no-screenshots` for custom flows.
869
- - `styleproof-diff` — the certify gate. With no args, it restores cached maps for the current commit and inferred base (`GITHUB_BASE_REF`, `branch.<name>.gh-merge-base`, `gh pr view`, then main/master fallbacks); `styleproof-diff main` / `styleproof-diff master` pins the base; `styleproof-diff <beforeDir> <afterDir>` keeps the manual two-directory form for CI fallback captures. Exits `0` certified (identical); `1` on a reviewable diff — computed-style/DOM/state differences, and equally an unacknowledged inventory removal, an incomplete coverage registry, or an unproven-determinism capture; `2` on a usage/capture error (including a **missing map** — a bundle that claims to exist yet holds zero captures, i.e. a `styleproof-manifest.json` present with no maps, on either side, or a head capture that produced nothing; refused loudly rather than mislabelled as all-new — **and** the no-args case where the cached base map can't be restored at all: no map-store remote, no cached bundle, nothing to compare. A "nothing was compared" outcome always exits `2`, never a soft `0` that would read as certified; the error names the two ways forward — run in CI where the base is restorable, or use the two-directory form); `3` when only new surfaces are present (no baseline for _those_ surfaces to diff against — new surfaces against an existing baseline, or a base dir with no manifest at all, meaning no baseline was ever captured: the first-adoption review path; approval policy decides whether to gate). A clean run prints `0 changed surfaces across N captured surface(s)`, and `--json` includes `compared`. The human output **groups the same way the report does**: surfaces that changed identically collapse into one finding (with the per-surface count on its header), longhands fold into shorthands, and size/position-derived longhands fold behind a `(+N derived longhands)` count — so one real change reads as one entry, not dozens of raw lines. A change that rode the shared frame every view draws (a persistent nav/header/footer) is promoted to a "🧱 Global chrome change" callout up top. `--json` stays the complete, unchanged machine contract — every surface and every raw longhand — regardless of the human grouping.
984
+ - `styleproof-diff` — the certify gate. With no args, it restores cached maps for the current commit and inferred base (`GITHUB_BASE_REF`, `branch.<name>.gh-merge-base`, `gh pr view`, then main/master fallbacks); `styleproof-diff main` / `styleproof-diff master` pins the base; `styleproof-diff <beforeDir> <afterDir>` keeps the manual two-directory form for CI fallback captures. Exits `0` certified (identical); `1` on a reviewable diff — computed-style/DOM/state differences, and equally an unacknowledged inventory removal, an unacknowledged failing data endpoint under an armed `dataResidue: 'gate'`, an incomplete coverage registry, or an unproven-determinism capture; `2` on a usage/capture error (including a **manifest-less side** — since **v4**, a two-directory compare where a side ships maps but no `styleproof-manifest.json` is refused loudly, naming the bare side(s), because the same-environment guard can't be enforced without one; re-capture with current StyleProof; **and** a **missing map** — a bundle that claims to exist yet holds zero captures, i.e. a `styleproof-manifest.json` present with no maps, on either side, or a head capture that produced nothing; refused loudly rather than mislabelled as all-new — **and** the no-args case where the cached base map can't be restored at all: no map-store remote, no cached bundle, nothing to compare. A "nothing was compared" outcome always exits `2`, never a soft `0` that would read as certified; the error names the two ways forward — run in CI where the base is restorable, or use the two-directory form); `3` when only new surfaces are present (no baseline for _those_ surfaces to diff against — new surfaces against an existing baseline, or a base dir with no maps at all (and hence no manifest), meaning no baseline was ever captured: the first-adoption review path; approval policy decides whether to gate). A clean run prints `0 changed surfaces across N captured surface(s)`, and `--json` includes `compared`. The human output **groups the same way the report does**: surfaces that changed identically collapse into one finding (with the per-surface count on its header), longhands fold into shorthands, and size/position-derived longhands fold behind a `(+N derived longhands)` count — so one real change reads as one entry, not dozens of raw lines. A change that rode the shared frame every view draws (a persistent nav/header/footer) is promoted to a "🧱 Global chrome change" callout up top. `--json` stays the complete, unchanged machine contract — every surface and every raw longhand — regardless of the human grouping.
870
985
  - `styleproof-report` — render the diff to a Markdown report with before/after crops. With no args, it reports cached maps for the current commit against the inferred base; `styleproof-report main` / `styleproof-report master` pins the base; `styleproof-report <beforeDir> <afterDir> --out <dir>` keeps the manual two-directory form. Add `--include-content` for the opt-in, advisory content section (see above).
986
+ - `styleproof-capture` — one-shot capture of any URL (no spec): `styleproof-capture <url> --key <name> --out <dir>`, with `--widths` (omit to auto-detect `@media` bands), `--wait <selector>`, `--ignore <selector>`, `--no-screenshots`, and the crawler flags (`--crawl`, `--setup <file>`, `--require-full-coverage` → exit 4 on residue, `--until-covered`, `--workers <n>`, `--no-data-states`) described in [Match a design pixel-for-pixel](#match-a-design-pixel-for-pixel).
871
987
  - `styleproof-variants` — crawl a running app for one-step state variants and write `styleproof.variants.generated.json`. Pass `--base-url`, repeat `--route`, and use `--strict` when unresolved skipped/live candidates should fail automation.
872
988
 
873
989
  A programmatic API is also exported — `captureStyleMap`, `diffStyleMaps`, `generateStyleMapReport`, and the breakpoint helpers `detectViewportWidths` / `widthsFromBoundaries`, among others. 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.