styleproof 2.0.0 → 2.1.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,61 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.1.0] - 2026-06-23
11
+
12
+ ### Added
13
+
14
+ - **Coverage guard (`expected` / `exclude`).** `defineStyleMapCapture` now accepts
15
+ `expected` — the app's route/view universe — and emits a guard test that fails
16
+ when a route has no captured surface and isn't in `exclude`. It runs in the
17
+ normal test suite (no `STYLEMAP_DIR`, no browser — a static check), closing the
18
+ one hole captures can't catch on their own: a new page nobody added to
19
+ `surfaces` is invisible to the diff (no base capture, no head capture), so the
20
+ gate goes green having never looked at it. `exclude` is a `key → reason` ledger
21
+ of deliberate opt-outs; a key absent from `expected` (a renamed/removed route)
22
+ fails the guard too, so the ledger can't rot. Omit `expected` and behaviour is
23
+ unchanged. The pure `coverageGaps(captured, expected, exclude)` helper is also
24
+ exported. Closes the class of regression where a brand-new view's styles ship
25
+ uncaptured because the surface list silently drifted from the app's routes.
26
+ - **Coverage guard works out of the box for Next.js.** New `discoverNextRoutes()`
27
+ reads the App Router (`app/`) and Pages Router (`pages/`) at run time and returns
28
+ `{ key, path, dynamic }[]` (route groups/slots stripped, `[param]` flagged). `styleproof-init`
29
+ now detects a Next.js app and scaffolds a spec that wires both the surfaces and
30
+ `expected` to it — so a fresh install is protected without hand-wiring, and a page
31
+ added later is covered automatically. Non-Next projects get the previous starter
32
+ surface plus a commented guard block to point at their own route registry.
33
+ - **Network-aware settle (default on).** The settle now holds while the page's own
34
+ data requests are in flight — excluding long-lived `EventSource`/WebSocket streams
35
+ (handled by the live-region pass) — instead of only waiting for the computed-style
36
+ map to go quiet. So late-fetched content is captured **loaded, not mid-load**, and
37
+ the settle can't false-settle on a loading state before a slow backend responds —
38
+ the phantom-diff / self-check flake that a fixed wait produces under CI load. New
39
+ exported `trackInflightRequests(page)` arms the tracker; `defineStyleMapCapture`
40
+ arms it before each `go()` automatically.
41
+ Opt out with `stabilize.waitForRequests: false`.
42
+ - **`styleproof-init` scaffolds a production-build web server.** The generated
43
+ `playwright.config.ts` now includes a `webServer` that runs
44
+ `npm run build && npm run start` (reusing a server already up), so a fresh project
45
+ captures against a production build by default instead of a `next dev`-style server
46
+ whose JIT timing variance is the top source of capture flakes.
47
+
48
+ ### Changed
49
+
50
+ - **`selfCheck` defaults on while recording, off on replay.** The determinism guard
51
+ (capture twice, fail on drift) was opt-in (`STYLEPROOF_SELFCHECK=1`), so by default
52
+ nondeterminism shipped silently as a phantom diff. It now defaults **on when
53
+ recording** (no `replayFrom`), where live nondeterminism surfaces, and **off on
54
+ replay**, which is deterministic by construction — so a fresh
55
+ `defineStyleMapCapture({ surfaces })` auto-detects and names nondeterminism with no
56
+ 2× cost on the replay run. `STYLEPROOF_SELFCHECK=1` still forces it on for both;
57
+ `selfCheck: false` opts out.
58
+ - **`styleproof-init` scaffolds a minimal `settle()`.** Now that the engine's
59
+ network-aware settle waits out in-flight data and fonts, freezes animations, and
60
+ blurs focus, the generated helper drops the hand-rolled `document.fonts.ready`,
61
+ animation-freeze, fixed `waitForTimeout`, and `networkidle` wait (the last an
62
+ active trap — it never fires against an SSE stream). It keeps only what the engine
63
+ can't know about — scroll-reveal — and `go()` is a plain `page.goto(...)`.
64
+
10
65
  ## [2.0.0] - 2026-06-22
11
66
 
12
67
  ### 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
 
@@ -200,17 +260,19 @@ It's **asynchronous by design**: approval is a checkbox tick handled by a separa
200
260
 
201
261
  **Capture spec `defineStyleMapCapture({ surfaces, … })`** — determinism is on by default; you rarely set more than `surfaces` and `dir`:
202
262
 
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. |
263
+ | Option | Default | Purpose |
264
+ | ------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
265
+ | `surfaces` | _required_ | Page states to certify — each `{ key, go, widths, ignore?, height? }`. `go(page)` drives to a settled state. |
266
+ | `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. |
267
+ | `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. |
268
+ | `dir` | `STYLEMAP_DIR` | Output label (`base`/`head`); the spec is **inert until set**, so it sits safely beside your other specs. |
269
+ | `replayFrom` | `STYLEPROOF_REPLAY_FROM` | Baseline dir whose recorded responses to replay. Unset this run **records** its HAR for the comparison to use. |
270
+ | `replayUrl` | `**/api/**` (`…REPLAY_URL`) | URL glob for the data boundary to record/replay; everything else (JS/CSS/fonts) loads live so the code runs. |
271
+ | `freezeClock` | `true` | Pin `Date.now()`/`new Date()` so time-derived styling can't drift; timers keep running so settling still works. |
272
+ | `clockTime` | `2025-01-01T00:00:00Z` | The frozen instant. |
273
+ | `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. |
274
+ | `screenshots` | `true` | Save full-page screenshots for the report's before/after crops. |
275
+ | `baseDir` | `__stylemaps__` | Output root directory. |
214
276
 
215
277
  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
278
 
@@ -225,8 +287,8 @@ Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<styl
225
287
 
226
288
  **CLIs** (every flag accepts `--flag value` and `--flag=value`; `--help` lists all):
227
289
 
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.
290
+ - `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).
291
+ - `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
292
  - `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
293
 
232
294
  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
@@ -92,16 +92,24 @@ export type CaptureOptions = {
92
92
  * ticker never reads as a change — no manual `ignore` needed. Text-only churn
93
93
  * (a clock, "2m ago") never matters: the diff compares computed style, not
94
94
  * 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.
95
+ * quietFor, timeout, waitForRequests }` to tune the poll cadence, the no-change
96
+ * window that counts as settled, the budget (ms), and the network signal.
97
+ *
98
+ * By default the settle is **network-aware** (`waitForRequests: true`): it won't
99
+ * settle on the brief DOM lull BEFORE a `fetch`/XHR response arrives — it holds
100
+ * while data requests are in flight (excluding long-lived `EventSource`/WebSocket
101
+ * streams, which never finish), so late-fetched content is captured loaded, not
102
+ * mid-load. This is the signal a DOM-quiet window alone lacks: without it, a slow
103
+ * backend (e.g. a dev server under CI load) settles on the loading state on one run
104
+ * and the loaded state on the next — a self-check flake. Anything still moving at
105
+ * `timeout` is treated as a live region. Set `waitForRequests: false` to settle on
106
+ * DOM quiet alone.
100
107
  */
101
108
  stabilize?: boolean | {
102
109
  interval?: number;
103
110
  quietFor?: number;
104
111
  timeout?: number;
112
+ waitForRequests?: boolean;
105
113
  };
106
114
  /**
107
115
  * Capture forced :hover/:focus/:active state deltas (default true). This is
@@ -129,10 +137,34 @@ export type CaptureOptions = {
129
137
  * guards styles, since text now participates in change detection.
130
138
  */
131
139
  captureText?: boolean;
140
+ /**
141
+ * Advanced/internal: a getter for the count of in-flight data requests, supplied by
142
+ * a tracker ({@link trackInflightRequests}) armed BEFORE navigation so the page's own
143
+ * load fetches are counted by the network-aware settle. `defineStyleMapCapture`
144
+ * wires this automatically. Omit it for a direct `captureStyleMap` call and the
145
+ * settle arms its own tracker from capture time — which can't see a request already
146
+ * in flight when you called it (arm one yourself before `goto` if that matters).
147
+ */
148
+ pendingRequests?: () => number;
132
149
  };
133
150
  /** True if `path` is one of `roots` or a structural descendant of one. Shared by
134
151
  * the capture (excluding live regions) and the diff (skipping them). */
135
152
  export declare function isUnder(path: string, roots: string[]): boolean;
153
+ /**
154
+ * Track in-flight DATA requests on `page` so the network-aware settle can wait for
155
+ * late-loading content to ARRIVE, not just for the DOM to go briefly quiet (the lull
156
+ * before a response paints). Long-lived streams (EventSource/WebSocket) never finish,
157
+ * so they're excluded — their painted state is handled by the live-region pass.
158
+ *
159
+ * Attach BEFORE navigation to count the page's OWN load fetches: a request already in
160
+ * flight when you call `captureStyleMap` fired its `request` event before any listener
161
+ * attached there, so only a tracker armed earlier (the runner does this before `go()`)
162
+ * can see it.
163
+ */
164
+ export declare function trackInflightRequests(page: Page): {
165
+ pending: () => number;
166
+ dispose: () => void;
167
+ };
136
168
  /**
137
169
  * Capture the page's complete style map. Drive the page to the state you want
138
170
  * first (navigate, open menus); by default the capture then auto-settles the
package/dist/capture.js CHANGED
@@ -322,7 +322,7 @@ function changedElementPaths(a, b) {
322
322
  * Reuses `capturePage` (motion already frozen by the caller), so only
323
323
  * content/layout churn — not an animation frame — keeps it from settling.
324
324
  */
325
- async function stabilizePage(page, ignore, interval, quietFor, timeout, captureText) {
325
+ async function stabilizePage(page, ignore, interval, quietFor, timeout, captureText, pending) {
326
326
  // captureText is threaded in so text churn participates in settle detection:
327
327
  // a clock/ticker whose text changes (with no style change) keeps the map from
328
328
  // settling and is excluded as a live region, exactly as style churn already is.
@@ -340,8 +340,16 @@ async function stabilizePage(page, ignore, interval, quietFor, timeout, captureT
340
340
  lastChangeAt = Date.now();
341
341
  recent = changed;
342
342
  }
343
+ else if (pending() > 0) {
344
+ // The DOM is momentarily quiet, but data requests are still in flight — this
345
+ // is the lull BEFORE the response paints, not a settled state. Hold the quiet
346
+ // window so we wait for the content to ARRIVE (no live-region path to record;
347
+ // network activity isn't a mutating element). Long-lived streams are excluded
348
+ // by the caller, so this can't hang on an SSE that never finishes.
349
+ lastChangeAt = Date.now();
350
+ }
343
351
  else if (Date.now() - lastChangeAt >= quietFor) {
344
- return []; // unchanged for the full quiet window → settled
352
+ return []; // DOM unchanged AND network idle for the full quiet window → settled
345
353
  }
346
354
  }
347
355
  return recent; // never went quiet for quietFor within budget → still-moving paths are live
@@ -374,19 +382,75 @@ function capturePageTokens() {
374
382
  probe.remove();
375
383
  return tokens;
376
384
  }
385
+ /**
386
+ * Track in-flight DATA requests on `page` so the network-aware settle can wait for
387
+ * late-loading content to ARRIVE, not just for the DOM to go briefly quiet (the lull
388
+ * before a response paints). Long-lived streams (EventSource/WebSocket) never finish,
389
+ * so they're excluded — their painted state is handled by the live-region pass.
390
+ *
391
+ * Attach BEFORE navigation to count the page's OWN load fetches: a request already in
392
+ * flight when you call `captureStyleMap` fired its `request` event before any listener
393
+ * attached there, so only a tracker armed earlier (the runner does this before `go()`)
394
+ * can see it.
395
+ */
396
+ export function trackInflightRequests(page) {
397
+ const inflight = new Set();
398
+ const isStream = (r) => {
399
+ const t = r.resourceType();
400
+ return t === 'eventsource' || t === 'websocket';
401
+ };
402
+ const onStart = (r) => {
403
+ if (!isStream(r))
404
+ inflight.add(r);
405
+ };
406
+ const onEnd = (r) => {
407
+ inflight.delete(r);
408
+ };
409
+ page.on('request', onStart);
410
+ page.on('requestfinished', onEnd);
411
+ page.on('requestfailed', onEnd);
412
+ return {
413
+ pending: () => inflight.size,
414
+ dispose: () => {
415
+ page.off('request', onStart);
416
+ page.off('requestfinished', onEnd);
417
+ page.off('requestfailed', onEnd);
418
+ },
419
+ };
420
+ }
377
421
  /** Settle the page and return the paths of live regions to exclude. */
378
- async function detectVolatile(page, ignore, stabilize, captureText) {
422
+ async function detectVolatile(page, ignore, stabilize, captureText, externalPending) {
379
423
  if (stabilize === false)
380
424
  return [];
381
425
  const opt = typeof stabilize === 'object' ? stabilize : {};
382
- const volatile = await stabilizePage(page, ignore, opt.interval || 150, opt.quietFor || 600, opt.timeout || 5000, captureText);
383
- if (volatile.length) {
384
- // eslint-disable-next-line no-console
385
- console.warn(`styleproof: ${volatile.length} live region(s) kept changing on their own and were excluded from ` +
386
- 'this capture (nondeterministic — a stream, ticker, or late-loading content). The diff skips them so ' +
387
- 'they never read as a change. If a real change is being hidden, settle the page in go() or raise stabilize.timeout.');
426
+ const waitForRequests = opt.waitForRequests ?? true;
427
+ // Prefer the runner's pre-navigation tracker (it counts the page's load fetches);
428
+ // otherwise arm one here as a fallback for requests that fire after this call.
429
+ let pending = () => 0;
430
+ let dispose = () => { };
431
+ if (waitForRequests) {
432
+ if (externalPending) {
433
+ pending = externalPending;
434
+ }
435
+ else {
436
+ const t = trackInflightRequests(page);
437
+ pending = t.pending;
438
+ dispose = t.dispose;
439
+ }
440
+ }
441
+ try {
442
+ const volatile = await stabilizePage(page, ignore, opt.interval || 150, opt.quietFor || 600, opt.timeout || 5000, captureText, pending);
443
+ if (volatile.length) {
444
+ // eslint-disable-next-line no-console
445
+ console.warn(`styleproof: ${volatile.length} live region(s) kept changing on their own and were excluded from ` +
446
+ 'this capture (nondeterministic — a stream, ticker, or late-loading content). The diff skips them so ' +
447
+ 'they never read as a change. If a real change is being hidden, settle the page in go() or raise stabilize.timeout.');
448
+ }
449
+ return volatile;
450
+ }
451
+ finally {
452
+ dispose();
388
453
  }
389
- return volatile;
390
454
  }
391
455
  /** Drop live regions (and their subtrees) from the base capture, in Node so the
392
456
  * serialized capturePage stays a pure snapshot. */
@@ -451,7 +515,7 @@ export async function captureStyleMap(page, options = {}) {
451
515
  // the same loaded state, and collect any region still changing on its own
452
516
  // (a live stream/ticker) to exclude — animations are frozen above, so only
453
517
  // real content/layout churn lands here.
454
- const volatile = await detectVolatile(page, ignore, stabilize, captureText);
518
+ const volatile = await detectVolatile(page, ignore, stabilize, captureText, options.pendingRequests);
455
519
  const base = await page.evaluate(capturePage, { ignore, motionOnly: false, captureText });
456
520
  dropVolatile(base.elements, volatile);
457
521
  warnUntraversed(base.shadowHosts, base.sameOriginFrames);
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Coverage guard for the surface list.
3
+ *
4
+ * StyleProof captures exactly the surfaces a spec declares, and the diff matches
5
+ * surfaces by key — so a route nobody added to `surfaces` is invisible to the
6
+ * gate: the change it introduces has no baseline capture AND no head capture, so
7
+ * it never appears in any diff. The gate goes green having never looked at it.
8
+ * This is the one failure StyleProof can't catch from the captures alone, because
9
+ * it's about a capture that was never taken.
10
+ *
11
+ * `expected` closes the hole: a spec declares its full route/surface universe
12
+ * (e.g. an app's view registry), and the guard fails when that universe drifts
13
+ * from what's actually captured — turning a silent coverage hole into a red test,
14
+ * in the app's own suite, the moment the route is added.
15
+ */
16
+ export type CoverageGaps = {
17
+ /** Expected surfaces that are neither captured nor explicitly excluded. */
18
+ uncovered: string[];
19
+ /** `exclude` entries absent from `expected` — a renamed or removed route whose
20
+ * opt-out has gone stale (the same drift, in reverse). */
21
+ staleExclusions: string[];
22
+ };
23
+ /**
24
+ * Compare the captured surface keys against a declared `expected` universe.
25
+ *
26
+ * A surface is covered if it's captured OR listed in `exclude` (a deliberate,
27
+ * documented opt-out — `key → reason`). Captured surfaces NOT in `expected` are
28
+ * allowed: one route legitimately has several captured states (`landing`,
29
+ * `landing-nav-open`), and only the routes themselves form the universe.
30
+ *
31
+ * Pure and side-effect-free so it's unit-testable; `defineStyleMapCapture` wraps
32
+ * it in a Playwright test that runs in the normal suite (not gated on a capture
33
+ * dir), and it's exported so a consumer can assert coverage however it likes.
34
+ */
35
+ export declare function coverageGaps(capturedKeys: Iterable<string>, expected: Iterable<string>, exclude?: Record<string, string>): CoverageGaps;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Coverage guard for the surface list.
3
+ *
4
+ * StyleProof captures exactly the surfaces a spec declares, and the diff matches
5
+ * surfaces by key — so a route nobody added to `surfaces` is invisible to the
6
+ * gate: the change it introduces has no baseline capture AND no head capture, so
7
+ * it never appears in any diff. The gate goes green having never looked at it.
8
+ * This is the one failure StyleProof can't catch from the captures alone, because
9
+ * it's about a capture that was never taken.
10
+ *
11
+ * `expected` closes the hole: a spec declares its full route/surface universe
12
+ * (e.g. an app's view registry), and the guard fails when that universe drifts
13
+ * from what's actually captured — turning a silent coverage hole into a red test,
14
+ * in the app's own suite, the moment the route is added.
15
+ */
16
+ /**
17
+ * Compare the captured surface keys against a declared `expected` universe.
18
+ *
19
+ * A surface is covered if it's captured OR listed in `exclude` (a deliberate,
20
+ * documented opt-out — `key → reason`). Captured surfaces NOT in `expected` are
21
+ * allowed: one route legitimately has several captured states (`landing`,
22
+ * `landing-nav-open`), and only the routes themselves form the universe.
23
+ *
24
+ * Pure and side-effect-free so it's unit-testable; `defineStyleMapCapture` wraps
25
+ * it in a Playwright test that runs in the normal suite (not gated on a capture
26
+ * dir), and it's exported so a consumer can assert coverage however it likes.
27
+ */
28
+ export function coverageGaps(capturedKeys, expected, exclude = {}) {
29
+ const captured = new Set(capturedKeys);
30
+ const expectedList = [...expected];
31
+ const expectedSet = new Set(expectedList);
32
+ const uncovered = expectedList.filter((k) => !captured.has(k) && !(k in exclude));
33
+ const staleExclusions = Object.keys(exclude).filter((k) => !expectedSet.has(k));
34
+ return { uncovered, staleExclusions };
35
+ }
package/dist/index.d.ts CHANGED
@@ -1,7 +1,11 @@
1
- export { captureStyleMap, saveStyleMap, loadStyleMap } from './capture.js';
1
+ export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
2
2
  export type { StyleMap, CaptureOptions, ElementEntry, Rect } from './capture.js';
3
3
  export { defineStyleMapCapture } from './runner.js';
4
4
  export type { Surface, DefineOptions } from './runner.js';
5
+ export { coverageGaps } from './coverage.js';
6
+ export type { CoverageGaps } from './coverage.js';
7
+ export { discoverNextRoutes } from './routes.js';
8
+ export type { DiscoveredRoute } from './routes.js';
5
9
  export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, findingLabel } from './diff.js';
6
10
  export type { Finding, PropChange, SurfaceDiff, DiffCounts, ContentChange } from './diff.js';
7
11
  export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
package/dist/index.js CHANGED
@@ -1,4 +1,6 @@
1
- export { captureStyleMap, saveStyleMap, loadStyleMap } from './capture.js';
1
+ export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
2
2
  export { defineStyleMapCapture } from './runner.js';
3
+ export { coverageGaps } from './coverage.js';
4
+ export { discoverNextRoutes } from './routes.js';
3
5
  export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, findingLabel } from './diff.js';
4
6
  export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Best-effort route discovery for Next.js projects, so the coverage guard works
3
+ * out of the box: a generated spec calls this at run time, so `expected` reflects
4
+ * the app's *current* routes — a newly added page appears automatically, and the
5
+ * guard fails until it has a surface. No static list to keep in sync (that drift
6
+ * is the whole bug the guard exists to prevent).
7
+ *
8
+ * Covers the App Router (`app/`, `src/app/` — directories with a `page.*`) and the
9
+ * Pages Router (`pages/`, `src/pages/` — page files, minus `_app`/`_document`/`api`).
10
+ * Route groups `(group)` and parallel slots `@slot` are stripped; `[param]` /
11
+ * `[...catchall]` segments mark a route dynamic. It reads the filesystem only —
12
+ * no framework internals — so it's a heuristic, not a router; edit the generated
13
+ * spec if your routing does something exotic.
14
+ */
15
+ export type DiscoveredRoute = {
16
+ /** Filename-safe surface key (`/` → `index`, `/blog/[slug]` → `blog-slug`). */
17
+ key: string;
18
+ /** URL path to navigate (`/`, `/about`, `/blog/[slug]`). */
19
+ path: string;
20
+ /** True when a segment is a `[param]` / `[...catchall]` — can't be navigated as-is. */
21
+ dynamic: boolean;
22
+ };
23
+ /**
24
+ * Discover a Next.js project's routes under `cwd` (default `process.cwd()`).
25
+ * Returns one entry per route, deduped by path (App Router wins a tie) and sorted.
26
+ * Empty array when no `app/` or `pages/` dir is found — the caller decides whether
27
+ * that means "not a Next project".
28
+ */
29
+ export declare function discoverNextRoutes(cwd?: string): DiscoveredRoute[];
package/dist/routes.js ADDED
@@ -0,0 +1,84 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ const APP_PAGE_RE = /^page\.(?:js|jsx|ts|tsx|mdx)$/;
4
+ const PAGES_EXT_RE = /\.(?:js|jsx|ts|tsx)$/;
5
+ const isGroupOrSlot = (seg) => /^\(.*\)$/.test(seg) || seg.startsWith('@');
6
+ const isDynamicSeg = (seg) => /\[.*\]/.test(seg);
7
+ /** Filename-safe, readable key from a route path. */
8
+ function toKey(routePath) {
9
+ if (routePath === '/')
10
+ return 'index';
11
+ const k = routePath
12
+ .replace(/^\//, '')
13
+ .replace(/\[\[?\.\.\.([^\]]+)\]\]?/g, 'all-$1') // [...x] / [[...x]] → all-x
14
+ .replace(/\[([^\]]+)\]/g, '$1') // [x] → x
15
+ .replace(/[^a-zA-Z0-9]+/g, '-')
16
+ .replace(/^-+|-+$/g, '')
17
+ .toLowerCase();
18
+ return k || 'index';
19
+ }
20
+ function makeRoute(segs) {
21
+ const routePath = segs.length ? '/' + segs.join('/') : '/';
22
+ return { key: toKey(routePath), path: routePath, dynamic: segs.some(isDynamicSeg) };
23
+ }
24
+ function readDir(dir) {
25
+ try {
26
+ return fs.readdirSync(dir, { withFileTypes: true });
27
+ }
28
+ catch {
29
+ return []; // missing/unreadable dir → no routes, never throw into a spec
30
+ }
31
+ }
32
+ /** App Router: a directory holding a `page.*` is a route; nest by sub-directory. */
33
+ function appRoutes(appDir) {
34
+ const out = [];
35
+ const walk = (dir, segs) => {
36
+ const entries = readDir(dir);
37
+ if (entries.some((e) => e.isFile() && APP_PAGE_RE.test(e.name))) {
38
+ out.push(makeRoute(segs.filter((s) => !isGroupOrSlot(s))));
39
+ }
40
+ for (const e of entries)
41
+ if (e.isDirectory())
42
+ walk(path.join(dir, e.name), [...segs, e.name]);
43
+ };
44
+ walk(appDir, []);
45
+ return out;
46
+ }
47
+ /** Pages Router: each page file is a route; `index` collapses to its directory. */
48
+ function pagesRoutes(pagesDir) {
49
+ const out = [];
50
+ const walk = (dir, segs) => {
51
+ for (const e of readDir(dir)) {
52
+ if (e.isDirectory()) {
53
+ if (segs.length === 0 && e.name === 'api')
54
+ continue; // API routes aren't pages
55
+ walk(path.join(dir, e.name), [...segs, e.name]);
56
+ }
57
+ else if (e.isFile() && PAGES_EXT_RE.test(e.name)) {
58
+ const base = e.name.replace(PAGES_EXT_RE, '');
59
+ if (base.startsWith('_'))
60
+ continue; // _app, _document, _error
61
+ out.push(makeRoute(base === 'index' ? segs : [...segs, base]));
62
+ }
63
+ }
64
+ };
65
+ walk(pagesDir, []);
66
+ return out;
67
+ }
68
+ const firstExisting = (cwd, candidates) => candidates.map((d) => path.join(cwd, d)).find((p) => fs.existsSync(p));
69
+ /**
70
+ * Discover a Next.js project's routes under `cwd` (default `process.cwd()`).
71
+ * Returns one entry per route, deduped by path (App Router wins a tie) and sorted.
72
+ * Empty array when no `app/` or `pages/` dir is found — the caller decides whether
73
+ * that means "not a Next project".
74
+ */
75
+ export function discoverNextRoutes(cwd = process.cwd()) {
76
+ const appDir = firstExisting(cwd, ['app', 'src/app']);
77
+ const pagesDir = firstExisting(cwd, ['pages', 'src/pages']);
78
+ const found = [...(appDir ? appRoutes(appDir) : []), ...(pagesDir ? pagesRoutes(pagesDir) : [])];
79
+ const byPath = new Map();
80
+ for (const r of found)
81
+ if (!byPath.has(r.path))
82
+ byPath.set(r.path, r);
83
+ return [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path));
84
+ }
package/dist/runner.d.ts CHANGED
@@ -7,7 +7,11 @@ import type { Page } from '@playwright/test';
7
7
  export type Surface = {
8
8
  /** Capture file name prefix; must be unique. */
9
9
  key: string;
10
- /** Navigate and drive the page to the state, ending settled (fonts loaded, entrance animations done). */
10
+ /**
11
+ * Navigate and drive the page to the state. Only reach the state — StyleProof
12
+ * settles it for you (waits out in-flight data and fonts, freezes animations)
13
+ * before reading, so you don't hand-roll `networkidle`/`fonts.ready` waits here.
14
+ */
11
15
  go: (page: Page) => Promise<void>;
12
16
  /** Selectors for nondeterministic regions (live data, third-party embeds); skipped entirely. */
13
17
  ignore?: string[];
@@ -18,6 +22,23 @@ export type Surface = {
18
22
  };
19
23
  export type DefineOptions = {
20
24
  surfaces: Surface[];
25
+ /**
26
+ * The full set of surface keys the app knows it has — its route/view universe,
27
+ * typically derived from a registry (e.g. an app's list of routes or view ids).
28
+ * When set, StyleProof emits a coverage-guard test (in the NORMAL suite, not
29
+ * gated on a capture dir) that fails if any expected key is neither captured (a
30
+ * surface) nor in `exclude`. This is what stops a newly added route from
31
+ * shipping uncaptured: the gate can only diff what a spec lists, so without this
32
+ * a forgotten surface is silently invisible. Omit to opt out (no guard).
33
+ */
34
+ expected?: string[];
35
+ /**
36
+ * Expected keys deliberately NOT captured, each mapped to the reason — a visible,
37
+ * reviewed opt-out ledger. Keeps the coverage guard green for known gaps without
38
+ * letting them hide: an entry whose key isn't in `expected` (a renamed/removed
39
+ * route) also fails the guard, so the ledger can't rot.
40
+ */
41
+ exclude?: Record<string, string>;
21
42
  /**
22
43
  * Output directory label. Convention: drive it from an env var so the same
23
44
  * spec captures `before`, `after`, or a CI label — and skips entirely when
@@ -58,10 +79,16 @@ export type DefineOptions = {
58
79
  /** Fixed instant for the frozen clock (default `2025-01-01T00:00:00Z`). */
59
80
  clockTime?: string | number | Date;
60
81
  /**
61
- * Capture each surface twice and fail if the computed styles differ — proves
62
- * the capture is deterministic (catches a replay gap falling through to the
63
- * live backend, or unseeded client randomness) instead of letting it surface
64
- * as a phantom change on an unrelated diff. Default from STYLEPROOF_SELFCHECK=1.
82
+ * Capture each surface twice and fail if the computed styles differ — proves the
83
+ * capture is deterministic (catches a replay gap falling through to the live
84
+ * backend, or unseeded client randomness) instead of letting it surface as a
85
+ * phantom change on an unrelated diff.
86
+ *
87
+ * Defaults ON for the RECORDING run and OFF for the REPLAY run: live nondeterminism
88
+ * surfaces while recording against the real backend, whereas the replay run renders
89
+ * against the recorded HAR and is deterministic by construction — so self-checking it
90
+ * just doubles the work. `STYLEPROOF_SELFCHECK=1` forces it on for both; pass
91
+ * `selfCheck` explicitly to override.
65
92
  */
66
93
  selfCheck?: boolean;
67
94
  /**
@@ -87,6 +114,14 @@ export type DefineOptions = {
87
114
  * recently added route first); non-stream requests `fallback()` to the HAR.
88
115
  */
89
116
  export declare function passLiveStreams(page: Page, url: string): Promise<void>;
117
+ /**
118
+ * Default for `selfCheck` when the consumer didn't set it: ON when RECORDING (no
119
+ * `replayFrom`) — that's where live nondeterminism surfaces — and OFF when REPLAYING,
120
+ * since the replay run renders against the recorded HAR and is deterministic by
121
+ * construction, so self-checking it just doubles the work. `STYLEPROOF_SELFCHECK=1`
122
+ * forces it on either way.
123
+ */
124
+ export declare function defaultSelfCheck(replayFrom: string | undefined, env?: string | undefined): boolean;
90
125
  /**
91
126
  * Generate one Playwright test per surface × width that captures the style
92
127
  * map to `<baseDir>/<dir>/<key>@<width>.json.gz`. Captures are made
@@ -100,4 +135,4 @@ export declare function passLiveStreams(page: Page, url: string): Promise<void>;
100
135
  * defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
101
136
  * ```
102
137
  */
103
- export declare function defineStyleMapCapture({ surfaces, dir, baseDir, screenshots, replayFrom, replayUrl, freezeClock, clockTime, selfCheck, captureText, }: DefineOptions): void;
138
+ export declare function defineStyleMapCapture({ surfaces, expected, exclude, dir, baseDir, screenshots, replayFrom, replayUrl, freezeClock, clockTime, selfCheck, captureText, }: DefineOptions): void;
package/dist/runner.js CHANGED
@@ -1,8 +1,9 @@
1
- import { test } from '@playwright/test';
1
+ import { test, expect } from '@playwright/test';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { captureStyleMap, saveStyleMap } from './capture.js';
4
+ import { captureStyleMap, saveStyleMap, trackInflightRequests } from './capture.js';
5
5
  import { diffStyleMaps } from './diff.js';
6
+ import { coverageGaps } from './coverage.js';
6
7
  /** One-line description of the first drift finding, for the self-check error. */
7
8
  function driftDesc(f) {
8
9
  if (f.kind === 'dom')
@@ -69,9 +70,9 @@ async function pinInputs(page, harName, s) {
69
70
  await page.clock.setFixedTime(new Date(s.clockTime));
70
71
  }
71
72
  /** Capture the surface again and throw if the computed styles drifted from `first`. */
72
- async function assertDeterministic(page, surface, first, captureText) {
73
+ async function assertDeterministic(page, surface, first, captureText, pending) {
73
74
  await surface.go(page);
74
- const again = await captureStyleMap(page, { ignore: surface.ignore ?? [], captureText });
75
+ const again = await captureStyleMap(page, { ignore: surface.ignore ?? [], captureText, pendingRequests: pending });
75
76
  const drift = diffStyleMaps(first, again);
76
77
  if (drift.length) {
77
78
  throw new Error(`styleproof self-check failed: ${surface.key} is non-deterministic — ` +
@@ -86,18 +87,41 @@ async function captureSurface(page, surface, width, s) {
86
87
  await pinInputs(page, `${surface.key}@${width}.har`, s);
87
88
  const height = typeof surface.height === 'function' ? surface.height(width) : (surface.height ?? 800);
88
89
  await page.setViewportSize({ width, height });
89
- await surface.go(page);
90
- const map = await captureStyleMap(page, { ignore: surface.ignore ?? [], captureText: s.captureText });
91
- if (s.selfCheck)
92
- await assertDeterministic(page, surface, map, s.captureText);
93
- const stem = path.join(s.baseDir, s.dir, `${surface.key}@${width}`);
94
- saveStyleMap(`${stem}.json.gz`, map);
95
- if (s.screenshots) {
96
- // captureStyleMap froze animations/transitions, so this is the same settled
97
- // state the map describes.
98
- await page.screenshot({ path: `${stem}.png`, fullPage: true, animations: 'disabled' });
90
+ // Arm the in-flight request tracker BEFORE go() so the surface's own load fetches
91
+ // count toward the network-aware settle a request that starts during navigation
92
+ // fired its event before captureStyleMap could attach a listener of its own.
93
+ const requests = trackInflightRequests(page);
94
+ try {
95
+ await surface.go(page);
96
+ const map = await captureStyleMap(page, {
97
+ ignore: surface.ignore ?? [],
98
+ captureText: s.captureText,
99
+ pendingRequests: requests.pending,
100
+ });
101
+ if (s.selfCheck)
102
+ await assertDeterministic(page, surface, map, s.captureText, requests.pending);
103
+ const stem = path.join(s.baseDir, s.dir, `${surface.key}@${width}`);
104
+ saveStyleMap(`${stem}.json.gz`, map);
105
+ if (s.screenshots) {
106
+ // captureStyleMap froze animations/transitions, so this is the same settled
107
+ // state the map describes.
108
+ await page.screenshot({ path: `${stem}.png`, fullPage: true, animations: 'disabled' });
109
+ }
110
+ }
111
+ finally {
112
+ requests.dispose();
99
113
  }
100
114
  }
115
+ /**
116
+ * Default for `selfCheck` when the consumer didn't set it: ON when RECORDING (no
117
+ * `replayFrom`) — that's where live nondeterminism surfaces — and OFF when REPLAYING,
118
+ * since the replay run renders against the recorded HAR and is deterministic by
119
+ * construction, so self-checking it just doubles the work. `STYLEPROOF_SELFCHECK=1`
120
+ * forces it on either way.
121
+ */
122
+ export function defaultSelfCheck(replayFrom, env = process.env.STYLEPROOF_SELFCHECK) {
123
+ return env === '1' || !replayFrom;
124
+ }
101
125
  /**
102
126
  * Generate one Playwright test per surface × width that captures the style
103
127
  * map to `<baseDir>/<dir>/<key>@<width>.json.gz`. Captures are made
@@ -111,8 +135,7 @@ async function captureSurface(page, surface, width, s) {
111
135
  * defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
112
136
  * ```
113
137
  */
114
- export function defineStyleMapCapture({ surfaces, dir, baseDir = '__stylemaps__', screenshots = true, replayFrom = process.env.STYLEPROOF_REPLAY_FROM, replayUrl = process.env.STYLEPROOF_REPLAY_URL ?? '**/api/**', freezeClock = true, clockTime = '2025-01-01T00:00:00Z', selfCheck = process.env.STYLEPROOF_SELFCHECK === '1', captureText = false, }) {
115
- test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
138
+ export function defineStyleMapCapture({ surfaces, expected, exclude = {}, dir, baseDir = '__stylemaps__', screenshots = true, replayFrom = process.env.STYLEPROOF_REPLAY_FROM, replayUrl = process.env.STYLEPROOF_REPLAY_URL ?? '**/api/**', freezeClock = true, clockTime = '2025-01-01T00:00:00Z', selfCheck = defaultSelfCheck(replayFrom), captureText = false, }) {
116
139
  const settings = {
117
140
  dir: dir,
118
141
  baseDir,
@@ -124,7 +147,25 @@ export function defineStyleMapCapture({ surfaces, dir, baseDir = '__stylemaps__'
124
147
  selfCheck,
125
148
  captureText,
126
149
  };
150
+ // Coverage guard. Runs in the NORMAL test suite (NOT gated on a capture dir), so
151
+ // a route added without a surface fails the app's own tests — long before, and
152
+ // independent of, a capture run. This is the one gap captures can't catch: a
153
+ // surface never taken can't be diffed. Only emitted when the spec declares its
154
+ // `expected` universe; otherwise StyleProof keeps its prior behaviour exactly.
155
+ if (expected) {
156
+ test.describe('styleproof coverage', () => {
157
+ test('every expected surface is captured or explicitly excluded', () => {
158
+ const { uncovered, staleExclusions } = coverageGaps(surfaces.map((s) => s.key), expected, exclude);
159
+ expect(uncovered, `StyleProof coverage gap: ${uncovered.length} expected surface(s) are neither captured ` +
160
+ `nor excluded — add each to \`surfaces\`, or to \`exclude\` with a reason. ` +
161
+ `Missing: ${uncovered.join(', ')}`).toEqual([]);
162
+ expect(staleExclusions, `StyleProof: \`exclude\` lists surface(s) absent from \`expected\` ` +
163
+ `(renamed or removed?): ${staleExclusions.join(', ')}`).toEqual([]);
164
+ });
165
+ });
166
+ }
127
167
  test.describe('styleproof capture', () => {
168
+ test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
128
169
  for (const surface of surfaces) {
129
170
  for (const width of surface.widths) {
130
171
  test(`${surface.key} @ ${width}`, ({ page }) => captureSurface(page, surface, width, settings));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Catch every CSS change before it ships — review PRs and certify refactors by the browser's computed styles, not pixels. Works with any styling system.",
5
5
  "keywords": [
6
6
  "playwright",