styleproof 3.9.0 → 3.11.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,38 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.11.0] - 2026-07-06
11
+
12
+ ### Added
13
+
14
+ - **Report leads with the certification gates.** Source-of-truth step 3: the reviewer-
15
+ facing `report.md` now opens with a **Certification** block — the three source-of-truth
16
+ verdicts, so "is this green trustworthy?" is answered before the pixel details:
17
+ - **Coverage** — ✓ complete / ✗ INCOMPLETE (names the uncaptured surfaces) / ⚠ not asserted;
18
+ - **Determinism** — ✓ proven / ✗ NOT proven / ⚠ unknown;
19
+ - **Inventory** — ✓ navigable set unchanged / ⚠ N affordance(s) removed (names them) /
20
+ ✓ N removal(s), all acknowledged.
21
+
22
+ These verdicts were previously visible only in the `styleproof-diff` CI logs; now
23
+ they're in the artifact a reviewer actually reads. The block is omitted for an old
24
+ bundle that carries no certification metadata, so nothing changes for legacy captures.
25
+
26
+ ## [3.10.0] - 2026-07-06
27
+
28
+ ### Added
29
+
30
+ - **Determinism provenance — a green needs a proven-deterministic capture.** Source-of-
31
+ truth step 2: a clean diff of two _nondeterministic_ captures is meaningless (they
32
+ might just happen to match). The capture now records its determinism basis in the
33
+ ledger — `self-checked` (captured twice, styles matched — a drift would have failed the
34
+ capture), `replayed` (rendered against a recorded HAR, deterministic by construction),
35
+ or `unproven` (neither) — and `styleproof-diff`:
36
+ - **blocks (exit 1) when either side is `unproven`**, even on an empty style diff;
37
+ - prints `✓ determinism proven — base X, head Y`, `✗ determinism NOT proven …`, or
38
+ `⚠ determinism basis unknown` (an older bundle with no field — degrades, never a
39
+ false red). The record-then-replay flow (base `self-checked`, head `replayed`) is
40
+ proven, as it should be.
41
+
10
42
  ## [3.9.0] - 2026-07-05
11
43
 
12
44
  ### Added
package/README.md CHANGED
@@ -30,6 +30,7 @@ report. Intentional visual changes get approved; unexpected ones block or fail.
30
30
  - [Newly-added elements show their full style](#newly-added-elements-show-their-full-style)
31
31
  - [Reference](#reference)
32
32
  - [Blocking without branch protection](#blocking-without-branch-protection)
33
+ - [Contributing](#contributing)
33
34
  - [License](#license)
34
35
 
35
36
  ## Why
@@ -759,6 +760,17 @@ Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<styl
759
760
 
760
761
  A programmatic API is also exported — `captureStyleMap`, `diffStyleMaps`, `generateStyleMapReport`, and the breakpoint helpers `detectViewportWidths` / `widthsFromBoundaries`, among others. For the capture internals, the approve-workflow trust model, and how to contribute, see [CONTRIBUTING](https://github.com/BenSheridanEdwards/StyleProof/blob/main/CONTRIBUTING.md) and the [`example/`](https://github.com/BenSheridanEdwards/StyleProof/tree/main/example) workflows.
761
762
 
763
+ ## Contributing
764
+
765
+ See [CONTRIBUTING](https://github.com/BenSheridanEdwards/StyleProof/blob/main/CONTRIBUTING.md)
766
+ for the dev loop, and [AGENTS.md](https://github.com/BenSheridanEdwards/StyleProof/blob/main/AGENTS.md)
767
+ (the same file as `CLAUDE.md`) for the operating rules and agent tooling. The repo
768
+ is wired for Claude Code with **Ponytail** (default lazy-coding mode), **GitNexus**
769
+ (code-intelligence graph — MCP server in [`.mcp.json`](.mcp.json), skills in
770
+ `.claude/skills/gitnexus/`), and **Graphify** (`/graphify` knowledge graph). The
771
+ GitNexus index (`.gitnexus/`) and Graphify output (`graphify-out/`) are gitignored;
772
+ build the index with `npx gitnexus analyze`.
773
+
762
774
  ## License
763
775
 
764
776
  MIT © Ben Sheridan-Edwards
@@ -33,15 +33,15 @@ import {
33
33
  resolveCachedCaptureDirs,
34
34
  } from '../dist/map-store.js';
35
35
  import {
36
- cliErrorMessage,
36
+ cachedMapsUnavailableMessage,
37
37
  isHelpArg,
38
38
  missingManualCaptureMessage,
39
39
  showHelpAndExit,
40
40
  unknownFlagMessage,
41
41
  } from '../dist/cli-errors.js';
42
- import { loadStyleMap } from '../dist/capture.js';
43
- import { auditRunInventory } from '../dist/inventory.js';
44
- import { auditCoverage, COVERAGE_LEDGER } from '../dist/coverage.js';
42
+ import { readInventories } from '../dist/capture.js';
43
+ import { auditRunInventory, readAckFile } from '../dist/inventory.js';
44
+ import { auditCoverage, auditDeterminism, COVERAGE_LEDGER } from '../dist/coverage.js';
45
45
  import { isMapFile } from '../dist/map-store.js';
46
46
 
47
47
  const COMMAND = path.basename(process.argv[1] ?? 'styleproof-diff').replace(/\.mjs$/, '');
@@ -52,25 +52,13 @@ const COMMAND = path.basename(process.argv[1] ?? 'styleproof-diff').replace(/\.m
52
52
  // offered and head no longer does BLOCKS unless acknowledged. Inert when no map
53
53
  // carries inventory, so every existing capture behaves exactly as before.
54
54
 
55
- // Mirror of indexDir() in diff.ts — the capture-dir file filter, kept local so the
56
- // bin needn't reach into diff internals for one small read.
57
- function dirInventories(dir) {
58
- return fs
59
- .readdirSync(dir)
60
- .filter(isMapFile)
61
- .map((f) => loadStyleMap(path.join(dir, f)))
62
- .map((m) => (m.inventory ? { inventory: m.inventory } : {}));
63
- }
64
-
65
55
  // `key -> reason` acknowledged removals. Optional file; absent → none. Malformed
66
56
  // JSON fails loud (exit 2) rather than silently un-acknowledging a real removal.
67
57
  function loadAllowRemoved() {
68
- const p = path.resolve(process.env.STYLEPROOF_INVENTORY ?? 'styleproof.inventory.json');
69
- if (!fs.existsSync(p)) return {};
70
58
  try {
71
- return JSON.parse(fs.readFileSync(p, 'utf8'));
59
+ return readAckFile();
72
60
  } catch (e) {
73
- console.error(`${COMMAND}: ${p} is not valid JSON — ${e.message}`);
61
+ console.error(`${COMMAND}: ${e.message}`);
74
62
  process.exit(2);
75
63
  }
76
64
  }
@@ -78,8 +66,8 @@ function loadAllowRemoved() {
78
66
  // Read both sides' inventory and audit removals. MUST run before any cached-map
79
67
  // cleanup deletes the restored dirs. Returns null when no capture carries inventory.
80
68
  function readInventoryAudit(dirA, dirB) {
81
- const baseInv = dirInventories(dirA);
82
- const headInv = dirInventories(dirB);
69
+ const baseInv = readInventories(dirA);
70
+ const headInv = readInventories(dirB);
83
71
  if (![...baseInv, ...headInv].some((m) => m.inventory?.length)) return null;
84
72
  const allowed = loadAllowRemoved();
85
73
  return { allowed, ...auditRunInventory(baseInv, headInv, allowed) };
@@ -129,17 +117,14 @@ function capturedSurfaceKeys(dir) {
129
117
  ];
130
118
  }
131
119
 
132
- function readCoverageVerdict(dir) {
133
- let ledger = null;
120
+ function readLedger(dir) {
134
121
  const p = path.join(dir, COVERAGE_LEDGER);
135
- if (fs.existsSync(p)) {
136
- try {
137
- ledger = JSON.parse(fs.readFileSync(p, 'utf8'));
138
- } catch {
139
- /* a corrupt ledger reads as no registry → "not asserted", never a false green */
140
- }
122
+ if (!fs.existsSync(p)) return null;
123
+ try {
124
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
125
+ } catch {
126
+ return null; // a corrupt ledger reads as no registry → "not asserted", never a false green
141
127
  }
142
- return auditCoverage(capturedSurfaceKeys(dir), ledger);
143
128
  }
144
129
 
145
130
  // Print the completeness verdict; return true if it BLOCKS (a registered surface is missing).
@@ -165,6 +150,23 @@ function printCoverageVerdict(v) {
165
150
  return true;
166
151
  }
167
152
 
153
+ // Print the determinism verdict; return true if it BLOCKS (a side's capture was unproven).
154
+ function printDeterminismVerdict(v) {
155
+ if (v.status === 'proven') {
156
+ console.log(`\n✓ determinism proven — base ${v.base}, head ${v.head}`);
157
+ return false;
158
+ }
159
+ if (v.status === 'unknown') {
160
+ console.log('\n⚠ determinism basis unknown — a capture predates the determinism ledger; recapture to certify it.');
161
+ return false;
162
+ }
163
+ console.log(
164
+ `\n✗ determinism NOT proven — base ${v.base}, head ${v.head}. An unproven capture can drift, so a clean\n` +
165
+ ' diff might be two matching NONDETERMINISTIC reads. Enable selfCheck (default) or replay a recorded HAR.',
166
+ );
167
+ return true;
168
+ }
169
+
168
170
  const HELP = `${COMMAND} — certify a CSS refactor by diffing two computed-style map captures
169
171
 
170
172
  usage: ${COMMAND} [baseRef] [options]
@@ -231,13 +233,7 @@ if (args.length <= 1) {
231
233
  dirA = cacheCapture.beforeDir;
232
234
  dirB = cacheCapture.afterDir;
233
235
  } catch (e) {
234
- console.error(
235
- [
236
- `${COMMAND}: cached maps are not available for this comparison`,
237
- cliErrorMessage(e),
238
- 'Next: run styleproof-map on the base and head commits to upload maps, or let CI recapture both sides.',
239
- ].join('\n'),
240
- );
236
+ console.error(cachedMapsUnavailableMessage(COMMAND, 'comparison', e));
241
237
  process.exit(2);
242
238
  }
243
239
  } else {
@@ -257,14 +253,17 @@ if (args.length <= 1) {
257
253
  let result;
258
254
  let inventoryAudit = null;
259
255
  let coverageVerdict = null;
256
+ let determinismVerdict = null;
260
257
  try {
261
258
  assertCompatibleMapDirs(dirA, dirB);
262
259
  result = diffStyleMapDirs(dirA, dirB);
263
- // Read inventory + coverage here, while the (possibly cached/restored) dirs still
264
- // exist — the finally below deletes them in cached-map mode. Coverage is the HEAD
265
- // bundle's completeness basis.
260
+ // Read inventory + the certification ledgers here, while the (possibly cached/restored)
261
+ // dirs still exist — the finally below deletes them in cached-map mode. Coverage is the
262
+ // HEAD bundle's completeness basis; determinism needs both sides.
266
263
  inventoryAudit = readInventoryAudit(dirA, dirB);
267
- coverageVerdict = readCoverageVerdict(dirB);
264
+ const headLedger = readLedger(dirB);
265
+ coverageVerdict = auditCoverage(capturedSurfaceKeys(dirB), headLedger);
266
+ determinismVerdict = auditDeterminism(readLedger(dirA), headLedger);
268
267
  } catch (e) {
269
268
  console.error(e.message);
270
269
  process.exit(2);
@@ -302,9 +301,13 @@ for (const sd of surfaces) {
302
301
 
303
302
  const invRemovals = printInventoryAudit(inventoryAudit);
304
303
  const coverageFails = printCoverageVerdict(coverageVerdict);
304
+ const determinismFails = printDeterminismVerdict(determinismVerdict);
305
305
 
306
306
  if (jsonOut)
307
- fs.writeFileSync(jsonOut, JSON.stringify({ counts, surfaces, compared, coverage: coverageVerdict }, null, 2));
307
+ fs.writeFileSync(
308
+ jsonOut,
309
+ JSON.stringify({ counts, surfaces, compared, coverage: coverageVerdict, determinism: determinismVerdict }, null, 2),
310
+ );
308
311
 
309
312
  const total = counts.dom + counts.style + counts.state;
310
313
  const newSurfaces = surfaces.filter((s) => s.missing).length;
@@ -313,14 +316,16 @@ const surfaceCount = surfaces.length;
313
316
  const newNote = newSurfaces ? ` (+${newSurfaces} new surface(s) with no baseline)` : '';
314
317
  const invNote = invRemovals ? ` + ${invRemovals} unacknowledged inventory removal(s)` : '';
315
318
  const covNote = coverageFails ? ` + ${coverageVerdict.uncovered.length} uncaptured registered surface(s)` : '';
316
- const clean = total === 0 && invRemovals === 0 && !coverageFails;
319
+ const detNote = determinismFails ? ' + determinism unproven' : '';
320
+ const clean = total === 0 && invRemovals === 0 && !coverageFails && !determinismFails;
317
321
  console.log(
318
322
  clean
319
323
  ? newSurfaces === 0
320
324
  ? `\n✓ 0 changed surfaces across ${compared} captured surface(s): every computed style, pseudo-element, and hover/focus/active state matches`
321
325
  : `\nℹ ${newSurfaces} new surface(s) captured with no baseline to compare — review before baselining`
322
- : `\n✗ ${counts.dom} DOM change(s), ${counts.style} computed-style difference(s), ${counts.state} state-delta difference(s) across ${surfaceCount} surfaces${newNote}${invNote}${covNote}`,
326
+ : `\n✗ ${counts.dom} DOM change(s), ${counts.style} computed-style difference(s), ${counts.state} state-delta difference(s) across ${surfaceCount} surfaces${newNote}${invNote}${covNote}${detNote}`,
323
327
  );
324
- // 0 = identical, 1 = reviewable differences (incl. unacknowledged inventory removals or
325
- // an incomplete coverage registry), 3 = only new surfaces (no baseline). 2 = usage/capture error.
326
- process.exit(total > 0 || invRemovals > 0 || coverageFails ? 1 : newSurfaces > 0 ? 3 : 0);
328
+ // 0 = identical, 1 = reviewable differences (incl. unacknowledged inventory removals, an
329
+ // incomplete coverage registry, or an unproven-determinism capture), 3 = only new surfaces
330
+ // (no baseline). 2 = usage/capture error.
331
+ process.exit(total > 0 || invRemovals > 0 || coverageFails || determinismFails ? 1 : newSurfaces > 0 ? 3 : 0);
@@ -12,7 +12,7 @@
12
12
  * Exit code 0 = no changes, 1 = report generated, 2 = usage error.
13
13
  */
14
14
  import { generateStyleMapReport } from '../dist/report.js';
15
- import { cliErrorMessage, isHelpArg, showHelpAndExit, unknownFlagMessage } from '../dist/cli-errors.js';
15
+ import { cachedMapsUnavailableMessage, isHelpArg, showHelpAndExit, unknownFlagMessage } from '../dist/cli-errors.js';
16
16
  import {
17
17
  DEFAULT_MAP_STORE_BRANCH,
18
18
  DEFAULT_REMOTE,
@@ -116,13 +116,7 @@ if (args.length <= 1) {
116
116
  beforeDir = cacheCapture.beforeDir;
117
117
  afterDir = cacheCapture.afterDir;
118
118
  } catch (e) {
119
- console.error(
120
- [
121
- `${COMMAND}: cached maps are not available for this report`,
122
- cliErrorMessage(e),
123
- 'Next: run styleproof-map on the base and head commits to upload maps, or let CI recapture both sides.',
124
- ].join('\n'),
125
- );
119
+ console.error(cachedMapsUnavailableMessage(COMMAND, 'report', e));
126
120
  process.exit(2);
127
121
  }
128
122
  } else {
package/dist/capture.d.ts CHANGED
@@ -253,4 +253,12 @@ export declare function captureStyleMap(page: Page, options?: CaptureOptions): P
253
253
  export declare function saveStyleMap(filePath: string, map: StyleMap): void;
254
254
  /** Read a style map written by {@link saveStyleMap} (`.json` or `.json.gz`). */
255
255
  export declare function loadStyleMap(filePath: string): StyleMap;
256
+ /**
257
+ * Read every surface map's navigable inventory from a capture dir, in the shape the
258
+ * inventory audit consumes. One home for "read the inventories out of a dir", shared
259
+ * by the diff CLI and the report (was duplicated in both).
260
+ */
261
+ export declare function readInventories(dir: string): Array<{
262
+ inventory?: NavigableItem[];
263
+ }>;
256
264
  export {};
package/dist/capture.js CHANGED
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { gzipSync, gunzipSync } from 'node:zlib';
4
4
  import { classifyInventory, collectNavAffordances } from './inventory.js';
5
+ import { isMapFile } from './map-store.js';
5
6
  const INTERACTIVE = 'a, button, input, textarea, select, summary, [role="button"], [tabindex]';
6
7
  // Freeze motion so every captured value is a settled end state, not a frame
7
8
  // of an animation or a mid-flight transition after a forced :hover.
@@ -354,8 +355,9 @@ function detectOverlayCandidates({ ignore }) {
354
355
  });
355
356
  }
356
357
  /** Full (unpruned) computed styles for an element and its descendants, pseudo-elements included. */
357
- // Serialized into the browser by page.evaluate; cannot call module helpers.
358
- // fallow-ignore-next-line complexity -- pre-existing (cog 16); in-page fn can't call module helpers, so it can't be split. Exposed here only because the inventory feature edits this file. Refactor separately.
358
+ // Serialized into the browser by page.evaluate; cannot call module helpers, so this
359
+ // in-page fn (cog 16) can't be split into smaller ones. Pre-existing; refactor separately.
360
+ // fallow-ignore-next-line complexity
359
361
  function snapSubtree({ selector, index }) {
360
362
  const el = document.querySelectorAll(selector)[index];
361
363
  const pathOf = (n) => {
@@ -777,3 +779,15 @@ export function loadStyleMap(filePath) {
777
779
  'Re-capture it — a partial write or interrupted upload produces an unreadable .gz.', { cause: e });
778
780
  }
779
781
  }
782
+ /**
783
+ * Read every surface map's navigable inventory from a capture dir, in the shape the
784
+ * inventory audit consumes. One home for "read the inventories out of a dir", shared
785
+ * by the diff CLI and the report (was duplicated in both).
786
+ */
787
+ export function readInventories(dir) {
788
+ return fs
789
+ .readdirSync(dir)
790
+ .filter(isMapFile)
791
+ .map((f) => loadStyleMap(path.join(dir, f)))
792
+ .map((m) => (m.inventory ? { inventory: m.inventory } : {}));
793
+ }
@@ -1,6 +1,11 @@
1
1
  export declare function isHelpArg(arg: string | undefined): boolean;
2
2
  export declare function showHelpAndExit(help: string): never;
3
3
  export declare function cliErrorMessage(error: unknown): string;
4
+ /**
5
+ * The "cached maps couldn't be restored" guidance, shared by styleproof-diff and
6
+ * styleproof-report (each names its own `purpose` — "comparison" / "report").
7
+ */
8
+ export declare function cachedMapsUnavailableMessage(command: string, purpose: string, error: unknown): string;
4
9
  export declare function unknownFlagMessage(command: string, flag: string): string;
5
10
  export declare function missingSpecMessage(spec: string): string;
6
11
  export declare function playwrightMissingMessage(message: string): string;
@@ -8,6 +8,17 @@ export function showHelpAndExit(help) {
8
8
  export function cliErrorMessage(error) {
9
9
  return error instanceof Error ? error.message : String(error);
10
10
  }
11
+ /**
12
+ * The "cached maps couldn't be restored" guidance, shared by styleproof-diff and
13
+ * styleproof-report (each names its own `purpose` — "comparison" / "report").
14
+ */
15
+ export function cachedMapsUnavailableMessage(command, purpose, error) {
16
+ return [
17
+ `${command}: cached maps are not available for this ${purpose}`,
18
+ cliErrorMessage(error),
19
+ 'Next: run styleproof-map on the base and head commits to upload maps, or let CI recapture both sides.',
20
+ ].join('\n');
21
+ }
11
22
  export function unknownFlagMessage(command, flag) {
12
23
  return `${command}: unknown flag: ${flag}\nNext: run ${command} --help to see supported options.`;
13
24
  }
@@ -36,13 +36,32 @@ export type CoverageGaps = {
36
36
  export declare function coverageGaps(capturedKeys: Iterable<string>, expected: Iterable<string>, exclude?: Record<string, string>): CoverageGaps;
37
37
  /** Bundled next to the maps, so the completeness basis travels with the capture. */
38
38
  export declare const COVERAGE_LEDGER = "styleproof-coverage.json";
39
+ /**
40
+ * How a capture's determinism was established — the second half of a trustworthy green.
41
+ * `self-checked`: captured twice and the computed styles matched (a drift would have
42
+ * failed the capture). `replayed`: rendered against a recorded HAR, so deterministic by
43
+ * construction. `unproven`: neither — the styles could have drifted and no one checked.
44
+ */
45
+ export type DeterminismBasis = 'self-checked' | 'replayed' | 'unproven';
39
46
  export type CoverageLedger = {
40
47
  version: 1;
41
48
  /** The declared surface registry, or null when the spec asserted none. */
42
49
  expected: string[] | null;
43
50
  /** Reviewed opt-outs (`key → reason`). */
44
51
  exclude: Record<string, string>;
52
+ /** How this capture's determinism was established (3.10.0). Absent on older bundles. */
53
+ determinism?: DeterminismBasis;
54
+ };
55
+ export type DeterminismVerdict = {
56
+ /** `proven` — both sides self-checked or replayed; `unproven` — a side was neither, so
57
+ * a clean diff might just be two matching NONDETERMINISTIC captures; `unknown` — an
58
+ * older bundle with no determinism field (degrade, don't block). */
59
+ status: 'proven' | 'unproven' | 'unknown';
60
+ base: DeterminismBasis | 'unknown';
61
+ head: DeterminismBasis | 'unknown';
45
62
  };
63
+ /** The gate's determinism call: a green needs BOTH sides proven (self-checked or replayed). */
64
+ export declare function auditDeterminism(base: CoverageLedger | null, head: CoverageLedger | null): DeterminismVerdict;
46
65
  export type CoverageVerdict = {
47
66
  /** `complete` — every registered surface captured; `incomplete` — a registered
48
67
  * surface is missing (gates); `unasserted` — no registry, so a green can only
package/dist/coverage.js CHANGED
@@ -44,6 +44,17 @@ export function coverageGaps(capturedKeys, expected, exclude = {}) {
44
44
  // the suite guard — which checks the declared list — cannot see it).
45
45
  /** Bundled next to the maps, so the completeness basis travels with the capture. */
46
46
  export const COVERAGE_LEDGER = 'styleproof-coverage.json';
47
+ /** The gate's determinism call: a green needs BOTH sides proven (self-checked or replayed). */
48
+ export function auditDeterminism(base, head) {
49
+ const b = base?.determinism ?? 'unknown';
50
+ const h = head?.determinism ?? 'unknown';
51
+ const proven = (d) => d === 'self-checked' || d === 'replayed';
52
+ if (b === 'unproven' || h === 'unproven')
53
+ return { status: 'unproven', base: b, head: h };
54
+ if (proven(b) && proven(h))
55
+ return { status: 'proven', base: b, head: h };
56
+ return { status: 'unknown', base: b, head: h };
57
+ }
47
58
  /** The gate's completeness call: audit what was actually captured against the ledger. */
48
59
  export function auditCoverage(capturedKeys, ledger) {
49
60
  if (!ledger || ledger.expected == null) {
@@ -21,6 +21,13 @@ export type InventoryDelta = {
21
21
  };
22
22
  /** `key -> reason` — removals that are intentional, reviewed, and on the record. */
23
23
  export type AllowedRemovals = Record<string, string>;
24
+ /**
25
+ * Read the acknowledged-removals file (`$STYLEPROOF_INVENTORY` or
26
+ * `styleproof.inventory.json`). `{}` when absent; THROWS on malformed JSON — the
27
+ * caller picks the policy (the CI gate fails loud so a broken ack file can't silently
28
+ * un-acknowledge a real loss; the advisory report degrades to `{}`).
29
+ */
30
+ export declare function readAckFile(): AllowedRemovals;
24
31
  /** One raw navigable affordance as read from the DOM, before classification. */
25
32
  export type RawAffordance = {
26
33
  tag: string;
package/dist/inventory.js CHANGED
@@ -17,6 +17,25 @@
17
17
  //
18
18
  // The harvest (`collectNavAffordances`) runs in-page, mirroring
19
19
  // `detectOverlayCandidates`; the diff/union/guard are pure and unit-testable.
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ /**
23
+ * Read the acknowledged-removals file (`$STYLEPROOF_INVENTORY` or
24
+ * `styleproof.inventory.json`). `{}` when absent; THROWS on malformed JSON — the
25
+ * caller picks the policy (the CI gate fails loud so a broken ack file can't silently
26
+ * un-acknowledge a real loss; the advisory report degrades to `{}`).
27
+ */
28
+ export function readAckFile() {
29
+ const p = path.resolve(process.env.STYLEPROOF_INVENTORY ?? 'styleproof.inventory.json');
30
+ if (!fs.existsSync(p))
31
+ return {};
32
+ try {
33
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
34
+ }
35
+ catch (e) {
36
+ throw new Error(`${p} is not valid JSON — ${e.message}`, { cause: e });
37
+ }
38
+ }
20
39
  // ── in-page harvest ───────────────────────────────────────────────────────────
21
40
  // Split in two: the DOM-touching half stays thin (and serializable to the page,
22
41
  // like detectOverlayCandidates); the classification is pure and unit-testable.
@@ -0,0 +1,5 @@
1
+ import type { PNG } from 'pngjs';
2
+ /** An [r, g, b] colour triple, each channel 0–255. */
3
+ export type RGB = [number, number, number];
4
+ /** Paint a solid rectangle onto a PNG, clamped to the canvas bounds. */
5
+ export declare function fillRect(png: PNG, x: number, y: number, w: number, h: number, [r, g, b]: RGB): void;
@@ -0,0 +1,12 @@
1
+ /** Paint a solid rectangle onto a PNG, clamped to the canvas bounds. */
2
+ export function fillRect(png, x, y, w, h, [r, g, b]) {
3
+ for (let yy = Math.max(0, y); yy < Math.min(png.height, y + h); yy++) {
4
+ for (let xx = Math.max(0, x); xx < Math.min(png.width, x + w); xx++) {
5
+ const i = (yy * png.width + xx) << 2;
6
+ png.data[i] = r;
7
+ png.data[i + 1] = g;
8
+ png.data[i + 2] = b;
9
+ png.data[i + 3] = 255;
10
+ }
11
+ }
12
+ }
package/dist/report.js CHANGED
@@ -1,10 +1,13 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { PNG } from 'pngjs';
4
- import { loadStyleMap } from './capture.js';
4
+ import { loadStyleMap, readInventories, } from './capture.js';
5
5
  import { isMapFile } from './map-store.js';
6
+ import { fillRect } from './png-util.js';
6
7
  import { diffStyleMapDirs, diffContentDirs, } from './diff.js';
7
8
  import { describeChange, tokenIndex, toHex, trackCount } from './describe.js';
9
+ import { auditCoverage, auditDeterminism, COVERAGE_LEDGER, } from './coverage.js';
10
+ import { auditRunInventory, readAckFile } from './inventory.js';
8
11
  // Re-export the plain-English summariser so consumers (and tests) reach it
9
12
  // through the package's report module rather than a deep path.
10
13
  export { describeChange, colorName, tokenIndex, toHex } from './describe.js';
@@ -111,17 +114,6 @@ const PNG_OPTS = { deflateLevel: 9, filterType: -1, colorType: 2, inputColorType
111
114
  function writePng(file, png) {
112
115
  fs.writeFileSync(file, PNG.sync.write(png, PNG_OPTS));
113
116
  }
114
- function fillRect(png, x, y, w, h, [r, g, b]) {
115
- for (let yy = Math.max(0, y); yy < Math.min(png.height, y + h); yy++) {
116
- for (let xx = Math.max(0, x); xx < Math.min(png.width, x + w); xx++) {
117
- const i = (yy * png.width + xx) << 2;
118
- png.data[i] = r;
119
- png.data[i + 1] = g;
120
- png.data[i + 2] = b;
121
- png.data[i + 3] = 255;
122
- }
123
- }
124
- }
125
117
  // The annotation hue: a magenta no real UI palette tends to use, so an outline
126
118
  // reads as a marker, not content. Drawn as a hollow rectangle (never filled) so
127
119
  // the UI underneath stays visible — and the clean image alongside proves the box
@@ -869,33 +861,89 @@ function renderContentSection(ctx) {
869
861
  // Pre-existing, grandfathered in the health baseline; the content layer is
870
862
  // rendered by extracted helpers, this only gained a call + headline branch.
871
863
  // fallow-ignore-next-line complexity
872
- export function generateStyleMapReport(opts) {
873
- const { beforeDir, afterDir, outDir, imageBaseUrl = '',
874
- // Tighter than before (was 24) so the change fills the frame — the annotation
875
- // box keeps enough context legible.
876
- pad: padBy = 12, minWidth = 320, minHeight = 180, maxHeight = 1600, zoomBelow = 64,
877
- // More, smaller crops before collapsing (was 6), so distinct changes get their
878
- // own focused frame rather than one wide merged one.
879
- maxCrops = 8, foldDetailsAt = 0, } = opts;
880
- const includeNoise = opts.includeLayoutNoise ?? false;
881
- const includeContent = opts.includeContent ?? false;
882
- const { surfaces, volatile: volatileCount } = diffStyleMapDirs(beforeDir, afterDir);
883
- const liveCandidateLabels = volatileCount > 0 ? collectLiveCandidateLabels(beforeDir, afterDir) : [];
884
- fs.mkdirSync(path.join(outDir, 'crops'), { recursive: true });
885
- // Focus each surface on styling intent: drop reflow-casualty props, suppress
886
- // forced-state echoes of base changes, and remove non-value noise (see
887
- // cleanFindings), unless includeLayoutNoise is set. Surfaces left with no real
888
- // change are dropped.
889
- const prepared = surfaces
890
- .map((sd) => ({
891
- sd,
892
- findings: sd.missing || includeNoise ? sd.findings : cleanFindings(sd.findings),
893
- }))
894
- .filter((p) => p.sd.missing || p.findings.length > 0);
895
- // Group surfaces that changed in the SAME way (the rects differ per width; the
896
- // change itself does not) so an identical change shows once, not once per
897
- // surfacewith one representative image (the widest surface in the group).
898
- const missing = prepared.filter((p) => p.sd.missing);
864
+ function readLedgerFile(dir) {
865
+ const p = path.join(dir, COVERAGE_LEDGER);
866
+ if (!fs.existsSync(p))
867
+ return null;
868
+ try {
869
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
870
+ }
871
+ catch {
872
+ return null;
873
+ }
874
+ }
875
+ function surfaceKeysIn(dir) {
876
+ return [
877
+ ...new Set(fs
878
+ .readdirSync(dir)
879
+ .filter(isMapFile)
880
+ .map((f) => f.replace(/@\d+\.json(\.gz)?$/, ''))),
881
+ ];
882
+ }
883
+ // ── Certification renderers ──────────────────────────────────────────────────────
884
+ // Each maps one source-of-truth verdict to its report line. Kept as separate one-
885
+ // verdict functions so certificationLines stays a thin orchestrator (and each stays
886
+ // well under the complexity gate).
887
+ function coverageLine(cov) {
888
+ if (cov.basis === 'complete')
889
+ return `- **Coverage** complete (all ${cov.registrySize} registered surface(s) captured)`;
890
+ if (cov.basis === 'incomplete')
891
+ return `- **Coverage** — ✗ INCOMPLETE (${cov.uncovered.length} registered surface(s) not captured: ${cov.uncovered.join(', ')})`;
892
+ return '- **Coverage** — ⚠ not asserted (no `expected` registry; certifies only the captured surfaces)';
893
+ }
894
+ function determinismLine(det) {
895
+ if (det.status === 'proven')
896
+ return `- **Determinism** — ✓ proven (base ${det.base}, head ${det.head})`;
897
+ if (det.status === 'unproven')
898
+ return `- **Determinism** — ✗ NOT proven (base ${det.base}, head ${det.head}) — a clean diff could be two nondeterministic reads`;
899
+ return '- **Determinism** — ⚠ unknown (a capture predates the determinism ledger)';
900
+ }
901
+ function inventoryLine(inv) {
902
+ if (inv.unexplained.length > 0) {
903
+ const keys = inv.unexplained.map((i) => i.key);
904
+ return `- **Inventory** — ⚠ ${inv.unexplained.length} navigable affordance(s) removed, unacknowledged: ${keys.slice(0, 8).join(', ')}${keys.length > 8 ? ', …' : ''}`;
905
+ }
906
+ if (inv.delta.removed.length > 0)
907
+ return `- **Inventory** — ✓ ${inv.delta.removed.length} removal(s), all acknowledged`;
908
+ return '- **Inventory** — ✓ navigable set unchanged';
909
+ }
910
+ // Acknowledged removals for the report — lenient: a missing OR malformed ack file
911
+ // just means no acknowledgements. (The diff CLI fails loud on malformed instead,
912
+ // because in CI an unreadable ack file must not silently un-acknowledge a real loss;
913
+ // the report is advisory, so it degrades quietly.)
914
+ function readAcknowledgedRemovals() {
915
+ try {
916
+ return readAckFile();
917
+ }
918
+ catch {
919
+ return {};
920
+ }
921
+ }
922
+ /**
923
+ * The certification block a reviewer reads FIRST — the source-of-truth gates (coverage
924
+ * complete? determinism proven? did the navigable set shrink?), not just the pixel diff.
925
+ * Empty when the bundle carries no certification metadata (an old capture).
926
+ */
927
+ function certificationLines(beforeDir, afterDir) {
928
+ const baseLedger = readLedgerFile(beforeDir);
929
+ const headLedger = readLedgerFile(afterDir);
930
+ const inv = auditRunInventory(readInventories(beforeDir), readInventories(afterDir), readAcknowledgedRemovals());
931
+ const hasLedger = baseLedger !== null || headLedger !== null;
932
+ const hasInvChange = inv.delta.removed.length > 0 || inv.delta.added.length > 0;
933
+ if (!hasLedger && !hasInvChange)
934
+ return [];
935
+ return [
936
+ '**Certification**',
937
+ coverageLine(auditCoverage(surfaceKeysIn(afterDir), headLedger)),
938
+ determinismLine(auditDeterminism(baseLedger, headLedger)),
939
+ inventoryLine(inv),
940
+ '',
941
+ ];
942
+ }
943
+ // Group surfaces that changed in the SAME way (the rects differ per width; the change
944
+ // itself does not) so an identical change shows once, not once per surface — keeping
945
+ // the widest surface as the representative image.
946
+ function groupBySignature(prepared) {
899
947
  const bySig = new Map();
900
948
  for (const p of prepared) {
901
949
  if (p.sd.missing)
@@ -911,9 +959,11 @@ export function generateStyleMapReport(opts) {
911
959
  bySig.set(sig, { surfaces: [p.sd.surface], rep: p, findings: p.findings });
912
960
  }
913
961
  }
914
- const changeGroups = [...bySig.values()];
915
- // Counts reflect the GROUPED view: each distinct change counts once, not once
916
- // per surface it appears on (after shorthand/dedupe collapsing).
962
+ return [...bySig.values()];
963
+ }
964
+ // Counts reflect the GROUPED view: each distinct change counts once, not once per
965
+ // surface it appears on (after shorthand/dedupe collapsing).
966
+ function countShownChanges(changeGroups) {
917
967
  const shown = { dom: 0, style: 0, state: 0 };
918
968
  for (const cg of changeGroups)
919
969
  for (const f of cg.findings) {
@@ -924,6 +974,254 @@ export function generateStyleMapReport(opts) {
924
974
  else
925
975
  shown.state += summarizeProps(f.props).length;
926
976
  }
977
+ return shown;
978
+ }
979
+ // The identical / changed / new-surface summary line(s). Split out (with an early
980
+ // return for the all-identical case) so reportHeadline stays flat.
981
+ function summaryLines(args) {
982
+ const { changeGroups, missing, shown, changedSurfaceCount, contentCount } = args;
983
+ if (changeGroups.length === 0 && missing.length === 0) {
984
+ return [
985
+ contentCount > 0
986
+ ? '✓ Computed styles identical: every longhand, pseudo-element, and hover/focus/active state matches. See the advisory content changes below.'
987
+ : '✓ All surfaces identical: every computed style, pseudo-element, and hover/focus/active state matches.',
988
+ ];
989
+ }
990
+ const md = [];
991
+ if (changeGroups.length > 0) {
992
+ md.push(`**${changeCountLabel(shown)}** across ${changeGroups.length} distinct change(s) in ${changedSurfaceCount} surface(s).`);
993
+ }
994
+ if (missing.length > 0) {
995
+ if (changeGroups.length > 0)
996
+ md.push('');
997
+ md.push(`🆕 **${missing.length} new surface(s)** captured with no baseline to compare — shown below for review. ` +
998
+ `Approve them before they become the baseline.`);
999
+ }
1000
+ return md;
1001
+ }
1002
+ // The headline summary lines between the certification block and the per-surface
1003
+ // detail: identical-vs-changed, new-surface count, live-region note, advisory-content
1004
+ // note. Extracted so generateStyleMapReport stays orchestration, not prose.
1005
+ function reportHeadline(args) {
1006
+ const { changeGroups, missing, shown, changedSurfaceCount, volatileCount, liveCandidateLabels, contentCount } = args;
1007
+ const md = summaryLines({ changeGroups, missing, shown, changedSurfaceCount, contentCount });
1008
+ if (volatileCount > 0) {
1009
+ const candidates = liveCandidateLabels.length
1010
+ ? ` Auto-detected live-state candidate(s): ${liveCandidateLabels.slice(0, 5).join('; ')}.`
1011
+ : '';
1012
+ md.push('', `_${volatileCount} live region(s) auto-excluded as nondeterministic (a stream, ticker, or late-loading content) — they don't affect the check.${candidates}_`);
1013
+ }
1014
+ if (contentCount > 0 && (changeGroups.length > 0 || missing.length > 0)) {
1015
+ md.push('', `📝 _${contentCount} advisory content change(s) below — they don't affect the check._`);
1016
+ }
1017
+ return md;
1018
+ }
1019
+ // Collapse many crops into one merged frame when a change scatters across more regions
1020
+ // than maxCrops would show — the union of all their boxes on each side.
1021
+ function collapseGroups(groups) {
1022
+ return [
1023
+ groups.reduce((acc, g) => ({
1024
+ paths: [...acc.paths, ...g.paths],
1025
+ before: visible(acc.before) && visible(g.before) ? union(acc.before, g.before) : (acc.before ?? g.before),
1026
+ after: visible(acc.after) && visible(g.after) ? union(acc.after, g.after) : (acc.after ?? g.after),
1027
+ })),
1028
+ ];
1029
+ }
1030
+ // Crop, composite, annotate and (for small changes) zoom the before/after pair for one
1031
+ // region, writing the PNGs and returning the image markdown + the images sidecar. The
1032
+ // dense pixel work, isolated from renderRegion's prose.
1033
+ function buildRegionImages(args) {
1034
+ const { g, region, regionFindings, sd, mapA, mapB, pngA, pngB, ctx, cropSeq } = args;
1035
+ const { img, outDir, minWidth, minHeight, maxHeight, zoomBelow } = ctx;
1036
+ // Crop the SAME page rectangle from both sides — the union of where the change sits
1037
+ // on each side — so the pair lines up exactly and the reviewer compares like-for-like
1038
+ // instead of playing spot-the-difference. (Centring each side on its own moved box
1039
+ // would shift the background between them.)
1040
+ const cropBox = visible(g.before) && visible(g.after) ? union(g.before, g.after) : region;
1041
+ const w = Math.max(minWidth, cropBox.w);
1042
+ const h = Math.min(maxHeight, Math.max(minHeight, cropBox.h));
1043
+ // Path-safe, report-unique stem: `hero@1280` → `hero-1280-3` so relative image links
1044
+ // resolve cleanly and two crops never collide on one filename.
1045
+ const stem = `crops/${sd.surface.replace(/[^a-z0-9-]/gi, '-')}-${cropSeq}`;
1046
+ const before = cropPng(pngA, cropBox, w, h);
1047
+ const after = cropPng(pngB, cropBox, w, h);
1048
+ const composite = compositePair(before.png, after.png);
1049
+ writePng(path.join(outDir, `${stem}-composite.png`), composite);
1050
+ // Annotated twin: outline the LEAF changed elements (the added avatars, the restyled
1051
+ // cards) on each side — not the merged container the crop anchors on, whose box would
1052
+ // just trace the whole frame. An element present on only one side (added/removed) is
1053
+ // boxed only there.
1054
+ const markPaths = innermost([...new Set(regionFindings.map((f) => f.path))]);
1055
+ const rectsA = markPaths.map((p) => mapA.elements[p]?.rect).filter((r) => !!r);
1056
+ const rectsB = markPaths.map((p) => mapB.elements[p]?.rect).filter((r) => !!r);
1057
+ const annotated = compositePair(annotateCrop(before, rectsA), annotateCrop(after, rectsB));
1058
+ writePng(path.join(outDir, `${stem}-annotated.png`), annotated);
1059
+ const images = {
1060
+ composite: `${stem}-composite.png`,
1061
+ annotated: `${stem}-annotated.png`,
1062
+ };
1063
+ // Name the changed element(s) so the reviewer knows where to look without expanding
1064
+ // anything (e.g. `changed: span.caret`).
1065
+ const changedNames = [
1066
+ ...new Set(markPaths
1067
+ .map((p) => mapA.elements[p] ?? mapB.elements[p])
1068
+ .filter((e) => !!e)
1069
+ .map((e) => (e.cls ? `${e.tag}.${e.cls.split(/\s+/)[0]}` : e.tag))),
1070
+ ].slice(0, 3);
1071
+ const changedLabel = changedNames.length ? ` — changed: \`${changedNames.join('`, `')}\`` : '';
1072
+ const ctxLabel = formatSurfaceWithContext(sd.surface, mapA, mapB);
1073
+ // A sub-pixel change (e.g. a 2px font bump on a caret) is invisible at 1:1, so when
1074
+ // the changed-element footprint is small, add a magnified crop that makes it obvious
1075
+ // without the reviewer hunting. Anchored on the leaf rects.
1076
+ const changed = unionRects([...rectsA, ...rectsB]);
1077
+ const maxDim = changed ? Math.max(changed.w, changed.h) : 0;
1078
+ let zoomFactor = 0;
1079
+ if (zoomBelow > 0 && changed && maxDim > 0 && maxDim <= zoomBelow) {
1080
+ const zBox = pad(changed, Math.max(maxDim, 16)); // ~3× the change for context
1081
+ zoomFactor = Math.min(8, Math.max(2, Math.round(240 / Math.max(zBox.w, zBox.h))));
1082
+ const zoom = compositePair(zoomCrop(pngA, zBox, rectsA, zoomFactor), zoomCrop(pngB, zBox, rectsB, zoomFactor));
1083
+ writePng(path.join(outDir, `${stem}-zoom.png`), zoom);
1084
+ images.zoom = `${stem}-zoom.png`;
1085
+ }
1086
+ // Both views shown by default: the clean before|after (the real UI) and the
1087
+ // highlighted twin (magenta boxes on each change) so a reviewer sees WHAT changed and
1088
+ // WHERE without expanding anything. Plain images (no link wrap) so a click opens the
1089
+ // full-resolution file.
1090
+ const md = [
1091
+ '',
1092
+ `![before ◀ │ ▶ after](${img(images.composite)})`,
1093
+ '',
1094
+ `<sub>◀ before · after ▶ — ${ctxLabel}</sub>`,
1095
+ '',
1096
+ `![highlighted before ◀ │ ▶ after](${img(images.annotated)})`,
1097
+ '',
1098
+ `<sub>🔍 magenta boxes mark each change${changedLabel}</sub>`,
1099
+ ];
1100
+ if (images.zoom) {
1101
+ md.push('', `![zoomed before ◀ │ ▶ after](${img(images.zoom)})`, '', `<sub>🔬 magnified ${zoomFactor}× — change too small to see at 1:1${changedLabel}</sub>`);
1102
+ }
1103
+ return { md, images };
1104
+ }
1105
+ // Render one crop region: its heading, the surface list, the before/after imagery (or a
1106
+ // note when there's nothing to show), and the per-crop change tables.
1107
+ function renderRegion(args) {
1108
+ const { g, cg, mapA, mapB, pngA, pngB, describeCtx, ctx, cropSeq } = args;
1109
+ const { sd } = cg.rep;
1110
+ // Exactly the findings whose element lives inside THIS crop, so the tables sit
1111
+ // directly under the screenshot that shows them — never a wall of changes spanning
1112
+ // several crops with no way to tell which is which.
1113
+ const regionFindings = cg.rep.findings.filter((f) => g.paths.some((root) => f.path === root || f.path.startsWith(root + ' > ')));
1114
+ const surfaceList = cg.surfaces.length > 1
1115
+ ? `_Identical across ${cg.surfaces.length} surfaces: ${formatSurfaceListWithContext(cg.surfaces, ctx.beforeDir)}_`
1116
+ : `_${formatSurfaceWithContext(sd.surface, mapA, mapB)}_`;
1117
+ const md = ['', `### ${regionHeading(g.paths, regionFindings)}`, '', surfaceList];
1118
+ const region = visible(g.after) ? g.after : g.before;
1119
+ let images = {};
1120
+ if (region && pngA && pngB) {
1121
+ const built = buildRegionImages({ g, region, regionFindings, sd, mapA, mapB, pngA, pngB, ctx, cropSeq });
1122
+ md.push(...built.md);
1123
+ images = built.images;
1124
+ }
1125
+ else if (!region) {
1126
+ md.push('', '_Changed element is not visible in this state (zero-size box) — see the property list._');
1127
+ }
1128
+ else {
1129
+ md.push('', '_No screenshots in these capture sets (run captures with `screenshots: true` for side-by-side crops)._');
1130
+ }
1131
+ // What this crop changed: plain-English bullets, then the property tables — folded
1132
+ // under a toggle once they'd be a wall (foldDetailsAt).
1133
+ md.push(...renderCropChanges(regionFindings, ctx.foldDetailsAt, describeCtx));
1134
+ return { md, regionJson: { paths: g.paths, before: g.before, after: g.after, images } };
1135
+ }
1136
+ // Render one change group: load its representative maps/screenshots, split it into
1137
+ // crop regions (collapsing past maxCrops), and render each region top-to-bottom.
1138
+ function renderChangeGroup(cg, ctx, maxCrops, cropSeq) {
1139
+ const { sd, findings: surfaceFindings } = cg.rep;
1140
+ const mapA = loadStyleMap(findCapture(ctx.beforeDir, sd.surface));
1141
+ const mapB = loadStyleMap(findCapture(ctx.afterDir, sd.surface));
1142
+ // Theme-token reverse-indexes so colour changes can name `red-200` per side.
1143
+ const describeCtx = { tokensBefore: tokenIndex(mapA.tokens), tokensAfter: tokenIndex(mapB.tokens) };
1144
+ const pngA = readPng(path.join(ctx.beforeDir, `${sd.surface}.png`));
1145
+ const pngB = readPng(path.join(ctx.afterDir, `${sd.surface}.png`));
1146
+ const changedPaths = outermost([...new Set(surfaceFindings.map((f) => f.path))]);
1147
+ let groups = groupRegions(changedPaths, mapA, mapB, ctx.padBy);
1148
+ if (groups.length > maxCrops)
1149
+ groups = collapseGroups(groups);
1150
+ // Read top-to-bottom: one section per crop, in page order.
1151
+ const topY = (g) => (visible(g.after) ? g.after.y : visible(g.before) ? g.before.y : Infinity);
1152
+ groups.sort((a, b) => topY(a) - topY(b));
1153
+ const md = [];
1154
+ const regions = [];
1155
+ for (const g of groups) {
1156
+ cropSeq++;
1157
+ const r = renderRegion({ g, cg, mapA, mapB, pngA, pngB, describeCtx, ctx, cropSeq });
1158
+ md.push(...r.md);
1159
+ regions.push(r.regionJson);
1160
+ }
1161
+ const json = {
1162
+ surfaces: cg.surfaces,
1163
+ representative: sd.surface,
1164
+ regions,
1165
+ findings: surfaceFindings,
1166
+ };
1167
+ return { md, json, findingCount: surfaceFindings.length, cropSeq };
1168
+ }
1169
+ // Render a new surface: present on only one side, so there's nothing to diff. Show the
1170
+ // captured side as a single screenshot and mark the heading for the PR comment.
1171
+ function renderNewSurface(p, ctx, cropSeq) {
1172
+ const { img, outDir, maxHeight } = ctx;
1173
+ const side = p.sd.missing === 'before' ? 'after' : 'before';
1174
+ const srcDir = side === 'after' ? ctx.afterDir : ctx.beforeDir;
1175
+ const map = loadStyleMap(findCapture(srcDir, p.sd.surface));
1176
+ const png = readPng(path.join(srcDir, `${p.sd.surface}.png`));
1177
+ const md = [
1178
+ '',
1179
+ `### \`${p.sd.surface}\` · new surface ${NEW_SURFACE_MARKER}`,
1180
+ '',
1181
+ `_${formatSurfaceWithContext(p.sd.surface, map)}_`,
1182
+ ];
1183
+ const json = { surface: p.sd.surface, missing: p.sd.missing, isNew: true };
1184
+ if (png) {
1185
+ cropSeq++;
1186
+ const h = Math.min(maxHeight, png.height);
1187
+ const crop = cropPng(png, { x: 0, y: 0, w: png.width, h }, png.width, h).png;
1188
+ const stem = `crops/${p.sd.surface.replace(/[^a-z0-9-]/gi, '-')}-${cropSeq}-new`;
1189
+ writePng(path.join(outDir, `${stem}.png`), crop);
1190
+ md.push('', `![new surface — ${side}](${img(`${stem}.png`)})`, '', `<sub>${side} · ${formatSurfaceWithContext(p.sd.surface, map)}${png.height > h ? ' (top of page)' : ''}</sub>`);
1191
+ json.image = `${stem}.png`;
1192
+ }
1193
+ else {
1194
+ md.push('', `_Captured only in the **${side}** set; no screenshot saved (run captures with \`screenshots: true\`)._`);
1195
+ }
1196
+ md.push('', `_No baseline to compare against — this surface is new. Review and approve it before it becomes part of the baseline._`);
1197
+ return { md, json, cropSeq };
1198
+ }
1199
+ export function generateStyleMapReport(opts) {
1200
+ const { beforeDir, afterDir, outDir, imageBaseUrl = '',
1201
+ // Tighter than before (was 24) so the change fills the frame — the annotation
1202
+ // box keeps enough context legible.
1203
+ pad: padBy = 12, minWidth = 320, minHeight = 180, maxHeight = 1600, zoomBelow = 64,
1204
+ // More, smaller crops before collapsing (was 6), so distinct changes get their
1205
+ // own focused frame rather than one wide merged one.
1206
+ maxCrops = 8, foldDetailsAt = 0, } = opts;
1207
+ const includeNoise = opts.includeLayoutNoise ?? false;
1208
+ const includeContent = opts.includeContent ?? false;
1209
+ const { surfaces, volatile: volatileCount } = diffStyleMapDirs(beforeDir, afterDir);
1210
+ const liveCandidateLabels = volatileCount > 0 ? collectLiveCandidateLabels(beforeDir, afterDir) : [];
1211
+ fs.mkdirSync(path.join(outDir, 'crops'), { recursive: true });
1212
+ // Focus each surface on styling intent: drop reflow-casualty props, suppress
1213
+ // forced-state echoes of base changes, and remove non-value noise (see
1214
+ // cleanFindings), unless includeLayoutNoise is set. Surfaces left with no real
1215
+ // change are dropped.
1216
+ const prepared = surfaces
1217
+ .map((sd) => ({
1218
+ sd,
1219
+ findings: sd.missing || includeNoise ? sd.findings : cleanFindings(sd.findings),
1220
+ }))
1221
+ .filter((p) => p.sd.missing || p.findings.length > 0);
1222
+ const missing = prepared.filter((p) => p.sd.missing);
1223
+ const changeGroups = groupBySignature(prepared);
1224
+ const shown = countShownChanges(changeGroups);
927
1225
  // Surfaces carrying a reviewable change — NOT the new (one-sided) ones, which
928
1226
  // have no baseline to compare and are summarised on their own line below so the
929
1227
  // headline never reads "0 changes" while warnings sit beneath it.
@@ -931,173 +1229,50 @@ export function generateStyleMapReport(opts) {
931
1229
  const md = [];
932
1230
  const json = [];
933
1231
  const img = (rel) => (imageBaseUrl ? `${imageBaseUrl.replace(/\/$/, '')}/${rel}` : rel);
1232
+ const ctx = {
1233
+ beforeDir,
1234
+ afterDir,
1235
+ outDir,
1236
+ img,
1237
+ padBy,
1238
+ minWidth,
1239
+ minHeight,
1240
+ maxHeight,
1241
+ zoomBelow,
1242
+ foldDetailsAt,
1243
+ };
934
1244
  // Opt-in, advisory: computed here so its count can colour the headline, but its
935
1245
  // markdown is appended at the very end and it NEVER feeds the gate below.
936
1246
  const contentSection = includeContent
937
1247
  ? renderContentSection({ beforeDir, afterDir, outDir, img, padBy, minWidth, minHeight, maxHeight })
938
1248
  : { md: [], count: 0 };
939
1249
  md.push('## 🗺️ StyleProof report', '');
940
- if (changeGroups.length === 0 && missing.length === 0) {
941
- md.push(contentSection.count > 0
942
- ? '✓ Computed styles identical: every longhand, pseudo-element, and hover/focus/active state matches. See the advisory content changes below.'
943
- : '✓ All surfaces identical: every computed style, pseudo-element, and hover/focus/active state matches.');
944
- }
945
- else {
946
- if (changeGroups.length > 0) {
947
- md.push(`**${changeCountLabel(shown)}** across ${changeGroups.length} distinct change(s) in ${changedSurfaceCount} surface(s).`);
948
- }
949
- if (missing.length > 0) {
950
- if (changeGroups.length > 0)
951
- md.push('');
952
- md.push(`🆕 **${missing.length} new surface(s)** captured with no baseline to compare — shown below for review. ` +
953
- `Approve them before they become the baseline.`);
954
- }
955
- }
956
- if (volatileCount > 0) {
957
- const candidates = liveCandidateLabels.length
958
- ? ` Auto-detected live-state candidate(s): ${liveCandidateLabels.slice(0, 5).join('; ')}.`
959
- : '';
960
- md.push('', `_${volatileCount} live region(s) auto-excluded as nondeterministic (a stream, ticker, or late-loading content) — they don't affect the check.${candidates}_`);
961
- }
962
- if (contentSection.count > 0 && (changeGroups.length > 0 || missing.length > 0)) {
963
- md.push('', `📝 _${contentSection.count} advisory content change(s) below — they don't affect the check._`);
964
- }
1250
+ // Lead with the source-of-truth gates (coverage / determinism / inventory) so a
1251
+ // reviewer reads "is this green trustworthy?" before the pixel details.
1252
+ md.push(...certificationLines(beforeDir, afterDir));
1253
+ md.push(...reportHeadline({
1254
+ changeGroups,
1255
+ missing,
1256
+ shown,
1257
+ changedSurfaceCount,
1258
+ volatileCount,
1259
+ liveCandidateLabels,
1260
+ contentCount: contentSection.count,
1261
+ }));
965
1262
  let totalFindings = 0;
966
1263
  let cropSeq = 0;
967
1264
  for (const cg of changeGroups) {
968
- const { sd, findings: surfaceFindings } = cg.rep;
969
- totalFindings += surfaceFindings.length;
970
- const mapA = loadStyleMap(findCapture(beforeDir, sd.surface));
971
- const mapB = loadStyleMap(findCapture(afterDir, sd.surface));
972
- // Theme-token reverse-indexes so colour changes can name `red-200` per side.
973
- const describeCtx = { tokensBefore: tokenIndex(mapA.tokens), tokensAfter: tokenIndex(mapB.tokens) };
974
- const pngA = readPng(path.join(beforeDir, `${sd.surface}.png`));
975
- const pngB = readPng(path.join(afterDir, `${sd.surface}.png`));
976
- const changedPaths = outermost([...new Set(surfaceFindings.map((f) => f.path))]);
977
- let groups = groupRegions(changedPaths, mapA, mapB, padBy);
978
- if (groups.length > maxCrops) {
979
- groups = [
980
- groups.reduce((acc, g) => ({
981
- paths: [...acc.paths, ...g.paths],
982
- before: visible(acc.before) && visible(g.before) ? union(acc.before, g.before) : (acc.before ?? g.before),
983
- after: visible(acc.after) && visible(g.after) ? union(acc.after, g.after) : (acc.after ?? g.after),
984
- })),
985
- ];
986
- }
987
- // Read top-to-bottom: one section per crop, in page order.
988
- const topY = (g) => (visible(g.after) ? g.after.y : visible(g.before) ? g.before.y : Infinity);
989
- groups.sort((a, b) => topY(a) - topY(b));
990
- const surfaceJson = {
991
- surfaces: cg.surfaces,
992
- representative: sd.surface,
993
- regions: [],
994
- };
995
- for (const g of groups) {
996
- cropSeq++;
997
- // Exactly the findings whose element lives inside THIS crop, so the tables
998
- // sit directly under the screenshot that shows them — never a wall of
999
- // changes spanning several crops with no way to tell which is which.
1000
- const regionFindings = surfaceFindings.filter((f) => g.paths.some((root) => f.path === root || f.path.startsWith(root + ' > ')));
1001
- const surfaceList = cg.surfaces.length > 1
1002
- ? `_Identical across ${cg.surfaces.length} surfaces: ${formatSurfaceListWithContext(cg.surfaces, beforeDir)}_`
1003
- : `_${formatSurfaceWithContext(sd.surface, mapA, mapB)}_`;
1004
- md.push('', `### ${regionHeading(g.paths, regionFindings)}`, '', surfaceList);
1005
- const region = visible(g.after) ? g.after : g.before;
1006
- let images = {};
1007
- if (region && pngA && pngB) {
1008
- // Crop the SAME page rectangle from both sides — the union of where the
1009
- // change sits on each side — so the pair lines up exactly and the reviewer
1010
- // compares like-for-like instead of playing spot-the-difference. (Centring
1011
- // each side on its own moved box would shift the background between them.)
1012
- const cropBox = visible(g.before) && visible(g.after) ? union(g.before, g.after) : region;
1013
- const w = Math.max(minWidth, cropBox.w);
1014
- const h = Math.min(maxHeight, Math.max(minHeight, cropBox.h));
1015
- // Path-safe, report-unique stem: `hero@1280` → `hero-1280-3` so relative
1016
- // image links resolve cleanly and two crops never collide on one filename.
1017
- const stem = `crops/${sd.surface.replace(/[^a-z0-9-]/gi, '-')}-${cropSeq}`;
1018
- const before = cropPng(pngA, cropBox, w, h);
1019
- const after = cropPng(pngB, cropBox, w, h);
1020
- const composite = compositePair(before.png, after.png);
1021
- writePng(path.join(outDir, `${stem}-composite.png`), composite);
1022
- // Annotated twin: outline the LEAF changed elements (the added avatars, the
1023
- // restyled cards) on each side — not the merged container the crop anchors
1024
- // on, whose box would just trace the whole frame. An element present on only
1025
- // one side (added/removed) is boxed only there.
1026
- const markPaths = innermost([...new Set(regionFindings.map((f) => f.path))]);
1027
- const rectsA = markPaths.map((p) => mapA.elements[p]?.rect).filter((r) => !!r);
1028
- const rectsB = markPaths.map((p) => mapB.elements[p]?.rect).filter((r) => !!r);
1029
- const annotated = compositePair(annotateCrop(before, rectsA), annotateCrop(after, rectsB));
1030
- writePng(path.join(outDir, `${stem}-annotated.png`), annotated);
1031
- images = { composite: `${stem}-composite.png`, annotated: `${stem}-annotated.png` };
1032
- // Name the changed element(s) so the reviewer knows where to look without
1033
- // expanding anything (e.g. `changed: span.caret`).
1034
- const changedNames = [
1035
- ...new Set(markPaths
1036
- .map((p) => mapA.elements[p] ?? mapB.elements[p])
1037
- .filter((e) => !!e)
1038
- .map((e) => (e.cls ? `${e.tag}.${e.cls.split(/\s+/)[0]}` : e.tag))),
1039
- ].slice(0, 3);
1040
- const changedLabel = changedNames.length ? ` — changed: \`${changedNames.join('`, `')}\`` : '';
1041
- const ctxLabel = formatSurfaceWithContext(sd.surface, mapA, mapB);
1042
- // A sub-pixel change (e.g. a 2px font bump on a caret) is invisible at 1:1,
1043
- // so when the changed-element footprint is small, add a magnified crop that
1044
- // makes it obvious without the reviewer hunting. Anchored on the leaf rects.
1045
- const changed = unionRects([...rectsA, ...rectsB]);
1046
- const maxDim = changed ? Math.max(changed.w, changed.h) : 0;
1047
- let zoomFactor = 0;
1048
- if (zoomBelow > 0 && changed && maxDim > 0 && maxDim <= zoomBelow) {
1049
- const zBox = pad(changed, Math.max(maxDim, 16)); // ~3× the change for context
1050
- zoomFactor = Math.min(8, Math.max(2, Math.round(240 / Math.max(zBox.w, zBox.h))));
1051
- const zoom = compositePair(zoomCrop(pngA, zBox, rectsA, zoomFactor), zoomCrop(pngB, zBox, rectsB, zoomFactor));
1052
- writePng(path.join(outDir, `${stem}-zoom.png`), zoom);
1053
- images.zoom = `${stem}-zoom.png`;
1054
- }
1055
- // Both views shown by default: the clean before|after (the real UI) and the
1056
- // highlighted twin (magenta boxes on each change) so a reviewer sees WHAT
1057
- // changed and WHERE without expanding anything. Plain images (no link wrap)
1058
- // so a click opens the full-resolution file.
1059
- md.push('', `![before ◀ │ ▶ after](${img(images.composite)})`, '', `<sub>◀ before · after ▶ — ${ctxLabel}</sub>`, '', `![highlighted before ◀ │ ▶ after](${img(images.annotated)})`, '', `<sub>🔍 magenta boxes mark each change${changedLabel}</sub>`);
1060
- if (images.zoom) {
1061
- md.push('', `![zoomed before ◀ │ ▶ after](${img(images.zoom)})`, '', `<sub>🔬 magnified ${zoomFactor}× — change too small to see at 1:1${changedLabel}</sub>`);
1062
- }
1063
- }
1064
- else if (!region) {
1065
- md.push('', '_Changed element is not visible in this state (zero-size box) — see the property list._');
1066
- }
1067
- else {
1068
- md.push('', '_No screenshots in these capture sets (run captures with `screenshots: true` for side-by-side crops)._');
1069
- }
1070
- // What this crop changed: plain-English bullets, then the property tables —
1071
- // folded under a toggle once they'd be a wall (foldDetailsAt).
1072
- md.push(...renderCropChanges(regionFindings, foldDetailsAt, describeCtx));
1073
- surfaceJson.regions.push({ paths: g.paths, before: g.before, after: g.after, images });
1074
- }
1075
- surfaceJson.findings = surfaceFindings;
1076
- json.push(surfaceJson);
1265
+ const r = renderChangeGroup(cg, ctx, maxCrops, cropSeq);
1266
+ md.push(...r.md);
1267
+ json.push(r.json);
1268
+ totalFindings += r.findingCount;
1269
+ cropSeq = r.cropSeq;
1077
1270
  }
1078
- // New surfaces: present on only one side, so there's nothing to diff. Show the
1079
- // captured side as a single screenshot and mark the heading for the PR comment.
1080
1271
  for (const p of missing) {
1081
- const side = p.sd.missing === 'before' ? 'after' : 'before';
1082
- const srcDir = side === 'after' ? afterDir : beforeDir;
1083
- const map = loadStyleMap(findCapture(srcDir, p.sd.surface));
1084
- const png = readPng(path.join(srcDir, `${p.sd.surface}.png`));
1085
- md.push('', `### \`${p.sd.surface}\` · new surface ${NEW_SURFACE_MARKER}`, '', `_${formatSurfaceWithContext(p.sd.surface, map)}_`);
1086
- const surfaceJson = { surface: p.sd.surface, missing: p.sd.missing, isNew: true };
1087
- if (png) {
1088
- cropSeq++;
1089
- const h = Math.min(maxHeight, png.height);
1090
- const crop = cropPng(png, { x: 0, y: 0, w: png.width, h }, png.width, h).png;
1091
- const stem = `crops/${p.sd.surface.replace(/[^a-z0-9-]/gi, '-')}-${cropSeq}-new`;
1092
- writePng(path.join(outDir, `${stem}.png`), crop);
1093
- md.push('', `![new surface — ${side}](${img(`${stem}.png`)})`, '', `<sub>${side} · ${formatSurfaceWithContext(p.sd.surface, map)}${png.height > h ? ' (top of page)' : ''}</sub>`);
1094
- surfaceJson.image = `${stem}.png`;
1095
- }
1096
- else {
1097
- md.push('', `_Captured only in the **${side}** set; no screenshot saved (run captures with \`screenshots: true\`)._`);
1098
- }
1099
- md.push('', `_No baseline to compare against — this surface is new. Review and approve it before it becomes part of the baseline._`);
1100
- json.push(surfaceJson);
1272
+ const r = renderNewSurface(p, ctx, cropSeq);
1273
+ md.push(...r.md);
1274
+ json.push(r.json);
1275
+ cropSeq = r.cropSeq;
1101
1276
  }
1102
1277
  md.push(...contentSection.md);
1103
1278
  const reportMdPath = path.join(outDir, 'report.md');
package/dist/runner.js CHANGED
@@ -458,11 +458,18 @@ function resolveSettings(c) {
458
458
  * `expected: null` records that the spec declared no registry, so a green can only
459
459
  * certify the captured surfaces. Runs on a capture run (dir set) only.
460
460
  */
461
- function writeCoverageLedgerTest(baseDir, dir, expected, exclude) {
461
+ function writeCoverageLedgerTest(settings, dir, expected, exclude) {
462
462
  test('styleproof coverage ledger', () => {
463
- const outDir = path.join(baseDir, dir);
463
+ const outDir = path.join(settings.baseDir, dir);
464
464
  fs.mkdirSync(outDir, { recursive: true });
465
- const ledger = { version: 1, expected, exclude };
465
+ // Determinism basis: self-check ON proves it (a drift would have failed the capture);
466
+ // else a replay run is deterministic by construction; else it's unproven.
467
+ const determinism = settings.selfCheck
468
+ ? 'self-checked'
469
+ : settings.replayFrom
470
+ ? 'replayed'
471
+ : 'unproven';
472
+ const ledger = { version: 1, expected, exclude, determinism };
466
473
  fs.writeFileSync(path.join(outDir, COVERAGE_LEDGER), JSON.stringify(ledger, null, 2));
467
474
  });
468
475
  }
@@ -490,7 +497,7 @@ export function defineStyleMapCapture(options) {
490
497
  test.describe('styleproof capture', () => {
491
498
  test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
492
499
  if (dir)
493
- writeCoverageLedgerTest(settings.baseDir, dir, expected ?? null, exclude);
500
+ writeCoverageLedgerTest(settings, dir, expected ?? null, exclude);
494
501
  for (const surface of captureSurfaces) {
495
502
  if (surface.widths && surface.widths.length > 0) {
496
503
  // Explicit widths: one parallelizable test per surface × width.
@@ -545,7 +552,7 @@ export function defineCrawlCapture(options) {
545
552
  // so it records `expected: null` (completeness honestly "not asserted": the crawl
546
553
  // captures what the nav links to, and can't prove that's every route in the app).
547
554
  if (dir)
548
- writeCoverageLedgerTest(settings.baseDir, dir, null, {});
555
+ writeCoverageLedgerTest(settings, dir, null, {});
549
556
  test('discover surfaces by crawling links, then capture each', async ({ page }) => {
550
557
  // 1. Load the root and wait for its nav links to hydrate — an SPA renders them
551
558
  // client-side, so they aren't in the initial HTML.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "3.9.0",
3
+ "version": "3.11.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",