styleproof 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,84 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.2.0] - 2026-06-23
11
+
12
+ ### Added
13
+
14
+ - **Newly-added elements now report their full resting computed style**, not just
15
+ interaction-state deltas. Previously an added element surfaced only its
16
+ `:hover`/`:focus` changes (the diff short-circuited added elements before the
17
+ style loop); its background, padding, font, radius, etc. were captured but never
18
+ shown. The diff now emits the new element's full style as `(unset) → value`
19
+ findings and the report renders them value-only (no bogus "Before" column), in
20
+ both the PR report and the `styleproof-diff` CLI. The element already gated via
21
+ its `added` finding, so this enriches detail without changing what gates.
22
+ - **`captureComponent` (opt-in, default off): surface the React component + props**
23
+ behind each element. With it on, capture reads the React fiber in-page
24
+ (`__reactFiber$*`/`__reactProps$*` on React 17+, `__reactInternalInstance$*` on
25
+ ≤16) to record the component display name and a sanitized subset of its props
26
+ (primitives only; `children`/handlers/objects dropped) on `ElementEntry.component`.
27
+ The report names it — `React component: Button (variant=primary, size=sm)` —
28
+ instead of a bare `<button>`. **Advisory only**, exactly like the content layer:
29
+ never fed to the certification diff or its blocking counts, so captures stay
30
+ deterministic. Names are mangled in minified prod builds, so it's most useful
31
+ against dev/non-minified output; a no-op on non-React pages.
32
+
33
+ ## [2.1.0] - 2026-06-23
34
+
35
+ ### Added
36
+
37
+ - **Coverage guard (`expected` / `exclude`).** `defineStyleMapCapture` now accepts
38
+ `expected` — the app's route/view universe — and emits a guard test that fails
39
+ when a route has no captured surface and isn't in `exclude`. It runs in the
40
+ normal test suite (no `STYLEMAP_DIR`, no browser — a static check), closing the
41
+ one hole captures can't catch on their own: a new page nobody added to
42
+ `surfaces` is invisible to the diff (no base capture, no head capture), so the
43
+ gate goes green having never looked at it. `exclude` is a `key → reason` ledger
44
+ of deliberate opt-outs; a key absent from `expected` (a renamed/removed route)
45
+ fails the guard too, so the ledger can't rot. Omit `expected` and behaviour is
46
+ unchanged. The pure `coverageGaps(captured, expected, exclude)` helper is also
47
+ exported. Closes the class of regression where a brand-new view's styles ship
48
+ uncaptured because the surface list silently drifted from the app's routes.
49
+ - **Coverage guard works out of the box for Next.js.** New `discoverNextRoutes()`
50
+ reads the App Router (`app/`) and Pages Router (`pages/`) at run time and returns
51
+ `{ key, path, dynamic }[]` (route groups/slots stripped, `[param]` flagged). `styleproof-init`
52
+ now detects a Next.js app and scaffolds a spec that wires both the surfaces and
53
+ `expected` to it — so a fresh install is protected without hand-wiring, and a page
54
+ added later is covered automatically. Non-Next projects get the previous starter
55
+ surface plus a commented guard block to point at their own route registry.
56
+ - **Network-aware settle (default on).** The settle now holds while the page's own
57
+ data requests are in flight — excluding long-lived `EventSource`/WebSocket streams
58
+ (handled by the live-region pass) — instead of only waiting for the computed-style
59
+ map to go quiet. So late-fetched content is captured **loaded, not mid-load**, and
60
+ the settle can't false-settle on a loading state before a slow backend responds —
61
+ the phantom-diff / self-check flake that a fixed wait produces under CI load. New
62
+ exported `trackInflightRequests(page)` arms the tracker; `defineStyleMapCapture`
63
+ arms it before each `go()` automatically.
64
+ Opt out with `stabilize.waitForRequests: false`.
65
+ - **`styleproof-init` scaffolds a production-build web server.** The generated
66
+ `playwright.config.ts` now includes a `webServer` that runs
67
+ `npm run build && npm run start` (reusing a server already up), so a fresh project
68
+ captures against a production build by default instead of a `next dev`-style server
69
+ whose JIT timing variance is the top source of capture flakes.
70
+
71
+ ### Changed
72
+
73
+ - **`selfCheck` defaults on while recording, off on replay.** The determinism guard
74
+ (capture twice, fail on drift) was opt-in (`STYLEPROOF_SELFCHECK=1`), so by default
75
+ nondeterminism shipped silently as a phantom diff. It now defaults **on when
76
+ recording** (no `replayFrom`), where live nondeterminism surfaces, and **off on
77
+ replay**, which is deterministic by construction — so a fresh
78
+ `defineStyleMapCapture({ surfaces })` auto-detects and names nondeterminism with no
79
+ 2× cost on the replay run. `STYLEPROOF_SELFCHECK=1` still forces it on for both;
80
+ `selfCheck: false` opts out.
81
+ - **`styleproof-init` scaffolds a minimal `settle()`.** Now that the engine's
82
+ network-aware settle waits out in-flight data and fonts, freezes animations, and
83
+ blurs focus, the generated helper drops the hand-rolled `document.fonts.ready`,
84
+ animation-freeze, fixed `waitForTimeout`, and `networkidle` wait (the last an
85
+ active trap — it never fires against an SSE stream). It keeps only what the engine
86
+ can't know about — scroll-reveal — and `go()` is a plain `page.goto(...)`.
87
+
10
88
  ## [2.0.0] - 2026-06-22
11
89
 
12
90
  ### Changed
package/README.md CHANGED
@@ -19,13 +19,58 @@ On every PR, StyleProof captures a `StyleMap` from the HEAD and from the base br
19
19
  - **New surfaces don't block.** A surface that exists only on the PR head (no baseline to diff — e.g. the bootstrap PR that first adds the capture spec, or a brand-new page) is shown in the report under a `🆕 new surface` heading but never holds the status red and needs no sign-off. It becomes part of the baseline once merged.
20
20
  - No committed baseline to maintain — the diff is HEAD-vs-base, so the report is _exactly what this PR changes_.
21
21
 
22
+ ## Don't let a new page ship uncaptured
23
+
24
+ StyleProof diffs the surfaces your spec lists — so a page nobody added to the list is invisible to the gate. Its change has no base capture _and_ no head capture, so it never appears in any diff, and the status goes green having never looked at it. This is the one thing the captures can't catch on their own: a capture that was never taken.
25
+
26
+ 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 the moment a route exists with no surface, so a new page can't ship uncaptured:
27
+
28
+ ```ts
29
+ import { defineStyleMapCapture } from 'styleproof';
30
+ import { ROUTES } from '../app/routes'; // your registry — wherever routes live
31
+
32
+ defineStyleMapCapture({
33
+ dir: process.env.STYLEMAP_DIR,
34
+ surfaces: SURFACES,
35
+ expected: ROUTES.map((r) => r.id), // every route StyleProof should cover
36
+ exclude: { checkout: 'auth-gated — capture fixture pending' }, // visible, reviewed opt-outs (key → reason)
37
+ });
38
+ ```
39
+
40
+ 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.
41
+
42
+ **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 wires both the surfaces and `expected` to them — so it's protected out of the box, and a page you add later is covered automatically with nothing to keep in sync:
43
+
44
+ ```ts
45
+ import { defineStyleMapCapture, discoverNextRoutes } from 'styleproof';
46
+
47
+ const ROUTES = discoverNextRoutes(); // [{ key, path, dynamic }, …] from app/ + pages/
48
+ defineStyleMapCapture({
49
+ surfaces: ROUTES.filter((r) => !r.dynamic).map((r) => ({
50
+ key: r.key,
51
+ go: (p) => p.goto(r.path),
52
+ widths: [1280, 768, 390],
53
+ })),
54
+ expected: ROUTES.map((r) => r.key),
55
+ exclude: Object.fromEntries(
56
+ ROUTES.filter((r) => r.dynamic).map((r) => [
57
+ r.key,
58
+ `dynamic route ${r.path} — add a surface with a concrete param`,
59
+ ]),
60
+ ),
61
+ dir: process.env.STYLEMAP_DIR,
62
+ });
63
+ ```
64
+
65
+ `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.
66
+
22
67
  ## What a report looks like
23
68
 
24
- One change — the hero CTA recoloured cyan → amber — posts as a single section: a side-by-side before/after cropped screenshot, a one-line summary, then the exact property change folded under a toggle.
69
+ One change — the hero CTA recoloured cyan → amber — appears as a single section in the report: a side-by-side before/after cropped screenshot, a one-line summary, then the exact property change folded under a toggle.
25
70
 
26
71
  ![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)
27
72
 
28
- As it renders in the PR comment (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):
73
+ 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:
29
74
 
30
75
  ```text
31
76
  ### `a.btn-solid` · 1 element restyled
@@ -67,11 +112,7 @@ defineStyleMapCapture({
67
112
  surfaces: [
68
113
  {
69
114
  key: 'landing',
70
- go: async (page) => {
71
- await page.goto('/');
72
- await page.waitForLoadState('networkidle');
73
- await page.evaluate(() => document.fonts.ready);
74
- },
115
+ go: (page) => page.goto('/'), // that's it — StyleProof settles the page (in-flight data, fonts, animations) before it reads
75
116
  widths: [1280, 768, 390], // one viewport per @media band
76
117
  },
77
118
  ],
@@ -141,12 +182,31 @@ Copy both `capture` and `report` files to `.github/workflows/` (the `report` one
141
182
 
142
183
  - **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>` (see the CI step above); tune the data boundary with `STYLEPROOF_REPLAY_URL` / `replayUrl` if your API isn't under `/api`.
143
184
  - **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.
144
- - **Self-check** (`STYLEPROOF_SELFCHECK=1`). Captures each surface twice and fails if they differ a replay gap or unseeded randomness surfaces as a clear _"non-deterministic capture"_ error, never as a phantom change on an unrelated PR.
185
+ - **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.
145
186
  - **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.
146
187
 
147
188
  > 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.
148
189
 
149
- **Live pages just work.** Before each capture, StyleProof settles the page it waits until the computed-style map stops changing, so async content (a fetch, an SSE/WebSocket stream backfilling a grid) is captured loaded, not mid-load. Anything still moving on its own after that is detected as a live region and excluded from the diff, so a stream or ticker never reads as a change — no manual `ignore` needed. Disable or tune with `captureStyleMap(page, { stabilize: false })` / `{ stabilize: { quietFor, timeout } }`.
190
+ **Live pages just work.** 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. Anything still moving on its own after that is detected as a live region and excluded from the diff, so a stream or ticker never reads as a change — no manual `ignore` needed. `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 } }`.
191
+
192
+ **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:
193
+
194
+ | Handled for you — zero config | How |
195
+ | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
196
+ | In-flight data, fonts, late layout | network-aware settle holds until requests finish _and_ the computed styles stop changing |
197
+ | Animations, transitions, focus ring, caret | frozen / blurred before the map is read |
198
+ | Clock-derived styling (`stale > 1h → red`) | `Date.now()` / `new Date()` frozen to a fixed instant |
199
+ | Framework & non-visual noise (`<script>`, route announcers) | skipped by default |
200
+ | Live / volatile regions (tickers, third-party embeds) | auto-detected as still-moving and excluded from the diff |
201
+ | Non-deterministic capture (replay gap, unseeded randomness) | self-check flags it _while recording_, with a named error |
202
+
203
+ | You set this — only because it's app-specific | Why it exists |
204
+ | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
205
+ | `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. |
206
+ | `replayUrl` / `STYLEPROOF_REPLAY_URL` | Your data endpoints aren't under `**/api/**`. |
207
+ | `ignore: ['.selector']` | You want a region gone **explicitly** — auto-exclude already handles most live regions, but a known-noisy element reads clearer named. |
208
+ | `clockTime` | Your styling keys off a **specific** date, not just "now". |
209
+ | `stabilize: { quietFor, timeout }` | An unusually slow surface needs a longer quiet window before the map is read. |
150
210
 
151
211
  ## Optional: content layer (advisory)
152
212
 
@@ -168,6 +228,23 @@ The report then carries a separate **📝 Content changes (advisory)** section:
168
228
 
169
229
  Notes: only an element's _own_ text is recorded (so a parent and child never double-report the same string); text churn in a live region is auto-excluded by the same settle pass that guards styles; and the certification CLI (`styleproof-diff`) is deliberately left content-blind.
170
230
 
231
+ ## Optional: React component layer (advisory)
232
+
233
+ For a React app, knowing _which component_ rendered an element is often the fastest way to read a change. Off by default, opt in with `captureComponent`:
234
+
235
+ ```ts
236
+ // styleproof.spec.ts — record the React component + props behind each element
237
+ defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR, captureComponent: true });
238
+ ```
239
+
240
+ Capture reads the React fiber in-page (`__reactFiber$*`/`__reactProps$*` on React 17+, `__reactInternalInstance$*` on ≤16) and records the component display name plus a **sanitized** subset of its props (primitives only — `children`, handlers, and objects are dropped) on `ElementEntry.component`. The report then names the element — **`React component: Button (variant=primary, size=sm)`** — instead of showing a bare `<button>`.
241
+
242
+ Like the content layer it is **advisory**: never fed to the certification diff or the gate, so captures stay deterministic. Component names are mangled in minified production builds, so it's most useful against a dev / non-minified target; on a non-React page the fiber keys are absent and the field is simply omitted.
243
+
244
+ ## Newly-added elements show their full style
245
+
246
+ 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.
247
+
171
248
  ## Reference
172
249
 
173
250
  **Action `BenSheridanEdwards/StyleProof@v2`** — key inputs:
@@ -200,17 +277,19 @@ It's **asynchronous by design**: approval is a checkbox tick handled by a separa
200
277
 
201
278
  **Capture spec `defineStyleMapCapture({ surfaces, … })`** — determinism is on by default; you rarely set more than `surfaces` and `dir`:
202
279
 
203
- | Option | Default | Purpose |
204
- | ------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------- |
205
- | `surfaces` | _required_ | Page states to certify — each `{ key, go, widths, ignore?, height? }`. `go(page)` drives to a settled state. |
206
- | `dir` | `STYLEMAP_DIR` | Output label (`base`/`head`); the spec is **inert until set**, so it sits safely beside your other specs. |
207
- | `replayFrom` | `STYLEPROOF_REPLAY_FROM` | Baseline dir whose recorded responses to replay. Unset this run **records** its HAR for the comparison to use. |
208
- | `replayUrl` | `**/api/**` (`…REPLAY_URL`) | URL glob for the data boundary to record/replay; everything else (JS/CSS/fonts) loads live so the code runs. |
209
- | `freezeClock` | `true` | Pin `Date.now()`/`new Date()` so time-derived styling can't drift; timers keep running so settling still works. |
210
- | `clockTime` | `2025-01-01T00:00:00Z` | The frozen instant. |
211
- | `selfCheck` | `STYLEPROOF_SELFCHECK=1` | Capture each surface twice and fail on any difference proves the capture is deterministic. |
212
- | `screenshots` | `true` | Save full-page screenshots for the report's before/after crops. |
213
- | `baseDir` | `__stylemaps__` | Output root directory. |
280
+ | Option | Default | Purpose |
281
+ | ------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
282
+ | `surfaces` | _required_ | Page states to certify — each `{ key, go, widths, ignore?, height? }`. `go(page)` drives to a settled state. |
283
+ | `expected` | _none_ | Your route/view universe. Emits a coverage-guard test (runs without a capture dir) that fails when a route has no surface and isn't excluded — so a new page can't ship uncaptured. |
284
+ | `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. |
285
+ | `dir` | `STYLEMAP_DIR` | Output label (`base`/`head`); the spec is **inert until set**, so it sits safely beside your other specs. |
286
+ | `replayFrom` | `STYLEPROOF_REPLAY_FROM` | Baseline dir whose recorded responses to replay. Unset this run **records** its HAR for the comparison to use. |
287
+ | `replayUrl` | `**/api/**` (`…REPLAY_URL`) | URL glob for the data boundary to record/replay; everything else (JS/CSS/fonts) loads live so the code runs. |
288
+ | `freezeClock` | `true` | Pin `Date.now()`/`new Date()` so time-derived styling can't drift; timers keep running so settling still works. |
289
+ | `clockTime` | `2025-01-01T00:00:00Z` | The frozen instant. |
290
+ | `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. |
291
+ | `screenshots` | `true` | Save full-page screenshots for the report's before/after crops. |
292
+ | `baseDir` | `__stylemaps__` | Output root directory. |
214
293
 
215
294
  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.
216
295
 
@@ -225,8 +304,8 @@ Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<styl
225
304
 
226
305
  **CLIs** (every flag accepts `--flag value` and `--flag=value`; `--help` lists all):
227
306
 
228
- - `styleproof-init` — scaffold the capture spec (and a starter `playwright.config.ts` if none exists).
229
- - `styleproof-diff <beforeDir> <afterDir>` — the certify gate; exits `1` on any difference.
307
+ - `styleproof-init` — scaffold the capture spec (and, if none exists, a starter `playwright.config.ts` whose `webServer` **builds and serves a production build**, so captures never run against a flaky dev server).
308
+ - `styleproof-diff <beforeDir> <afterDir>` — the certify gate; exits `0` certified (identical), `1` on a diff, `2` on a usage/capture error, `3` when only new surfaces are present (no baseline to diff against).
230
309
  - `styleproof-report <beforeDir> <afterDir> --out <dir>` — render the diff to a Markdown report with before/after crops. Add `--include-content` for the opt-in, advisory content section (see above).
231
310
 
232
311
  A programmatic API (`captureStyleMap`, `diffStyleMaps`, `generateStyleMapReport`, …) is also exported. For the capture internals, the approve-workflow trust model, and how to contribute, see [CONTRIBUTING](https://github.com/BenSheridanEdwards/StyleProof/blob/main/CONTRIBUTING.md) and the [`example/`](https://github.com/BenSheridanEdwards/StyleProof/tree/main/example) workflows.
@@ -6,8 +6,11 @@
6
6
  *
7
7
  * Writes:
8
8
  * - <dir> (default e2e/styleproof.spec.ts): a starter capture spec with a
9
- * robust settle() helper (waits fonts + neutralises scroll-reveal and CSS
10
- * animation/transition) and one sample Surface.
9
+ * minimal settle() helper (triggers scroll-reveal content; StyleProof itself
10
+ * handles fonts, animation freeze, and the settle). For a detected Next.js app it derives surfaces AND
11
+ * the `expected` coverage guard from the app's routes at run time, so a page
12
+ * added later can't ship without a surface; otherwise it writes one sample
13
+ * surface plus a commented guard block to wire to your own route registry.
11
14
  * - playwright.config.ts: only if one does not already exist, so an existing
12
15
  * Playwright project is never disturbed.
13
16
  *
@@ -17,6 +20,7 @@
17
20
  */
18
21
  import fs from 'node:fs';
19
22
  import path from 'node:path';
23
+ import { discoverNextRoutes } from '../dist/index.js';
20
24
 
21
25
  const HELP = `styleproof-init — scaffold a styleproof capture spec
22
26
 
@@ -30,13 +34,14 @@ options:
30
34
  -h, --help show this help
31
35
 
32
36
  What it writes:
33
- - the spec at --dir, with a settle() helper (fonts + reveal/animation freeze)
34
- and one sample Surface sweeping widths [1280, 768, 390]
37
+ - the spec at --dir, with a minimal settle() helper (scroll-reveal only).
38
+ In a Next.js app it discovers your routes at run time and wires both the
39
+ surfaces and the \`expected\` coverage guard to them, so a new page can't ship
40
+ uncaptured. Otherwise it writes one sample surface + a commented guard block.
35
41
  - playwright.config.ts, only if absent (an existing one is left untouched)
36
42
 
37
43
  After running, capture a baseline against a PRODUCTION build:
38
44
  STYLEMAP_DIR=baseline npx playwright test styleproof
39
- git add e2e/__stylemaps__/baseline && git commit -m "chore: stylemap baseline"
40
45
 
41
46
  To certify a refactor:
42
47
  STYLEMAP_DIR=before npx playwright test styleproof
@@ -70,10 +75,34 @@ if (!specPath) {
70
75
  process.exit(2);
71
76
  }
72
77
 
73
- const SPEC = `import type { Page } from '@playwright/test';
74
- import { defineStyleMapCapture, type Surface } from 'styleproof';
78
+ // Captures read whatever is in front of them, so the page must be settled and
79
+ // deterministic first this helper is shared by both spec variants below.
80
+ const SETTLE = `// StyleProof settles the page for you before it reads — it waits out in-flight data
81
+ // and fonts, freezes animations/transitions, and blurs focus. The one thing it can't
82
+ // know about is *scroll-reveal* content: elements an IntersectionObserver mounts (or
83
+ // fades in) only once they're scrolled into view. settle() triggers that — it scrolls
84
+ // the page so those reveals fire, forces common reveal markers to their final state so
85
+ // nothing is caught mid-fade, then returns to the top. Tune the selectors to match
86
+ // your project. No reveal-on-scroll content? Delete settle() and use the one-liner
87
+ // \`go: (page) => page.goto('/')\`.
88
+ async function settle(page: Page) {
89
+ await page.addStyleTag({
90
+ content: \`.reveal, [data-reveal], .fade-in, .animate-in {
91
+ opacity: 1 !important;
92
+ transform: none !important;
93
+ visibility: visible !important;
94
+ }\`,
95
+ });
96
+ await page.evaluate(async () => {
97
+ for (let y = 0; y < document.body.scrollHeight; y += window.innerHeight) {
98
+ window.scrollTo(0, y);
99
+ await new Promise((r) => setTimeout(r, 60));
100
+ }
101
+ window.scrollTo(0, 0);
102
+ });
103
+ }`;
75
104
 
76
- /**
105
+ const HEADER = `/**
77
106
  * styleproof capture spec (generated by \`styleproof-init\`).
78
107
  *
79
108
  * Each surface is one deterministic page state; widths sweep one viewport per
@@ -84,46 +113,63 @@ import { defineStyleMapCapture, type Surface } from 'styleproof';
84
113
  * ...refactor your CSS...
85
114
  * STYLEMAP_DIR=after npx playwright test styleproof # capture again
86
115
  * npx styleproof-diff __stylemaps__/before __stylemaps__/after
87
- */
116
+ */`;
88
117
 
89
- // Captures read whatever is in front of them, so the page must be settled and
90
- // deterministic first. settle() (1) waits for web fonts, (2) freezes CSS
91
- // animations/transitions and forces common scroll-reveal markers to their final
92
- // state so nothing is mid-fade, then (3) scrolls the page to trigger any
93
- // IntersectionObserver-driven reveals and returns to the top. Tune the reveal
94
- // selectors below to match your project.
95
- async function settle(page: Page) {
96
- await page.addStyleTag({
97
- content: \`
98
- *, *::before, *::after {
99
- animation: none !important;
100
- transition: none !important;
101
- animation-duration: 0s !important;
102
- transition-duration: 0s !important;
103
- }
104
- .reveal, [data-reveal], .fade-in, .animate-in {
105
- opacity: 1 !important;
106
- transform: none !important;
107
- visibility: visible !important;
108
- }
109
- \`,
110
- });
111
- await page.evaluate(async () => {
112
- await document.fonts.ready;
113
- for (let y = 0; y < document.body.scrollHeight; y += window.innerHeight) {
114
- window.scrollTo(0, y);
115
- await new Promise((r) => setTimeout(r, 60));
116
- }
117
- window.scrollTo(0, 0);
118
- });
119
- await page.waitForTimeout(300);
120
- }
118
+ // Next.js detected: derive both surfaces and the coverage guard from the app's
119
+ // routes AT RUN TIME, so a page added later is in `expected` automatically and
120
+ // fails the guard until it has a surface — no static list to drift.
121
+ const NEXT_SPEC = `import type { Page } from '@playwright/test';
122
+ import { defineStyleMapCapture, discoverNextRoutes, type Surface } from 'styleproof';
123
+
124
+ ${HEADER}
125
+
126
+ ${SETTLE}
127
+
128
+ // Routes discovered from your Next.js app (app/ + pages/) at RUN TIME — so a page
129
+ // you add later is covered automatically, with no surface list to keep in sync
130
+ // (that drift is exactly what lets a new page ship unverified). Edit freely; this
131
+ // is your spec. Static routes each get a capture; dynamic [param] routes can't be
132
+ // navigated without a value, so they're listed in \`exclude\` until you add a
133
+ // surface with a concrete param.
134
+ const ROUTES = discoverNextRoutes();
135
+
136
+ const SURFACES: Surface[] = ROUTES.filter((r) => !r.dynamic).map((r) => ({
137
+ key: r.key,
138
+ go: async (page) => {
139
+ await page.goto(r.path);
140
+ await settle(page);
141
+ },
142
+ ignore: [], // e.g. ['.live-feed', '.ad-slot'] for nondeterministic regions
143
+ widths: [1280, 768, 390],
144
+ }));
145
+
146
+ defineStyleMapCapture({
147
+ surfaces: SURFACES,
148
+ // Coverage guard: every known route must be a captured surface or excluded, or
149
+ // the suite fails (it runs without STYLEMAP_DIR — a static check, no browser).
150
+ // A new page with no surface can't slip through the gate unseen.
151
+ expected: ROUTES.map((r) => r.key),
152
+ exclude: Object.fromEntries(
153
+ ROUTES.filter((r) => r.dynamic).map((r) => [r.key, \`dynamic route (\${r.path}) — add a surface with a concrete param\`]),
154
+ ),
155
+ dir: process.env.STYLEMAP_DIR,
156
+ });
157
+ `;
158
+
159
+ // Non-Next project: one sample surface + a commented guard block to wire to
160
+ // whatever the project uses as a route/view registry.
161
+ const GENERIC_SPEC = `import type { Page } from '@playwright/test';
162
+ import { defineStyleMapCapture, type Surface } from 'styleproof';
163
+
164
+ ${HEADER}
165
+
166
+ ${SETTLE}
121
167
 
122
168
  const SURFACES: Surface[] = [
123
169
  {
124
170
  key: 'home',
125
171
  go: async (page) => {
126
- await page.goto('/', { waitUntil: 'networkidle' });
172
+ await page.goto('/');
127
173
  await settle(page);
128
174
  },
129
175
  ignore: [], // e.g. ['.live-feed', '.ad-slot'] for nondeterministic regions
@@ -133,18 +179,44 @@ const SURFACES: Surface[] = [
133
179
  // tabs, form errors — anything whose styling you want certified.
134
180
  ];
135
181
 
136
- defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
182
+ defineStyleMapCapture({
183
+ surfaces: SURFACES,
184
+ // Coverage guard (recommended): declare every route your app knows about so a
185
+ // newly added page can't ship without a surface. Wire \`expected\` to your route
186
+ // registry — a routes/views list, your router config, or a glob of your pages —
187
+ // and StyleProof fails the suite (no STYLEMAP_DIR needed) when a route has no
188
+ // surface and isn't excluded. (A Next.js app gets this auto-wired; see
189
+ // \`discoverNextRoutes\` in the README.)
190
+ // expected: ROUTES.map((r) => r.id),
191
+ // exclude: { checkout: 'auth-gated — fixture pending' },
192
+ dir: process.env.STYLEMAP_DIR,
193
+ });
137
194
  `;
138
195
 
139
196
  const CONFIG = `import { defineConfig, devices } from '@playwright/test';
140
197
 
141
- // Generated by styleproof-init. Point BASE_URL at a PRODUCTION build of the site
142
- // to capture — dev servers inject their own styles.
198
+ // Generated by styleproof-init.
199
+ //
200
+ // Capture against a PRODUCTION build, never a dev server. Dev servers (\`next dev\`,
201
+ // \`vite\`, …) JIT-compile each route on first request — slow and TIMING-VARIABLE
202
+ // under parallel CI load, so a capture can settle on the loading state on one run and
203
+ // the loaded state on the next: phantom diffs and self-check flakes. A built-and-served
204
+ // app serves precompiled routes at consistent timing. (StyleProof's settle waits for
205
+ // in-flight data either way, but a production build removes the variance at the source.)
143
206
  export default defineConfig({
144
207
  timeout: 120_000,
145
208
  use: {
146
209
  baseURL: process.env.BASE_URL || '${baseUrl}',
147
210
  },
211
+ // Build once, then serve THAT production build for the captures — so you can't
212
+ // accidentally capture a dev server. Adjust the command to your framework (e.g.
213
+ // \`vite build && vite preview\`); a server already running locally is reused.
214
+ webServer: {
215
+ command: 'npm run build && npm run start',
216
+ url: process.env.BASE_URL || '${baseUrl}',
217
+ reuseExistingServer: !process.env.CI,
218
+ timeout: 600_000, // a cold production build can take a few minutes
219
+ },
148
220
  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
149
221
  });
150
222
  `;
@@ -157,11 +229,26 @@ function writeFileSafe(file, contents, { force: f } = {}) {
157
229
  return { wrote: true, exists };
158
230
  }
159
231
 
232
+ // Choose the scaffold: routes-aware when this is a Next.js app with discoverable
233
+ // routes, else the generic one-surface starter.
234
+ const routes = discoverNextRoutes(process.cwd());
235
+ const isNext = routes.length > 0;
236
+ const SPEC = isNext ? NEXT_SPEC : GENERIC_SPEC;
237
+
160
238
  let wroteSomething = false;
161
239
 
162
240
  const spec = writeFileSafe(specPath, SPEC, { force });
163
241
  if (spec.wrote) {
164
242
  console.log(`${spec.exists ? 'overwrote' : 'created'} ${specPath}`);
243
+ if (isNext) {
244
+ const dynamic = routes.filter((r) => r.dynamic).length;
245
+ console.log(
246
+ ` detected ${routes.length} Next.js route(s) — wired surfaces + the \`expected\` coverage guard to them` +
247
+ (dynamic ? ` (${dynamic} dynamic route(s) excluded pending a concrete param)` : ''),
248
+ );
249
+ } else {
250
+ console.log(' no Next.js routes detected — wrote a starter surface + a commented coverage-guard block');
251
+ }
165
252
  wroteSomething = true;
166
253
  } else {
167
254
  console.log(`${specPath} already exists — left untouched (use --force to overwrite)`);
package/dist/capture.d.ts CHANGED
@@ -42,6 +42,18 @@ export type ElementEntry = {
42
42
  * advisory, not a computed-style outcome. See README "Optional: content layer".
43
43
  */
44
44
  text?: string;
45
+ /**
46
+ * The React component that rendered this element, and a sanitized subset of its
47
+ * props — present only when capture ran with `captureComponent: true` (opt-in,
48
+ * off by default). Extracted in-page from the React fiber. ADVISORY: never fed
49
+ * to the certification diff or its counts (like {@link ElementEntry.text}); it
50
+ * only enriches the report so a reviewer sees `Button (variant=primary)` rather
51
+ * than a bare `<button>`. Best in dev/non-minified builds (prod mangles names).
52
+ */
53
+ component?: {
54
+ name: string;
55
+ props?: Record<string, string>;
56
+ };
45
57
  };
46
58
  export type StyleMap = {
47
59
  defaults: Record<string, Props>;
@@ -92,16 +104,24 @@ export type CaptureOptions = {
92
104
  * ticker never reads as a change — no manual `ignore` needed. Text-only churn
93
105
  * (a clock, "2m ago") never matters: the diff compares computed style, not
94
106
  * text. Pass `false` to capture the exact frame `go()` left, or `{ interval,
95
- * quietFor, timeout }` (ms) to tune the poll cadence, the no-change window
96
- * that counts as settled, and the budget. Note: content that first paints
97
- * after a quiet gap longer than `quietFor` can't be waited for without a
98
- * signal settle that in `go()`; anything still moving at `timeout` is
99
- * treated as a live region.
107
+ * quietFor, timeout, waitForRequests }` to tune the poll cadence, the no-change
108
+ * window that counts as settled, the budget (ms), and the network signal.
109
+ *
110
+ * By default the settle is **network-aware** (`waitForRequests: true`): it won't
111
+ * settle on the brief DOM lull BEFORE a `fetch`/XHR response arrives — it holds
112
+ * while data requests are in flight (excluding long-lived `EventSource`/WebSocket
113
+ * streams, which never finish), so late-fetched content is captured loaded, not
114
+ * mid-load. This is the signal a DOM-quiet window alone lacks: without it, a slow
115
+ * backend (e.g. a dev server under CI load) settles on the loading state on one run
116
+ * and the loaded state on the next — a self-check flake. Anything still moving at
117
+ * `timeout` is treated as a live region. Set `waitForRequests: false` to settle on
118
+ * DOM quiet alone.
100
119
  */
101
120
  stabilize?: boolean | {
102
121
  interval?: number;
103
122
  quietFor?: number;
104
123
  timeout?: number;
124
+ waitForRequests?: boolean;
105
125
  };
106
126
  /**
107
127
  * Capture forced :hover/:focus/:active state deltas (default true). This is
@@ -129,10 +149,47 @@ export type CaptureOptions = {
129
149
  * guards styles, since text now participates in change detection.
130
150
  */
131
151
  captureText?: boolean;
152
+ /**
153
+ * Opt-in React layer (default OFF). When true, each element records the React
154
+ * component that rendered it (display name) and a sanitized subset of its props
155
+ * on {@link ElementEntry.component}, read in-page from the React fiber
156
+ * (`__reactFiber$*`/`__reactProps$*` on React 17+, `__reactInternalInstance$*`
157
+ * on ≤16). Lets the report name `Button (variant=primary)` instead of a bare
158
+ * `<button>`. ADVISORY — like `captureText` it never feeds the certification
159
+ * diff or its blocking counts. Only primitive props (string/number/boolean) are
160
+ * kept (children/handlers/objects dropped); names are mangled in minified prod
161
+ * builds, so this is most useful against dev/non-minified output. No-op on
162
+ * non-React pages (fiber keys absent → field omitted).
163
+ */
164
+ captureComponent?: boolean;
165
+ /**
166
+ * Advanced/internal: a getter for the count of in-flight data requests, supplied by
167
+ * a tracker ({@link trackInflightRequests}) armed BEFORE navigation so the page's own
168
+ * load fetches are counted by the network-aware settle. `defineStyleMapCapture`
169
+ * wires this automatically. Omit it for a direct `captureStyleMap` call and the
170
+ * settle arms its own tracker from capture time — which can't see a request already
171
+ * in flight when you called it (arm one yourself before `goto` if that matters).
172
+ */
173
+ pendingRequests?: () => number;
132
174
  };
133
175
  /** True if `path` is one of `roots` or a structural descendant of one. Shared by
134
176
  * the capture (excluding live regions) and the diff (skipping them). */
135
177
  export declare function isUnder(path: string, roots: string[]): boolean;
178
+ /**
179
+ * Track in-flight DATA requests on `page` so the network-aware settle can wait for
180
+ * late-loading content to ARRIVE, not just for the DOM to go briefly quiet (the lull
181
+ * before a response paints). Long-lived streams (EventSource/WebSocket) never finish,
182
+ * so they're excluded — their painted state is handled by the live-region pass.
183
+ *
184
+ * Attach BEFORE navigation to count the page's OWN load fetches: a request already in
185
+ * flight when you call `captureStyleMap` fired its `request` event before any listener
186
+ * attached there, so only a tracker armed earlier (the runner does this before `go()`)
187
+ * can see it.
188
+ */
189
+ export declare function trackInflightRequests(page: Page): {
190
+ pending: () => number;
191
+ dispose: () => void;
192
+ };
136
193
  /**
137
194
  * Capture the page's complete style map. Drive the page to the state you want
138
195
  * first (navigate, open menus); by default the capture then auto-settles the