styleproof 3.10.0 → 3.12.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,45 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.12.0] - 2026-07-05
11
+
12
+ ### Changed
13
+
14
+ - **Inventory guard: target-based keying.** Source-of-truth step 4 (keying honesty).
15
+ Tabs / menu items / nav buttons now key by the most stable identity the element
16
+ exposes — a developer-authored `data-testid`, else a non-generated `id` /
17
+ `aria-controls` — falling back to the label slug only when there's none. Previously
18
+ they always keyed by `slug(accessible-name)`, so a wobble in the **label** (a live
19
+ count badge like "COMMAND 5" → "COMMAND 3", a re-word) faked a `removed` + `added`
20
+ pair. Links were already immune (they key by href); this extends the same
21
+ target-based principle to the rest.
22
+
23
+ Framework-generated ids (React `useId` `:r0:`, Headless UI `headlessui-…`, Radix,
24
+ hashes) are rejected so keying on them can't _add_ churn. The design keeps the
25
+ guard's safe failure direction: the label-slug fallback turns a wobble into a
26
+ **surfaced** removed+added (a red you see and acknowledge), never a hidden real
27
+ removal. Give a nav item a `data-testid` to key it immune to its own text.
28
+
29
+ Note: an id-bearing affordance's key changes from `<role>:<slug>` to
30
+ `<role>:#<id>`, so existing `styleproof.inventory.json` acknowledgements for such
31
+ items need re-keying once (the guard names the new key).
32
+
33
+ ## [3.11.0] - 2026-07-06
34
+
35
+ ### Added
36
+
37
+ - **Report leads with the certification gates.** Source-of-truth step 3: the reviewer-
38
+ facing `report.md` now opens with a **Certification** block — the three source-of-truth
39
+ verdicts, so "is this green trustworthy?" is answered before the pixel details:
40
+ - **Coverage** — ✓ complete / ✗ INCOMPLETE (names the uncaptured surfaces) / ⚠ not asserted;
41
+ - **Determinism** — ✓ proven / ✗ NOT proven / ⚠ unknown;
42
+ - **Inventory** — ✓ navigable set unchanged / ⚠ N affordance(s) removed (names them) /
43
+ ✓ N removal(s), all acknowledged.
44
+
45
+ These verdicts were previously visible only in the `styleproof-diff` CI logs; now
46
+ they're in the artifact a reviewer actually reads. The block is omitted for an old
47
+ bundle that carries no certification metadata, so nothing changes for legacy captures.
48
+
10
49
  ## [3.10.0] - 2026-07-06
11
50
 
12
51
  ### 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,14 +33,14 @@ 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';
42
+ import { readInventories } from '../dist/capture.js';
43
+ import { auditRunInventory, readAckFile } from '../dist/inventory.js';
44
44
  import { auditCoverage, auditDeterminism, COVERAGE_LEDGER } from '../dist/coverage.js';
45
45
  import { isMapFile } from '../dist/map-store.js';
46
46
 
@@ -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) };
@@ -245,13 +233,7 @@ if (args.length <= 1) {
245
233
  dirA = cacheCapture.beforeDir;
246
234
  dirB = cacheCapture.afterDir;
247
235
  } catch (e) {
248
- console.error(
249
- [
250
- `${COMMAND}: cached maps are not available for this comparison`,
251
- cliErrorMessage(e),
252
- 'Next: run styleproof-map on the base and head commits to upload maps, or let CI recapture both sides.',
253
- ].join('\n'),
254
- );
236
+ console.error(cachedMapsUnavailableMessage(COMMAND, 'comparison', e));
255
237
  process.exit(2);
256
238
  }
257
239
  } else {
@@ -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
  }
@@ -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;
@@ -28,9 +35,23 @@ export type RawAffordance = {
28
35
  name: string;
29
36
  /** pathname+search for a same-origin `<a href>`; null otherwise. Resolved in-page. */
30
37
  internalPath: string | null;
38
+ /** `data-testid` — developer-authored, trusted as a stable identity when present. */
39
+ testId: string | null;
40
+ /** `id` — used as a stable identity only when it doesn't look framework-generated. */
41
+ domId: string | null;
42
+ /** `aria-controls` (a tab's panel) — a stable identity when not framework-generated. */
43
+ controls: string | null;
31
44
  };
32
45
  /** In-page: collect visible navigable affordances. No classification. */
33
46
  export declare function collectNavAffordances(): RawAffordance[];
47
+ /**
48
+ * Whether an `id` / `aria-controls` value is a STABLE identity worth keying on, vs a
49
+ * framework-generated one (React useId `:r0:`, Headless UI `headlessui-tabs-tab-3`,
50
+ * Radix `radix-:r1:`, Emotion hashes) that wobbles across renders/builds. Keying by a
51
+ * generated id would ADD churn, so those fall back to the label slug. (`data-testid` is
52
+ * developer-authored by definition, so it's trusted without this check.)
53
+ */
54
+ export declare function isStableId(id: string | null | undefined): id is string;
34
55
  /** Pure: turn raw affordances into keyed, deduped, sorted navigable items. */
35
56
  export declare function classifyInventory(raw: RawAffordance[]): NavigableItem[];
36
57
  /** Harvest a page's inventory: thin in-page collect + pure classify. */
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.
@@ -56,6 +75,9 @@ export function collectNavAffordances() {
56
75
  role: (el.getAttribute('role') || '').toLowerCase(),
57
76
  name: nameOf(el),
58
77
  internalPath: el.tagName === 'A' ? internalPath(el) : null,
78
+ testId: el.getAttribute('data-testid'),
79
+ domId: el.getAttribute('id'),
80
+ controls: el.getAttribute('aria-controls'),
59
81
  }));
60
82
  }
61
83
  const slug = (s) => s
@@ -63,6 +85,48 @@ const slug = (s) => s
63
85
  .replace(/[^a-z0-9]+/g, '-')
64
86
  .replace(/^-+|-+$/g, '')
65
87
  .slice(0, 60);
88
+ /**
89
+ * Whether an `id` / `aria-controls` value is a STABLE identity worth keying on, vs a
90
+ * framework-generated one (React useId `:r0:`, Headless UI `headlessui-tabs-tab-3`,
91
+ * Radix `radix-:r1:`, Emotion hashes) that wobbles across renders/builds. Keying by a
92
+ * generated id would ADD churn, so those fall back to the label slug. (`data-testid` is
93
+ * developer-authored by definition, so it's trusted without this check.)
94
+ */
95
+ export function isStableId(id) {
96
+ if (!id)
97
+ return false;
98
+ const v = id.trim();
99
+ if (!v || v.length > 80)
100
+ return false;
101
+ if (v.includes(':'))
102
+ return false; // React useId / Radix scoped ids
103
+ if (/^(headlessui|radix|mui|chakra|reach|react-aria|floating-ui|downshift)-/i.test(v))
104
+ return false;
105
+ if (/^[0-9a-f]{8,}$/i.test(v))
106
+ return false; // hash-like
107
+ return true;
108
+ }
109
+ // A tab/menuitem/button's stable identity, if it exposes one: a data-testid (trusted),
110
+ // else a non-generated id or aria-controls. Preferred over the label so a count badge
111
+ // or re-label in the text doesn't move the key. (Links already key by href, the most
112
+ // stable target of all.) When absent, we fall back to the label slug — the wobble is a
113
+ // false removed+added, which the guard SURFACES; it never hides a real removal.
114
+ function stableIdOf(c) {
115
+ if (c.testId && c.testId.trim())
116
+ return c.testId.trim();
117
+ if (isStableId(c.domId))
118
+ return c.domId.trim();
119
+ if (isStableId(c.controls))
120
+ return c.controls.trim();
121
+ return null;
122
+ }
123
+ // `<role>:#<stable-id>` when the affordance exposes a stable identity, else
124
+ // `<role>:<slug(name)>`. The `#` marker keeps id-keys from colliding with slug-keys
125
+ // (a slug never contains `#`).
126
+ function affordanceKey(role, c) {
127
+ const id = stableIdOf(c);
128
+ return id ? `${role}:#${id}` : `${role}:${slug(c.name)}`;
129
+ }
66
130
  /** Pure: turn raw affordances into keyed, deduped, sorted navigable items. */
67
131
  export function classifyInventory(raw) {
68
132
  const items = new Map();
@@ -75,13 +139,13 @@ export function classifyInventory(raw) {
75
139
  add(`route:${c.internalPath}`, 'link', c.name || c.internalPath, c.internalPath);
76
140
  }
77
141
  else if (c.name && c.role === 'tab') {
78
- add(`tab:${slug(c.name)}`, 'tab', c.name);
142
+ add(affordanceKey('tab', c), 'tab', c.name);
79
143
  }
80
144
  else if (c.name && c.role.startsWith('menuitem')) {
81
- add(`menuitem:${slug(c.name)}`, 'menuitem', c.name);
145
+ add(affordanceKey('menuitem', c), 'menuitem', c.name);
82
146
  }
83
147
  else if (c.name && c.tag === 'button') {
84
- add(`nav-button:${slug(c.name)}`, 'nav-button', c.name);
148
+ add(affordanceKey('nav-button', c), 'nav-button', c.name);
85
149
  }
86
150
  }
87
151
  return Array.from(items.values()).sort((a, b) => a.key.localeCompare(b.key));
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "3.10.0",
3
+ "version": "3.12.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",