styleproof 3.19.0 → 3.21.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,137 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.21.0] - 2026-07-07
11
+
12
+ ### Added
13
+
14
+ - **Shared-chrome tier in the report and the `styleproof-diff` CLI.** When one
15
+ change rides the frame every view draws — a persistent nav rail, header, or
16
+ footer that moved on every surface that renders it — it is promoted to a single
17
+ "🧱 Global chrome change" callout at the top, with the detail folded beneath,
18
+ instead of repeating across a long surface list on several entries. The reviewer
19
+ reads "the nav changed everywhere" once. The threshold is **structural, not a
20
+ tunable knob**: an element path is chrome only when it is hosted on more than one
21
+ surface base and changed on _every_ base that hosts it (full coverage of its
22
+ hosting surfaces). A change on merely some surfaces, or a view's own content
23
+ change entangled with the frame change, is never promoted — the view-specific
24
+ detail always stays visible. Purely presentational: grouping keys, findings,
25
+ counts, exit codes, and `--json` are unchanged.
26
+
27
+ ### Changed
28
+
29
+ - **`styleproof-diff` human output now reuses the report's grouping.** One real
30
+ change no longer prints once per surface with its derived-longhand echo: the CLI
31
+ groups surfaces that changed identically into one finding (with the per-surface
32
+ count on the header line), summarises longhands into shorthands, and folds the
33
+ size/position-derived longhands (`transform-origin`, `width`/`height`, cascaded
34
+ ancestor heights…) behind a `(+N derived longhands)` count. A one-view button
35
+ restyle at one width that used to fill dozens of raw lines now reads as a single
36
+ grouped finding. `--json` stays the complete, byte-stable machine contract (every
37
+ surface, every raw longhand); exit codes are unchanged.
38
+
39
+ ### Fixed
40
+
41
+ - **The report's certification block no longer contradicts the diff on inventory
42
+ additions.** For the same capture pair, `styleproof-diff` correctly prints a
43
+ navigable addition (additive, non-gating), but `styleproof-report`'s certification
44
+ block still read `Inventory — ✓ navigable set unchanged` — telling a reviewer the
45
+ navigable set didn't change when it did. The inventory line now echoes additions as
46
+ an informational, still-✓-class clause (`✓ N navigable affordance(s) added: <keys>
47
+ (additions don't gate)`), with the same truncation and key-escaping discipline as
48
+ the removals line. Removals still drive the ⚠/✗ gate semantics; additions are
49
+ appended. The diff and the report can no longer disagree about the navigable delta.
50
+ - **The unit suite no longer flakes on spawned-CLI tests (test-infra only, no
51
+ consumer-visible change).** The package-smoke test packed the live checkout with
52
+ `npm pack`, which runs the `prepare` lifecycle (`tsc`) even under `--ignore-scripts`.
53
+ `tsc` truncates each `dist/*.js` to zero bytes before rewriting it, so a CLI spawned
54
+ by a _different_ test running concurrently under `node --test` could read a
55
+ half-written `dist` module and die with a static ESM link error
56
+ (`does not provide an export named …`) — surfacing as an "impossible" exit code (a
57
+ flag-rejection test that pins exit `2` seeing exit `1`, or a spawned bin printing a
58
+ missing-export `SyntaxError`). The smoke test now packs a staged copy of the package
59
+ whose manifest carries no lifecycle scripts, so pack can never rebuild — the shared
60
+ `dist` is never mutated mid-suite. Same coverage (the packed tarball is still
61
+ installed and its API + every bin's `--help` exercised); the packed artifact is
62
+ identical.
63
+
64
+ ## [3.20.0] - 2026-07-07
65
+
66
+ ### Fixed
67
+
68
+ - **Crawl no longer double-captures `/` and `/index.html` as two surfaces.** On a
69
+ static multi-page site whose nav links the `.html` files, `/` and `/index.html`
70
+ (and `/dir/` vs `/dir/index.html`) are the same route but were captured twice as
71
+ byte-near-identical maps — doubling the capture work and duplicating every finding
72
+ in the diff. The crawl's dedup identity now normalizes a trailing `index.html` to
73
+ its directory path, so they collapse to one surface (first-seen href keeps its
74
+ original navigable form). Only the literal `index.html` filename normalizes — a
75
+ genuine `about.html` stays a distinct surface from `about`.
76
+ - **`styleproof-init` now states exactly which files it wrote — and that it did NOT
77
+ touch `package.json` or your lockfile.** Adopters attributed the `styleproof`
78
+ dependency entry (added by their package manager's `install`) to init; init only
79
+ ever reads `package.json` and writes the spec, the dedicated Playwright config,
80
+ `.gitignore` lines, and the CI workflow. The summary now enumerates those files and
81
+ says plainly that the manifest and lockfile were left untouched.
82
+ - **The no-comparison outcome of `styleproof-diff` / `styleproof-report` names both
83
+ ways forward.** When the no-args (inferred-base) path can restore no base map — no
84
+ map-store remote, no cached bundle — nothing is compared. That already exits `2`
85
+ (never a soft `0` a newcomer could read as "certified clean"); the message now says
86
+ "nothing was compared" and names the two working alternatives: run in CI (or a repo
87
+ with the `origin` remote) where the base is restorable, or the two-directory form
88
+ `styleproof-diff <beforeDir> <afterDir>`. A regression test pins the exit-2 contract
89
+ in a remote-less repo for both commands.
90
+ - **README: the Next.js coverage guard is described accurately.** The docs conflated
91
+ two behaviors. With the auto-wired spec, `surfaces` and `expected` both derive from
92
+ the same `discoverNextRoutes()` call, so a new static route is captured and expected
93
+ together — **auto-covered, never a guard failure**. The guard **fails** only on
94
+ genuine divergence (a dynamic route, a hand-maintained registry, or a route dropped
95
+ from `surfaces` while still `expected`). Rewrote the overclaiming passages and the
96
+ generated-spec comments to state both behaviors.
97
+ - **Layout-equivalent margin suppression no longer drops a real one-sided margin
98
+ change.** `dropLayoutEquivalentMarginProps` suppressed any horizontal
99
+ `margin-left/right/inline-start/inline-end` change whenever the element's rect
100
+ was unchanged — reasoning that a margin that doesn't move the box is cosmetic
101
+ drift. But a one-sided change (e.g. `margin-left: 0 → 40px` with `margin-right`
102
+ untouched) that leaves the rect identical only stayed put because _something
103
+ else compensated_; that is a genuine restyle, and it was silently dropped. The
104
+ suppression now fires only when there is no **demonstrable px imbalance**
105
+ between a side and its opposite — balanced drift (both sides move together) and
106
+ forced-state deltas are still suppressed exactly as before, but a one-sided
107
+ real change surfaces. A residual, consciously-deferred corner remains (a
108
+ perfectly _balanced_ change held in place by external compensation), documented
109
+ inline; closing it needs cross-element layout reasoning.
110
+ - **`styleproof-init` no longer imports the whole library barrel (fixes a CI
111
+ flake).** The scaffolder only needs `discoverNextRoutes`, but it imported it
112
+ from `dist/index.js` — dragging capture, the crawler, the report renderer, and
113
+ six Playwright-importing modules into a tool that writes files and captures
114
+ nothing. Loading that oversized module graph concurrently (init's own suite
115
+ spawns the CLI many times, alongside the rest of `node --test`) is what made
116
+ init's tests flake in CI, red-flagging releases with no code cause. It now
117
+ imports from the `dist/routes.js` leaf (`fs` + `path` only): init's transitive
118
+ module graph drops from 21 dist modules to 1, with zero Playwright modules on
119
+ its load path. Behaviour is unchanged; a regression test pins the leaf import.
120
+ - **Popup capture: verified reset + identity-bound triggers (no leaked-overlay
121
+ contamination, no wrong-trigger keying).** On a surface whose `go()` doesn't
122
+ navigate (SPA variants), the between-popups reset was Escape-only and assumed:
123
+ a toast or `[role="status"]` overlay Escape can't dismiss leaked into the next
124
+ popup's capture, and each reopen re-enumerated triggers positionally, so a
125
+ shifted trigger set (e.g. a click that adds a button) could key a popup under a
126
+ different trigger than the one originally enumerated. Triggers are now re-bound
127
+ by the DOM identity recorded at first enumeration, and the reset is verified
128
+ against the surface's pristine overlay set; a candidate that can't be opened
129
+ safely is skipped loudly (a `styleproof:` warning naming the popup and the
130
+ leaked overlay or missing trigger) instead of being captured contaminated,
131
+ mis-keyed, or — with self-check on — saved unproven. That identity is the
132
+ trigger's DOM path **and** its accessible label, not the path alone: for an
133
+ id-less trigger the path ends in `:nth-of-type`, which is still position within
134
+ a parent, so a same-tag same-parent sibling injected earlier in DOM order
135
+ between enumeration and reopen would slide the recorded path onto a different
136
+ trigger and key its popup under the wrong one — silently. Requiring the label
137
+ (the same aria-label/name/text/title accessible name the crawler reads) to match
138
+ too turns that mismatch into the same loud skip. Navigating surfaces are
139
+ unaffected.
140
+
10
141
  ## [3.19.0] - 2026-07-06
11
142
 
12
143
  ### 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
@@ -851,7 +866,7 @@ Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<styl
851
866
 
852
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`.
853
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.
854
- - `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`. The human output **groups the same way the report does**: surfaces that changed identically collapse into one finding (with the per-surface count on its header), longhands fold into shorthands, and size/position-derived longhands fold behind a `(+N derived longhands)` count — so one real change reads as one entry, not dozens of raw lines. A change that rode the shared frame every view draws (a persistent nav/header/footer) is promoted to a "🧱 Global chrome change" callout up top. `--json` stays the complete, unchanged machine contract — every surface and every raw longhand — regardless of the human grouping.
855
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).
856
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.
857
872
 
@@ -25,6 +25,20 @@
25
25
  import fs from 'node:fs';
26
26
  import path from 'node:path';
27
27
  import { diffStyleMapDirs, findingLabel } from '../dist/diff.js';
28
+ // The shared grouping brain (leaf — no Playwright-adjacent imports) that already
29
+ // dedupes the report: group identical change-sets across surfaces and fold derived
30
+ // longhands. Used for the HUMAN output only; --json stays the raw machine contract.
31
+ import {
32
+ cleanFindings,
33
+ groupBySignature,
34
+ groupByPath,
35
+ groupTitle,
36
+ summarizeProps,
37
+ derivedLonghandCount,
38
+ formatSurfaceList,
39
+ classifyChrome,
40
+ surfaceBase,
41
+ } from '../dist/change-groups.js';
28
42
  import {
29
43
  DEFAULT_MAP_STORE_BRANCH,
30
44
  DEFAULT_REMOTE,
@@ -39,7 +53,7 @@ import {
39
53
  showHelpAndExit,
40
54
  unknownFlagMessage,
41
55
  } from '../dist/cli-errors.js';
42
- import { readInventories } from '../dist/capture.js';
56
+ import { readInventories, surfaceElementPaths } from '../dist/capture.js';
43
57
  import { auditRunInventory, readAckFile } from '../dist/inventory.js';
44
58
  import { auditCoverage, auditDeterminism, COVERAGE_LEDGER } from '../dist/coverage.js';
45
59
  import { isMapFile } from '../dist/map-store.js';
@@ -254,6 +268,7 @@ let result;
254
268
  let inventoryAudit = null;
255
269
  let coverageVerdict = null;
256
270
  let determinismVerdict = null;
271
+ let surfacePaths = new Map();
257
272
  try {
258
273
  assertCompatibleMapDirs(dirA, dirB);
259
274
  result = diffStyleMapDirs(dirA, dirB);
@@ -264,6 +279,9 @@ try {
264
279
  const headLedger = readLedger(dirB);
265
280
  coverageVerdict = auditCoverage(capturedSurfaceKeys(dirB), headLedger);
266
281
  determinismVerdict = auditDeterminism(readLedger(dirA), headLedger);
282
+ // Element-path sets per surface, for the shared-chrome tier — same "read while
283
+ // the dirs exist" rule as the ledgers above.
284
+ surfacePaths = surfaceElementPaths(dirA, dirB);
267
285
  } catch (e) {
268
286
  console.error(e.message);
269
287
  process.exit(2);
@@ -272,33 +290,90 @@ try {
272
290
  }
273
291
  const { surfaces, counts, compared } = result;
274
292
 
275
- for (const sd of surfaces) {
276
- if (sd.missing) {
277
- const side = sd.missing === 'before' ? 'after' : 'before';
278
- console.log(`\n${sd.surface}: new surface captured only in the ${side} set, no baseline to compare`);
279
- continue;
280
- }
293
+ // ── grouped human output ─────────────────────────────────────────────────────
294
+ // Reuse the report's dedup so one real change doesn't print once per surface with
295
+ // its derived-longhand echo: group surfaces that changed identically, fold the
296
+ // size/position-derived longhands behind a count, and keep the per-surface tally
297
+ // in each group's header line. --json below is untouched (the raw machine feed).
298
+
299
+ // One finding's lines: a heading, then its summarised property deltas (the same
300
+ // dedupe the report shows). Returns [] for a DOM finding (handled separately) or a
301
+ // finding whose props all summarised away.
302
+ function findingLines(f) {
303
+ if (f.kind === 'dom') return [];
304
+ const rows = summarizeProps(f.props);
305
+ if (!rows.length) return [];
306
+ const head =
307
+ f.kind === 'state'
308
+ ? ` [:${f.state}] ${findingLabel(f.path, f.cls)}${f.sub !== f.path ? ` ⇒ ${f.sub}` : ''}`
309
+ : ` ${findingLabel(f.path, f.cls)}${f.pseudo || ''}`;
310
+ return [head, ...rows.map((p) => ` ${p.prop}: ${p.before} → ${p.after}`)];
311
+ }
312
+
313
+ // A DOM finding's one-line heading (added/removed/retagged).
314
+ function domLine(dom) {
315
+ return dom.change === 'retagged'
316
+ ? ` DOM retagged: ${dom.path} ${dom.detail ?? ''}`
317
+ : ` DOM ${dom.change}: ${findingLabel(dom.path, dom.cls)}`;
318
+ }
319
+
320
+ // The element lines for one change group, from its representative's cleaned
321
+ // findings: one heading per element, then its summarised property deltas.
322
+ function elementLines(findings) {
281
323
  const lines = [];
282
- for (const f of sd.findings) {
283
- if (f.kind === 'dom') {
284
- lines.push(
285
- f.change === 'retagged'
286
- ? ` DOM retagged: ${f.path} ${f.detail}`
287
- : ` DOM ${f.change}: ${findingLabel(f.path, f.cls)}`,
288
- );
289
- } else if (f.kind === 'style') {
290
- lines.push(` ${findingLabel(f.path, f.cls)}${f.pseudo || ''}`);
291
- for (const p of f.props) lines.push(` ${p.prop}: ${p.before} → ${p.after}`);
292
- } else {
293
- lines.push(` [:${f.state}] ${findingLabel(f.path, f.cls)}${f.sub !== f.path ? ` ⇒ ${f.sub}` : ''}`);
294
- for (const p of f.props) lines.push(` ${p.prop}: ${p.before} → ${p.after}`);
295
- }
324
+ for (const group of groupByPath(findings)) {
325
+ const dom = group.find((f) => f.kind === 'dom');
326
+ if (dom) lines.push(domLine(dom));
327
+ for (const f of group) lines.push(...findingLines(f));
296
328
  }
297
- console.log(`\n${sd.surface}: ${lines.filter((l) => !l.startsWith(' ')).length} element(s) differ`);
329
+ return lines;
330
+ }
331
+
332
+ // New (one-sided) surfaces keep their own line, unchanged.
333
+ for (const sd of surfaces) {
334
+ if (!sd.missing) continue;
335
+ const side = sd.missing === 'before' ? 'after' : 'before';
336
+ console.log(`\n${sd.surface}: new surface — captured only in the ${side} set, no baseline to compare`);
337
+ }
338
+
339
+ // Group the changed surfaces the way the report does, so an identical change
340
+ // across N surfaces prints once (with the count), not N times.
341
+ const preparedForGrouping = surfaces
342
+ .filter((sd) => !sd.missing)
343
+ // Carry the RAW findings too, so we can report how many derived longhands the
344
+ // grouped view folded (the cleaned findings have them already removed).
345
+ .map((sd) => ({ surface: sd.surface, findings: cleanFindings(sd.findings), raw: sd.findings }))
346
+ .filter((p) => p.findings.length > 0);
347
+
348
+ function printGroup(cg) {
349
+ const lines = elementLines(cg.findings);
350
+ const derived = derivedLonghandCount(cg.rep.raw);
351
+ const foldNote = derived > 0 ? ` (+${derived} derived longhand${derived === 1 ? '' : 's'})` : '';
352
+ const others = cg.surfaces.length - 1;
353
+ const scope =
354
+ others > 0
355
+ ? `${cg.rep.surface} (+${others} more surface${others === 1 ? '' : 's'}: ${formatSurfaceList(cg.surfaces)})`
356
+ : cg.rep.surface;
357
+ console.log(`\n${scope}: ${groupTitle(cg.findings)}${foldNote}`);
298
358
  for (const line of lines.slice(0, MAX)) console.log(line);
299
359
  if (lines.length > MAX) console.log(` ... and ${lines.length - MAX} more lines (re-run with --max ${lines.length})`);
300
360
  }
301
361
 
362
+ // Shared-chrome tier: a change that rode the frame every view draws (nav/header)
363
+ // gets one banner up top, then its detail — so the reviewer reads "the nav changed
364
+ // everywhere" once, not once per surface entry. Presentational only.
365
+ const grouped = groupBySignature(preparedForGrouping);
366
+ const { chrome, rest } = classifyChrome(grouped, surfacePaths);
367
+ if (chrome.length) {
368
+ // Base count from the pre-cleanup surface set (dirB may be deleted by now).
369
+ const bases = new Set([...surfacePaths.keys()].map(surfaceBase)).size;
370
+ console.log(
371
+ `\n🧱 Global chrome change(s) — across all ${bases} surface(s): ${chrome.length} change(s) rode the shared frame every view draws (a persistent nav, header, or footer).`,
372
+ );
373
+ for (const cg of chrome) printGroup(cg);
374
+ }
375
+ for (const cg of rest) printGroup(cg);
376
+
302
377
  const invRemovals = printInventoryAudit(inventoryAudit);
303
378
  const coverageFails = printCoverageVerdict(coverageVerdict);
304
379
  const determinismFails = printDeterminismVerdict(determinismVerdict);
@@ -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.');
package/dist/capture.d.ts CHANGED
@@ -261,4 +261,12 @@ export declare function loadStyleMap(filePath: string): StyleMap;
261
261
  export declare function readInventories(dir: string): Array<{
262
262
  inventory?: NavigableItem[];
263
263
  }>;
264
+ /**
265
+ * Each captured surface key → the set of element paths it renders, unioned across
266
+ * the given dirs (so an element present on only one side still "belongs" to the
267
+ * surface). Feeds the shared-chrome tier, which needs to know, per surface, which
268
+ * paths exist to tell a frame-wide change from one view's content. Shared by the
269
+ * report and the diff CLI (both already read maps this way).
270
+ */
271
+ export declare function surfaceElementPaths(...dirs: string[]): Map<string, Set<string>>;
264
272
  export {};
package/dist/capture.js CHANGED
@@ -805,3 +805,25 @@ export function readInventories(dir) {
805
805
  .map((f) => loadStyleMap(path.join(dir, f)))
806
806
  .map((m) => (m.inventory ? { inventory: m.inventory } : {}));
807
807
  }
808
+ /**
809
+ * Each captured surface key → the set of element paths it renders, unioned across
810
+ * the given dirs (so an element present on only one side still "belongs" to the
811
+ * surface). Feeds the shared-chrome tier, which needs to know, per surface, which
812
+ * paths exist to tell a frame-wide change from one view's content. Shared by the
813
+ * report and the diff CLI (both already read maps this way).
814
+ */
815
+ export function surfaceElementPaths(...dirs) {
816
+ const bySurface = new Map();
817
+ for (const dir of dirs) {
818
+ if (!fs.existsSync(dir))
819
+ continue;
820
+ for (const file of fs.readdirSync(dir).filter(isMapFile)) {
821
+ const surface = file.replace(/\.json(\.gz)?$/, '');
822
+ const set = bySurface.get(surface) ?? new Set();
823
+ for (const p of Object.keys(loadStyleMap(path.join(dir, file)).elements))
824
+ set.add(p);
825
+ bySurface.set(surface, set);
826
+ }
827
+ }
828
+ return bySurface;
829
+ }