styleproof 3.18.0 → 3.20.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,197 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.20.0] - 2026-07-07
11
+
12
+ ### Fixed
13
+
14
+ - **Crawl no longer double-captures `/` and `/index.html` as two surfaces.** On a
15
+ static multi-page site whose nav links the `.html` files, `/` and `/index.html`
16
+ (and `/dir/` vs `/dir/index.html`) are the same route but were captured twice as
17
+ byte-near-identical maps — doubling the capture work and duplicating every finding
18
+ in the diff. The crawl's dedup identity now normalizes a trailing `index.html` to
19
+ its directory path, so they collapse to one surface (first-seen href keeps its
20
+ original navigable form). Only the literal `index.html` filename normalizes — a
21
+ genuine `about.html` stays a distinct surface from `about`.
22
+ - **`styleproof-init` now states exactly which files it wrote — and that it did NOT
23
+ touch `package.json` or your lockfile.** Adopters attributed the `styleproof`
24
+ dependency entry (added by their package manager's `install`) to init; init only
25
+ ever reads `package.json` and writes the spec, the dedicated Playwright config,
26
+ `.gitignore` lines, and the CI workflow. The summary now enumerates those files and
27
+ says plainly that the manifest and lockfile were left untouched.
28
+ - **The no-comparison outcome of `styleproof-diff` / `styleproof-report` names both
29
+ ways forward.** When the no-args (inferred-base) path can restore no base map — no
30
+ map-store remote, no cached bundle — nothing is compared. That already exits `2`
31
+ (never a soft `0` a newcomer could read as "certified clean"); the message now says
32
+ "nothing was compared" and names the two working alternatives: run in CI (or a repo
33
+ with the `origin` remote) where the base is restorable, or the two-directory form
34
+ `styleproof-diff <beforeDir> <afterDir>`. A regression test pins the exit-2 contract
35
+ in a remote-less repo for both commands.
36
+ - **README: the Next.js coverage guard is described accurately.** The docs conflated
37
+ two behaviors. With the auto-wired spec, `surfaces` and `expected` both derive from
38
+ the same `discoverNextRoutes()` call, so a new static route is captured and expected
39
+ together — **auto-covered, never a guard failure**. The guard **fails** only on
40
+ genuine divergence (a dynamic route, a hand-maintained registry, or a route dropped
41
+ from `surfaces` while still `expected`). Rewrote the overclaiming passages and the
42
+ generated-spec comments to state both behaviors.
43
+ - **Layout-equivalent margin suppression no longer drops a real one-sided margin
44
+ change.** `dropLayoutEquivalentMarginProps` suppressed any horizontal
45
+ `margin-left/right/inline-start/inline-end` change whenever the element's rect
46
+ was unchanged — reasoning that a margin that doesn't move the box is cosmetic
47
+ drift. But a one-sided change (e.g. `margin-left: 0 → 40px` with `margin-right`
48
+ untouched) that leaves the rect identical only stayed put because _something
49
+ else compensated_; that is a genuine restyle, and it was silently dropped. The
50
+ suppression now fires only when there is no **demonstrable px imbalance**
51
+ between a side and its opposite — balanced drift (both sides move together) and
52
+ forced-state deltas are still suppressed exactly as before, but a one-sided
53
+ real change surfaces. A residual, consciously-deferred corner remains (a
54
+ perfectly _balanced_ change held in place by external compensation), documented
55
+ inline; closing it needs cross-element layout reasoning.
56
+ - **`styleproof-init` no longer imports the whole library barrel (fixes a CI
57
+ flake).** The scaffolder only needs `discoverNextRoutes`, but it imported it
58
+ from `dist/index.js` — dragging capture, the crawler, the report renderer, and
59
+ six Playwright-importing modules into a tool that writes files and captures
60
+ nothing. Loading that oversized module graph concurrently (init's own suite
61
+ spawns the CLI many times, alongside the rest of `node --test`) is what made
62
+ init's tests flake in CI, red-flagging releases with no code cause. It now
63
+ imports from the `dist/routes.js` leaf (`fs` + `path` only): init's transitive
64
+ module graph drops from 21 dist modules to 1, with zero Playwright modules on
65
+ its load path. Behaviour is unchanged; a regression test pins the leaf import.
66
+ - **Popup capture: verified reset + identity-bound triggers (no leaked-overlay
67
+ contamination, no wrong-trigger keying).** On a surface whose `go()` doesn't
68
+ navigate (SPA variants), the between-popups reset was Escape-only and assumed:
69
+ a toast or `[role="status"]` overlay Escape can't dismiss leaked into the next
70
+ popup's capture, and each reopen re-enumerated triggers positionally, so a
71
+ shifted trigger set (e.g. a click that adds a button) could key a popup under a
72
+ different trigger than the one originally enumerated. Triggers are now re-bound
73
+ by the DOM identity recorded at first enumeration, and the reset is verified
74
+ against the surface's pristine overlay set; a candidate that can't be opened
75
+ safely is skipped loudly (a `styleproof:` warning naming the popup and the
76
+ leaked overlay or missing trigger) instead of being captured contaminated,
77
+ mis-keyed, or — with self-check on — saved unproven. That identity is the
78
+ trigger's DOM path **and** its accessible label, not the path alone: for an
79
+ id-less trigger the path ends in `:nth-of-type`, which is still position within
80
+ a parent, so a same-tag same-parent sibling injected earlier in DOM order
81
+ between enumeration and reopen would slide the recorded path onto a different
82
+ trigger and key its popup under the wrong one — silently. Requiring the label
83
+ (the same aria-label/name/text/title accessible name the crawler reads) to match
84
+ too turns that mismatch into the same loud skip. Navigating surfaces are
85
+ unaffected.
86
+
87
+ ## [3.19.0] - 2026-07-06
88
+
89
+ ### Added
90
+
91
+ - **Selective-remap wiring: `explainAffectedSurfaces` + the pre-push recipe.** The
92
+ sound core (`affectedSurfaces`) shipped in 3.17.0 returns a bare `Set | 'all'` that
93
+ names nothing; the new pure `explainAffectedSurfaces(result, allSurfaceKeys, reason?)`
94
+ formatter renders the verdict as reviewer-checkable lines — which surfaces re-capture
95
+ and which reuse their committed base map — so a pre-push hook or CI log can print the
96
+ skip list before anyone trusts it. `affectedSurfaces`'s return shape is unchanged
97
+ (backward-compatible; the helper takes the surface keys as a second argument rather
98
+ than extending the sentinel). README's selective-remap section gains the helper, its
99
+ output, and the full `git diff → dependency-cruiser → affectedSurfaces → capture subset`
100
+ pre-push recipe. Opt-in and advisory throughout — the default full-coverage gate is
101
+ untouched.
102
+
103
+ ### Fixed
104
+
105
+ - **Report tables escape hostile CSS values (no Markdown breakout).** A property
106
+ value carrying a `|` used to split a report table row, and a backtick used to close
107
+ the code span and leak live Markdown (`content:"…"`, `url(…)`, `font` strings). The
108
+ render boundary now escapes values instead of stripping them — `|` → `\|`, and the
109
+ code fence widens past the value's longest backtick run — so hostile values render
110
+ as one intact, readable cell. `report.md` only; the privileged review comment was
111
+ never affected.
112
+ - **The navigable-removal guard now sees SVG `<a>` nav links.** The in-page harvest
113
+ keyed anchors off `tagName === 'A'`, which is HTML-only (SVG reports lowercase `a`),
114
+ so an SVG nav link never entered the inventory and its removal never gated. The tag
115
+ check is now case-insensitive, the selector matches any-namespace `href`
116
+ (`a[*|href]`), and the target falls back to `xlink:href` — an `<svg><a>` link now
117
+ resolves like an HTML one.
118
+ - **Single-value `transform-origin`/`perspective-origin` jitter is suppressed.** The
119
+ sub-pixel-origin equality check required at least two length components, so a
120
+ one-value origin (`50px`) leaked rounding jitter as a false diff. One to three
121
+ components are now all suppressed within `ORIGIN_EPSILON_PX`; a real, larger change
122
+ still reports.
123
+ - **`styleproof-diff --json` exits 2 (not 1) when the file cannot be written.** A bad
124
+ `--json` path is a usage/setup error, but the write sat outside the guarded blocks
125
+ and let the failure fall through to exit 1 — which CI reads as a real diff. It now
126
+ reports to stderr and exits 2.
127
+ - **Crawl flag docs corrected.** `--max-depth`'s default is documented as 16 (not
128
+ "unbounded" in `--help`, not "3" in the JSDoc); `--until-covered` is now listed in
129
+ `styleproof-capture --help`.
130
+ - **Crawl no longer silently drops surfaces to key collisions, and mapping no longer
131
+ clicks title-only destructive controls.** Five soundness fixes across the crawl path:
132
+ - `selectCrawlLinks` deduped by raw URL, so `/about` and `/about/` — one route — were
133
+ two links that keyed identically and the second capture overwrote the first. Trailing
134
+ slashes are now normalized in the dedup identity (root `/` and the query string are
135
+ left intact), so a route is captured once.
136
+ - Genuinely distinct surfaces whose derived keys collide (e.g. `/a/b` and `/a-b` both
137
+ slugify to `a-b`) previously overwrote each other's map file. `selectCrawlLinks` now
138
+ disambiguates with a `-2`, `-3`, … suffix (mirroring the surface crawler), so every
139
+ surface survives.
140
+ - `defaultLinkKey` joined query-param values in iteration order, so `?tab=a&x=b` and
141
+ `?x=b&tab=a` — the same route — keyed as `a-b` vs `b-a` and flapped the coverage
142
+ guard into phantom regressions. Params are now sorted by name before joining.
143
+ - The surface crawler's clickable-candidate label omitted the `title` attribute, so an
144
+ icon-only `<button title="Delete">` labeled as `button` and slipped past the
145
+ destructive-action guard — mapping would click it. `title` is now part of the label
146
+ input in both crawlers.
147
+ - The variant crawler carried a weaker, divergent copy of the destructive-action word
148
+ list (missing `revoke|reset|wipe|drop|rotate|provision|seal|regenerate|renew`). Both
149
+ crawlers now share one `DANGER_SOURCE` constant, so mapping refuses the same set of
150
+ destructive controls everywhere.
151
+ - `defineStyleMapCapture` and `defineCrawlCapture` now assert every expanded capture
152
+ key is unique before running: the `surface.key-variant.key` join is ambiguous
153
+ (`a` + `b-c` and `a-b` + `c` both expand to `a-b-c`), which used to overwrite a map
154
+ file with no error. The key format is unchanged (it's public — filenames and report
155
+ identities); a collision now throws up front and names both origins so the author can
156
+ rename one.
157
+ - **Sass `@use`/`@forward` in a CSS Module now fails closed to `'all'`.** A
158
+ `.module.scss`/`.module.sass` that loads a partial via `@use`/`@forward` can pull in
159
+ global rules the JS import graph can't see, so `classifyStyleChange` now treats any
160
+ such file as global (`'all'`) — a sound over-approximation, no heuristics. A plain CSS
161
+ Module with only class selectors stays `'scope'` as before.
162
+ - **Legacy Sass/CSS `@import` in a CSS Module now fails closed too.** The fail-closed
163
+ load check missed the `@import` form, so a `.module.scss` loading a partial via
164
+ `@import "vars"` — whose members merge in exactly like `@use` — classified as `'scope'`
165
+ and could silently skip a re-capture. `classifyStyleChange` now treats any
166
+ `@use`/`@forward`/`@import` load as global (`'all'`), covering both the Sass partial
167
+ load and the plain-CSS pass-through (`@import url(x.css)`, whose selectors are not
168
+ hashed into the module's per-file scope, so it escapes the module). Only ever widens
169
+ toward `'all'`; a module with no load directive still stays `'scope'`.
170
+ - **`affectedSurfaces` now canonicalizes paths across all inputs, closing a silent
171
+ unsound skip.** `surfaces` entries, `changedFiles`, graph edges, and `files` are now
172
+ normalized to one spelling (strip a leading `./`, collapse `//`, resolve `.`/`..` as
173
+ pure string math — no fs), so a `./pages/Home.tsx` surface entry no longer misses a
174
+ reachability hit spelled `pages/Home.tsx` and gets dropped from the affected set. And a
175
+ declared surface whose entry path appears in neither `files` nor any graph edge is now
176
+ unplaceable → `'all'`, the same fail-closed rule as an unplaceable changed file.
177
+ - **Stale browser-build sidecar can no longer stamp a false fingerprint.**
178
+ `styleproof-map` now deletes any prior run's `styleproof-browser.json` before Playwright
179
+ runs, and `writeBrowserBuildSidecar(dir, undefined)` now removes an existing sidecar
180
+ rather than leaving it. Previously a reused capture dir plus a run that recorded no
181
+ browser version would fold the _previous_ run's build into this run's manifest, and
182
+ `assertCompatibleMapDirs` would trust that false `browserVersion` fingerprint.
183
+ - **`captureStyleMap` no longer leaks its motion-freeze `<style>` onto a reused page.**
184
+ The freeze injected for the base/forced-state reads was re-applied without a handle and
185
+ never removed, so on a page recaptured **without a reload** (an SPA `go()` that doesn't
186
+ navigate, multi-surface reuse, the self-check's re-run) the next capture's motion pass
187
+ read the still-frozen transition/animation longhands (`none`/`0s`) as its baseline —
188
+ phantom drift that surfaced as a **false "non-deterministic capture"** self-check
189
+ failure. The re-applied tag is now tracked and removed in a `finally`, so throw paths
190
+ clean up too. (No API change.)
191
+ - **`liveStates` + `expected` no longer reports a false coverage gap.** A surface with
192
+ `liveStates` is captured only as its split expansions (`home-loading`, `home-loaded`) —
193
+ the bare base key is dropped by design — but the coverage ledger recorded `expected`
194
+ in base keys and the gate compared captured keys literally, so a fully-captured app
195
+ failed the gate with `uncovered: ['home']` (live in 3.18.0). The ledger writer now
196
+ records `expected` already translated through the same liveState expansion, and the
197
+ suite-side guard maps each capture back to its originating `surfaceKey` — a precise
198
+ mapping via real metadata, so an unrelated `home-banner` never satisfies an uncaptured
199
+ `home`. Both the spec-driven and crawl capture paths are fixed consistently.
200
+
10
201
  ## [3.18.0] - 2026-07-06
11
202
 
12
203
  ### Added
package/README.md CHANGED
@@ -57,8 +57,12 @@ surface. `expected` turns that inventory into a guard: a key it lists that is
57
57
  neither captured nor excluded fails as missing coverage instead of silently
58
58
  passing. What's guarded depends on how `expected` is fed —
59
59
 
60
- - **Next.js:** auto-guarded. `styleproof-init` wires `expected` to the routes it
61
- discovers, so a new page fails the guard with nothing to keep in sync.
60
+ - **Next.js:** auto-covered. `styleproof-init` derives both `surfaces` and
61
+ `expected` from the same `discoverNextRoutes()` call, so a new static route lands
62
+ in both at once — captured and expected together, with nothing to keep in sync.
63
+ The guard then fails only on genuine divergence between the two: a dynamic route
64
+ (pre-excluded, so its reason surfaces), a hand-maintained registry, or a route
65
+ dropped from `surfaces` while still in `expected`.
62
66
  - **Link-crawled SPAs:** pass `expected` to `defineCrawlCapture` and the crawl
63
67
  reconciles it against the _rendered nav_ (the route universe for such an app),
64
68
  both directions — a new linked route with no `expected` entry fails, and an
@@ -152,7 +156,7 @@ _and_ no head capture, so it never appears in any diff, and the status goes
152
156
  green having never looked at it. This is the one thing the captures can't catch
153
157
  on their own: a capture that was never taken.
154
158
 
155
- 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:
159
+ Declare your app's route/view universe in `expected` and StyleProof emits a coverage-guard test in your **normal** suite (it runs even without `STYLEMAP_DIR` — it's a static check, no browser). It fails when `expected` and your captured surfaces diverge — a route you listed in `expected` with no surface and no `exclude` entry fails as missing coverage, so a registry entry can't quietly ship uncaptured:
156
160
 
157
161
  ```ts
158
162
  import { defineStyleMapCapture } from 'styleproof';
@@ -168,7 +172,7 @@ defineStyleMapCapture({
168
172
 
169
173
  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.
170
174
 
171
- **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:
175
+ **Next.js: wired for you.** Run `styleproof-init` in a Next.js project and the generated spec discovers your routes (App Router `app/` + Pages Router `pages/`) at run time and derives **both** the surfaces and `expected` from that same `discoverNextRoutes()` call. Because they share one source, a static route you add later is captured and expected in the same step — auto-covered, never a guard failure, with nothing to keep in sync. The guard exists for the cases where the two genuinely diverge: a dynamic `[param]` route (it can't be navigated without a value, so it's placed in `exclude` with a reason rather than captured), a registry you hand-maintain instead of the live call, or a route you drop from `surfaces` while it's still `expected`:
172
176
 
173
177
  ```ts
174
178
  import { defineStyleMapCapture, discoverNextRoutes } from 'styleproof';
@@ -292,6 +296,17 @@ semantic roots that were actually present in the computed-style map, so tests ca
292
296
  assert a capture reached `role="dialog"`, `aria-modal`, `role="menu"`,
293
297
  `role="listbox"`, or hot-toast text.
294
298
 
299
+ Triggers are enumerated once per surface and every reopen re-binds to that same
300
+ element by identity — its DOM path **and** its accessible label — never by
301
+ position. Between popups the surface is reset (Escape + `go()`) and the reset is
302
+ verified: if an overlay a previous popup left behind is still visible (Escape
303
+ closes dialogs, not toasts or status regions), or an enumerated trigger
304
+ disappeared or changed identity (e.g. a same-tag sibling shifted in earlier),
305
+ that candidate is **skipped loudly** — a `styleproof:` warning names the popup and
306
+ why — instead of capturing contaminated state or keying a popup under the wrong
307
+ trigger. Dismiss the leaking overlay in the surface's `go()`, or capture it as an
308
+ explicit variant.
309
+
295
310
  **Harvest one-step variants.** Routes are not the whole UI: drawers, tabs,
296
311
  dialogs, empty form errors, selects, and other one-step states need their own
297
312
  captures. `styleproof-variants` opens a running app, tries semantic controls
@@ -733,7 +748,48 @@ const result = affectedSurfaces({
733
748
  // → 'all' (some change couldn't be bounded — capture everything)
734
749
  ```
735
750
 
736
- Two honest limits, both resolving to `'all'`: a computed `import(`../dir/${x}`)` is treated as a bundler **context module** (every file under that dir is a possible target, so precision there is directory-level, never a miss); and the CSS-in-JS global list (`createGlobalStyle`, `injectGlobal`, `globalStyle`, …) must match the libraries you use — an unrecognized global API is the one way a scoped verdict could be unsound, so treat an unsupported styling system as a reason to skip selective remap. Because a PR-time miss would be silent, always let `main` (or a scheduled run) capture **all** surfaces as the trust-but-verify net.
751
+ Two honest limits, both resolving to `'all'`: a computed `import(`../dir/${x}`)` is treated as a bundler **context module** (every file under that dir is a possible target, so precision there is directory-level, never a miss); and a CSS-Module (`.module.scss`/`.module.sass`) that carries a Sass `@use`/`@forward` load resolves to `'all'`, because those pull in a partial the JS import graph can't bound. One honest **residual** stays `'scope'` by design: the CSS-in-JS global list (`createGlobalStyle`, `injectGlobal`, `globalStyle`, …) must match the libraries you use — an allowlist can't fail closed on an _unknown_ member, so an unrecognized global API in a `.tsx` is the one way a scoped verdict could be unsound. Treat an unsupported styling system as a reason to skip selective remap. Because a PR-time miss would be silent, always let `main` (or a scheduled run) capture **all** surfaces as the trust-but-verify net.
752
+
753
+ ### Show the skip list, then wire the pre-push hook
754
+
755
+ Before you trust a skip, print it. `explainAffectedSurfaces(result, allSurfaceKeys)` renders the verdict as reviewer-checkable lines — which surfaces re-capture and which reuse their committed base map — and takes an optional reason string for the `'all'` case:
756
+
757
+ ```ts
758
+ import { affectedSurfaces, explainAffectedSurfaces } from 'styleproof';
759
+
760
+ const result = affectedSurfaces(/* … */);
761
+ console.log(explainAffectedSurfaces(result, Object.keys(surfaces)));
762
+ ```
763
+
764
+ A scoped change (only `dashboard`'s subtree touched) prints:
765
+
766
+ ```
767
+ selective remap: ON → re-capture 1, reuse 2 from base
768
+ ↻ dashboard (re-capture — a changed file reaches it)
769
+ ✓ home (reuse base map — no changed file reaches it)
770
+ ✓ pricing (reuse base map — no changed file reaches it)
771
+ ```
772
+
773
+ A global/token change fails closed to a full re-capture:
774
+
775
+ ```
776
+ selective remap: OFF → re-capture all 3 surface(s) — src/tokens.css is a global (unscoped) stylesheet
777
+ ↻ dashboard (re-capture)
778
+ ↻ home (re-capture)
779
+ ↻ pricing (re-capture)
780
+ ```
781
+
782
+ Wired into a pre-push hook, the whole recipe is: diff the changed files, produce the graph, ask `affectedSurfaces`, print the skip list, then capture only the subset and reuse the committed base maps for the rest.
783
+
784
+ ```sh
785
+ #!/usr/bin/env sh
786
+ # .husky/pre-push (opt-in; the default CI gate still captures every surface)
787
+ CHANGED=$(git diff --name-only origin/main...HEAD)
788
+ npx dependency-cruiser src --no-config --output-type json > dc.json
789
+ node scripts/selective-remap.mjs "$CHANGED" dc.json # affectedSurfaces + explain + capture subset
790
+ ```
791
+
792
+ `scripts/selective-remap.mjs` is yours to own — it maps `dc.json` into `{ from, to }` edges (as above), calls `affectedSurfaces`, prints `explainAffectedSurfaces`, then captures only the returned keys and copies each reused surface's committed base map forward. `main` re-captures everything, so a PR-time miss is still caught at merge.
737
793
 
738
794
  ## Newly-added elements show their full style
739
795
 
@@ -810,7 +866,7 @@ Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<styl
810
866
 
811
867
  - `styleproof-init` — scaffold the gate: the capture spec, a dedicated `playwright.styleproof.config.ts` (production-build `webServer`, parallel capture), `.gitignore` cache entries, and the cache-first report workflow. One command. Generated commands follow the repo's lockfile (`bun.lock`/`bun.lockb`, `pnpm-lock.yaml`, `yarn.lock`, or npm by default), respect pnpm/Corepack version pins, and detect Vite/Next production preview commands instead of assuming every repo has `start`.
812
868
  - `styleproof-map` — capture the current commit's computed-style map through Playwright. By default it writes `.styleproof/maps/current`, keeps screenshots for reports, writes a manifest, and uploads to `styleproof-maps` outside CI when the working tree was clean and a git remote exists. Pass `--crawl-base-url` plus repeated `--crawl-route` to run `styleproof-variants` before capture, `--no-upload`, `--restore --sha <commit>`, `--spec`, `--dir`, `--base-dir`, or `--no-screenshots` for custom flows.
813
- - `styleproof-diff` — the certify gate. With no args, it restores cached maps for the current commit and inferred base (`GITHUB_BASE_REF`, `branch.<name>.gh-merge-base`, `gh pr view`, then main/master fallbacks); `styleproof-diff main` / `styleproof-diff master` pins the base; `styleproof-diff <beforeDir> <afterDir>` keeps the manual two-directory form for CI fallback captures. Exits `0` certified (identical); `1` on a reviewable diff — computed-style/DOM/state differences, and equally an unacknowledged inventory removal, an incomplete coverage registry, or an unproven-determinism capture; `2` on a usage/capture error (including a **missing map** — a bundle that claims to exist yet holds zero captures, i.e. a `styleproof-manifest.json` present with no maps, on either side, or a head capture that produced nothing; refused loudly rather than mislabelled as all-new); `3` when only new surfaces are present (no baseline for _those_ surfaces to diff against — new surfaces against an existing baseline, or a base dir with no manifest at all, meaning no baseline was ever captured: the first-adoption review path; approval policy decides whether to gate). A clean run prints `0 changed surfaces across N captured surface(s)`, and `--json` includes `compared`.
869
+ - `styleproof-diff` — the certify gate. With no args, it restores cached maps for the current commit and inferred base (`GITHUB_BASE_REF`, `branch.<name>.gh-merge-base`, `gh pr view`, then main/master fallbacks); `styleproof-diff main` / `styleproof-diff master` pins the base; `styleproof-diff <beforeDir> <afterDir>` keeps the manual two-directory form for CI fallback captures. Exits `0` certified (identical); `1` on a reviewable diff — computed-style/DOM/state differences, and equally an unacknowledged inventory removal, an incomplete coverage registry, or an unproven-determinism capture; `2` on a usage/capture error (including a **missing map** — a bundle that claims to exist yet holds zero captures, i.e. a `styleproof-manifest.json` present with no maps, on either side, or a head capture that produced nothing; refused loudly rather than mislabelled as all-new — **and** the no-args case where the cached base map can't be restored at all: no map-store remote, no cached bundle, nothing to compare. A "nothing was compared" outcome always exits `2`, never a soft `0` that would read as certified; the error names the two ways forward — run in CI where the base is restorable, or use the two-directory form); `3` when only new surfaces are present (no baseline for _those_ surfaces to diff against — new surfaces against an existing baseline, or a base dir with no manifest at all, meaning no baseline was ever captured: the first-adoption review path; approval policy decides whether to gate). A clean run prints `0 changed surfaces across N captured surface(s)`, and `--json` includes `compared`.
814
870
  - `styleproof-report` — render the diff to a Markdown report with before/after crops. With no args, it reports cached maps for the current commit against the inferred base; `styleproof-report main` / `styleproof-report master` pins the base; `styleproof-report <beforeDir> <afterDir> --out <dir>` keeps the manual two-directory form. Add `--include-content` for the opt-in, advisory content section (see above).
815
871
  - `styleproof-variants` — crawl a running app for one-step state variants and write `styleproof.variants.generated.json`. Pass `--base-url`, repeat `--route`, and use `--strict` when unresolved skipped/live candidates should fail automation.
816
872
 
@@ -55,7 +55,10 @@ whole surface: --crawl
55
55
  fulfilled with 500 → error render)
56
56
  --workers <n> concurrent sweep workers (default 4); same surface set as a
57
57
  serial crawl — pass 1 for byte-stable key attribution
58
- --max-depth <n> throttle recursion depth (default: unbounded)
58
+ --until-covered stop the crawl early the moment every stylesheet class has
59
+ been rendered — a coverage-oriented sweep for design mockups
60
+ --max-depth <n> throttle recursion depth (default: 16 — backstop for
61
+ append-generator UIs)
59
62
  --max-actions <n> throttle controls tried per state (default: unbounded)
60
63
  --max-states <n> throttle total surfaces (default: unbounded)
61
64
  --no-reset-storage don't clear localStorage between steps (default: clear)
@@ -303,39 +303,48 @@ const invRemovals = printInventoryAudit(inventoryAudit);
303
303
  const coverageFails = printCoverageVerdict(coverageVerdict);
304
304
  const determinismFails = printDeterminismVerdict(determinismVerdict);
305
305
 
306
- if (jsonOut)
307
- fs.writeFileSync(
308
- jsonOut,
309
- JSON.stringify(
310
- {
311
- counts,
312
- surfaces,
313
- compared,
314
- coverage: coverageVerdict,
315
- determinism: determinismVerdict,
316
- // The inventory verdict, machine-readable — parallel to coverage/determinism and
317
- // to the report's certification block. `null` when no capture carried inventory.
318
- // `unacknowledged` is the gating set: a CI can hard-fail on `unacknowledged.length`.
319
- inventory: inventoryAudit && {
320
- removed: inventoryAudit.delta.removed.map((i) => i.key),
321
- added: inventoryAudit.delta.added.map((i) => i.key),
322
- unacknowledged: inventoryAudit.unexplained.map((i) => i.key),
323
- staleAcknowledgements: inventoryAudit.staleAllowances,
306
+ if (jsonOut) {
307
+ // A write failure (bad --json path, unwritable dir) is a usage/setup error, not a
308
+ // "reviewable differences" result — exit 2, never leak the exit-1 that CI reads as
309
+ // a real diff.
310
+ try {
311
+ fs.writeFileSync(
312
+ jsonOut,
313
+ JSON.stringify(
314
+ {
315
+ counts,
316
+ surfaces,
317
+ compared,
318
+ coverage: coverageVerdict,
319
+ determinism: determinismVerdict,
320
+ // The inventory verdict, machine-readable — parallel to coverage/determinism and
321
+ // to the report's certification block. `null` when no capture carried inventory.
322
+ // `unacknowledged` is the gating set: a CI can hard-fail on `unacknowledged.length`.
323
+ inventory: inventoryAudit && {
324
+ removed: inventoryAudit.delta.removed.map((i) => i.key),
325
+ added: inventoryAudit.delta.added.map((i) => i.key),
326
+ unacknowledged: inventoryAudit.unexplained.map((i) => i.key),
327
+ staleAcknowledgements: inventoryAudit.staleAllowances,
328
+ },
329
+ // Explain the `inventory: null` so a gate reading this JSON can tell "armed but no
330
+ // data" apart from "audited, nothing removed". Neither map carried inventory → set
331
+ // `inventory: true` in the capture spec (styleproof-init scaffolds it).
332
+ ...(inventoryAudit
333
+ ? {}
334
+ : {
335
+ inventoryNote:
336
+ 'no captured map carried an inventory — set `inventory: true` in the capture spec to arm the navigable-removal gate',
337
+ }),
324
338
  },
325
- // Explain the `inventory: null` so a gate reading this JSON can tell "armed but no
326
- // data" apart from "audited, nothing removed". Neither map carried inventory → set
327
- // `inventory: true` in the capture spec (styleproof-init scaffolds it).
328
- ...(inventoryAudit
329
- ? {}
330
- : {
331
- inventoryNote:
332
- 'no captured map carried an inventory — set `inventory: true` in the capture spec to arm the navigable-removal gate',
333
- }),
334
- },
335
- null,
336
- 2,
337
- ),
338
- );
339
+ null,
340
+ 2,
341
+ ),
342
+ );
343
+ } catch (e) {
344
+ console.error(`${COMMAND}: could not write --json ${jsonOut}: ${e.message}`);
345
+ process.exit(2);
346
+ }
347
+ }
339
348
 
340
349
  const total = counts.dom + counts.style + counts.state;
341
350
  const newSurfaces = surfaces.filter((s) => s.missing).length;
@@ -7,10 +7,13 @@
7
7
  * Writes:
8
8
  * - <dir> (default e2e/styleproof.spec.ts): a starter capture spec with a
9
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.
10
+ * handles fonts, animation freeze, and the settle). For a detected Next.js app it derives BOTH the
11
+ * surfaces AND the `expected` coverage guard from the same `discoverNextRoutes()`
12
+ * call, so a static route added later is captured and expected together —
13
+ * auto-covered, never a guard failure; the guard fails only on genuine
14
+ * divergence (a dynamic route, a hand-maintained registry, or a route dropped
15
+ * from surfaces but still expected). Otherwise it writes one sample surface
16
+ * plus a commented guard block to wire to your own route registry.
14
17
  * - playwright.styleproof.config.ts: a dedicated production-build Playwright
15
18
  * config for StyleProof captures, so an existing app Playwright config is
16
19
  * never disturbed or accidentally reused.
@@ -23,7 +26,12 @@
23
26
  */
24
27
  import fs from 'node:fs';
25
28
  import path from 'node:path';
26
- import { discoverNextRoutes } from '../dist/index.js';
29
+ // Import from the leaf module, not the barrel: styleproof-init only scaffolds
30
+ // files and never captures. Pulling `../dist/index.js` here dragged the whole
31
+ // library — capture, crawler, report, and six Playwright-importing modules —
32
+ // into a tiny scaffolder's load path, and that oversized concurrent module
33
+ // graph is what made init's tests flake in CI. routes.js needs only fs + path.
34
+ import { discoverNextRoutes } from '../dist/routes.js';
27
35
  import { isHelpArg, showHelpAndExit } from '../dist/cli-errors.js';
28
36
 
29
37
  const HELP = `styleproof-init — scaffold a styleproof capture spec
@@ -39,9 +47,10 @@ options:
39
47
 
40
48
  What it writes:
41
49
  - the spec at --dir, with a minimal settle() helper (scroll-reveal only).
42
- In a Next.js app it discovers your routes at run time and wires both the
43
- surfaces and the \`expected\` coverage guard to them, so a new page can't ship
44
- uncaptured. Otherwise it writes one sample surface + a commented guard block.
50
+ In a Next.js app it discovers your routes at run time and derives both the
51
+ surfaces and the \`expected\` coverage guard from that one call, so a new static
52
+ route is auto-covered (captured + expected together); the guard fails only when
53
+ the two diverge. Otherwise it writes one sample surface + a commented guard block.
45
54
  - playwright.styleproof.config.ts, a dedicated production-build Playwright config
46
55
  - .github/workflows/styleproof.yml, a cache-first PR report workflow
47
56
 
@@ -114,9 +123,12 @@ const HEADER = `/**
114
123
  * npx styleproof-diff # compare cached base/head maps by commit SHA
115
124
  */`;
116
125
 
117
- // Next.js detected: derive both surfaces and the coverage guard from the app's
118
- // routes AT RUN TIME, so a page added later is in `expected` automatically and
119
- // fails the guard until it has a surface no static list to drift.
126
+ // Next.js detected: derive BOTH surfaces and the coverage guard from the app's
127
+ // routes AT RUN TIME, from one `discoverNextRoutes()` call so a static page added
128
+ // later is a captured surface AND `expected` in the same step (auto-covered, never a
129
+ // guard failure), with no static list to drift. The guard fires only when the two
130
+ // diverge (a dynamic route, a hand-maintained registry, or a route dropped from
131
+ // surfaces but still expected).
120
132
  const NEXT_SPEC = `import type { Page } from '@playwright/test';
121
133
  import { defineStyleMapCapture, discoverNextRoutes, type Surface } from 'styleproof';
122
134
 
@@ -124,12 +136,12 @@ ${HEADER}
124
136
 
125
137
  ${SETTLE}
126
138
 
127
- // Routes discovered from your Next.js app (app/ + pages/) at RUN TIME so a page
128
- // you add later is covered automatically, with no surface list to keep in sync
129
- // (that drift is exactly what lets a new page ship unverified). Edit freely; this
130
- // is your spec. Static routes each get a capture; dynamic [param] routes can't be
131
- // navigated without a value, so they're listed in \`exclude\` until you add a
132
- // surface with a concrete param.
139
+ // Routes discovered from your Next.js app (app/ + pages/) at RUN TIME. Both SURFACES
140
+ // and \`expected\` below come from this one list, so a static route you add later is
141
+ // captured and expected together covered automatically, with no surface list to
142
+ // keep in sync. Edit freely; this is your spec. Static routes each get a capture;
143
+ // dynamic [param] routes can't be navigated without a value, so they're listed in
144
+ // \`exclude\` until you add a surface with a concrete param.
133
145
  const ROUTES = discoverNextRoutes();
134
146
 
135
147
  const SURFACES: Surface[] = ROUTES.filter((r) => !r.dynamic).map((r) => ({
@@ -146,9 +158,10 @@ const SURFACES: Surface[] = ROUTES.filter((r) => !r.dynamic).map((r) => ({
146
158
 
147
159
  defineStyleMapCapture({
148
160
  surfaces: SURFACES,
149
- // Coverage guard: every known route must be a captured surface or excluded, or
150
- // the suite fails (it runs without STYLEMAP_DIR — a static check, no browser).
151
- // A new page with no surface can't slip through the gate unseen.
161
+ // Coverage guard: every \`expected\` route must be a captured surface or excluded, or
162
+ // the suite fails (it runs without STYLEMAP_DIR — a static check, no browser). Since
163
+ // both sides come from ROUTES, static routes never trip it; it fires when they
164
+ // diverge — a dynamic route (excluded below), or a route you drop from SURFACES.
152
165
  expected: ROUTES.map((r) => r.key),
153
166
  exclude: Object.fromEntries(
154
167
  ROUTES.filter((r) => r.dynamic).map((r) => [r.key, \`dynamic route (\${r.path}) — add a surface with a concrete param\`]),
@@ -480,9 +493,14 @@ const isNext = routes.length > 0;
480
493
  const SPEC = isNext ? NEXT_SPEC : GENERIC_SPEC;
481
494
 
482
495
  let wroteSomething = false;
496
+ // Every path init created or modified this run, so the summary can name exactly what
497
+ // it touched — and, by omission, what it did NOT (init never writes package.json or a
498
+ // lockfile; that's the package manager's `install`, not this scaffolder).
499
+ const touched = [];
483
500
 
484
501
  const spec = writeFileSafe(specPath, SPEC, { force });
485
502
  if (spec.wrote) {
503
+ touched.push(specPath);
486
504
  console.log(`${spec.exists ? 'overwrote' : 'created'} ${specPath}`);
487
505
  if (isNext) {
488
506
  const dynamic = routes.filter((r) => r.dynamic).length;
@@ -502,6 +520,7 @@ if (spec.wrote) {
502
520
  const configPath = 'playwright.styleproof.config.ts';
503
521
  const config = writeFileSafe(configPath, CONFIG, { force });
504
522
  if (config.wrote) {
523
+ touched.push(configPath);
505
524
  console.log(`${config.exists ? 'overwrote' : 'created'} ${configPath} (dedicated StyleProof capture config)`);
506
525
  wroteSomething = true;
507
526
  } else {
@@ -515,6 +534,7 @@ if (fs.existsSync('playwright.config.ts') || fs.existsSync('playwright.config.js
515
534
 
516
535
  const ignored = ['.styleproof/', 'test-results/', 'playwright-report/'].filter((line) => ensureGitignoreLine(line));
517
536
  if (ignored.length) {
537
+ touched.push('.gitignore');
518
538
  console.log(`updated .gitignore (${ignored.join(', ')})`);
519
539
  wroteSomething = true;
520
540
  }
@@ -522,12 +542,21 @@ if (ignored.length) {
522
542
  // Cache-first CI report — never overwrite an existing workflow.
523
543
  const ci = writeFileSafe(CI_PATH, CI_WORKFLOW);
524
544
  if (ci.wrote) {
545
+ touched.push(CI_PATH);
525
546
  console.log(`created ${CI_PATH} (cache-first StyleProof report)`);
526
547
  wroteSomething = true;
527
548
  } else {
528
549
  console.log(`${CI_PATH} already exists — left untouched`);
529
550
  }
530
551
 
552
+ if (touched.length) {
553
+ // State exactly what init wrote, and — because adopters have blamed init for the
554
+ // `styleproof` entry their package manager's `install` added — say plainly that it
555
+ // did NOT touch package.json or the lockfile. Truth over assumption.
556
+ console.log(`\nstyleproof-init wrote only: ${touched.join(', ')}`);
557
+ console.log('It did NOT modify package.json or your lockfile (that was your package manager’s install).');
558
+ }
559
+
531
560
  console.log('\nHow the gate works — it runs on your first PR with no extra steps:');
532
561
  console.log(' 1. Commit and open a PR. CI captures the base and head surfaces in one pinned');
533
562
  console.log(' environment and posts the StyleProof report — no local step required.');
@@ -22,6 +22,7 @@ import {
22
22
  unknownFlagMessage,
23
23
  } from '../dist/cli-errors.js';
24
24
  import {
25
+ BROWSER_BUILD_SIDECAR,
25
26
  DEFAULT_MAP_DIR,
26
27
  DEFAULT_MAP_LABEL,
27
28
  DEFAULT_MAP_STORE_BRANCH,
@@ -285,6 +286,13 @@ if (restore) {
285
286
  }
286
287
  }
287
288
 
289
+ // Clear any prior run's browser-build sidecar before Playwright runs, so ONLY this
290
+ // run can have written it. The default dir (.styleproof/maps/current) is reused across
291
+ // runs; if this run records no browser version (the capture test not reached, or the
292
+ // handle unavailable), a stale sidecar would otherwise be read into the manifest and
293
+ // stamp a WRONG browser build that the compatibility guard then trusts as a fingerprint.
294
+ fs.rmSync(path.join(targetDir, BROWSER_BUILD_SIDECAR), { force: true });
295
+
288
296
  const command = process.platform === 'win32' ? 'playwright.cmd' : 'playwright';
289
297
  const configArgs =
290
298
  fs.existsSync(STYLEPROOF_PLAYWRIGHT_CONFIG) && !hasPlaywrightConfigArg(playwrightArgs)
@@ -67,9 +67,33 @@ export type AffectedSurfaces = Set<string> | 'all';
67
67
  * unrecognized, is `'all'`.
68
68
  */
69
69
  export declare function classifyStyleChange(file: string, readFile: (p: string) => string): 'scope' | 'all';
70
+ /**
71
+ * Canonicalize a repo-relative path so the same file spells the same regardless
72
+ * of source (a `surfaces` value, a `changedFiles` entry, or a graph edge). Two
73
+ * tools disagree on `./pages/Home.tsx` vs `pages/Home.tsx` vs `pages//Home.tsx`;
74
+ * without one spelling, a reverse-reachability hit can silently miss the surface
75
+ * whose entry key was spelled differently, dropping it from the affected set —
76
+ * an unsound skip. Byte-cheap and fs-free: strip a leading `./`, collapse `//`,
77
+ * and drop `.`/`..` segments as pure string math (no realpath, no resolution). */
78
+ export declare function canonicalPath(p: string): string;
70
79
  /**
71
80
  * Compute the set of declared surfaces a change could have altered, or `'all'`.
72
81
  * See the module doc for the soundness contract. Any not in the returned set are
73
82
  * provably unaffected and may reuse their committed base map.
74
83
  */
75
84
  export declare function affectedSurfaces(input: AffectedSurfacesInput): AffectedSurfaces;
85
+ /**
86
+ * Render an {@link affectedSurfaces} verdict as human-readable lines a pre-push
87
+ * hook (or CI log) can print, so a reviewer can sanity-check the skip list before
88
+ * trusting it. Pure formatter — no I/O, no graph work.
89
+ *
90
+ * @param result the value {@link affectedSurfaces} returned.
91
+ * @param allSurfaces every declared surface key (e.g. `Object.keys(surfaces)`),
92
+ * so the helper can name what is *reused from base* — the ones
93
+ * the verdict skips — not just what re-captures.
94
+ * @param reason optional one-line explanation for an `'all'` verdict (e.g.
95
+ * the classifying file, from {@link classifyStyleChange}). The
96
+ * library doesn't attach a reason to the sentinel, so pass it
97
+ * if the caller knows why; omitted, the `'all'` line stands alone.
98
+ */
99
+ export declare function explainAffectedSurfaces(result: AffectedSurfaces, allSurfaces: Iterable<string>, reason?: string): string;