styleproof 3.20.0 → 4.0.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/dist/report.js CHANGED
@@ -1,16 +1,23 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { PNG } from 'pngjs';
4
- import { loadStyleMap, readInventories, } from './capture.js';
4
+ import { loadStyleMap, readInventories, readResidue, surfaceElementPaths, } from './capture.js';
5
5
  import { isMapFile } from './map-store.js';
6
6
  import { fillRect } from './png-util.js';
7
7
  import { diffStyleMapDirs, diffContentDirs, } from './diff.js';
8
- import { describeChange, tokenIndex, toHex, trackCount } from './describe.js';
8
+ import { describeChange, tokenIndex, toHex } from './describe.js';
9
9
  import { auditCoverage, auditDeterminism, COVERAGE_LEDGER, } from './coverage.js';
10
10
  import { auditRunInventory, readAckFile } from './inventory.js';
11
+ import { auditRunResidue, readResidueAckFile } from './data-residue.js';
12
+ // The pure grouping / classification brain — shared with the CLI. report.ts keeps
13
+ // the crop-and-PNG rendering on top of these.
14
+ import { cleanFindings, groupByPath, groupTitle, isNonValue, prettyLabel, safeKey, signatureOf, summarizeProps, surfaceBase, surfaceWidth, pushSurfaceWidth, renderSurfaceGroups, formatSurfaceList, classifyChrome, } from './change-groups.js';
11
15
  // Re-export the plain-English summariser so consumers (and tests) reach it
12
16
  // through the package's report module rather than a deep path.
13
17
  export { describeChange, colorName, tokenIndex, toHex } from './describe.js';
18
+ // Re-export the grouping primitives historically exported from here so existing
19
+ // imports (`from 'styleproof'` → report) keep resolving.
20
+ export { summarizeProps, prettyLabel } from './change-groups.js';
14
21
  // Hidden marker appended to a new-surface heading. Invisible in rendered
15
22
  // markdown; lets the PR-comment layer recognize one-sided surfaces.
16
23
  const NEW_SURFACE_MARKER = '<!-- styleproof-new -->';
@@ -52,16 +59,6 @@ function changeCountLabel(shown) {
52
59
  parts.push(`${shown.state} state-delta difference(s)`);
53
60
  return parts.join(' · ');
54
61
  }
55
- /** Group findings by their element path (one group per changed element). */
56
- function groupByPath(findings) {
57
- const byPath = new Map();
58
- for (const f of findings) {
59
- const arr = byPath.get(f.path) ?? [];
60
- arr.push(f);
61
- byPath.set(f.path, arr);
62
- }
63
- return [...byPath.values()];
64
- }
65
62
  function groupRegions(paths, a, b, padBy) {
66
63
  const groups = paths.map((p) => {
67
64
  const ra = a.elements[p]?.rect;
@@ -197,224 +194,8 @@ function readPng(file) {
197
194
  return null;
198
195
  return PNG.sync.read(fs.readFileSync(file));
199
196
  }
200
- // --- readable findings: dedupe logical longhands, collapse shorthand families,
201
- // humanize values, label by semantic marker, group identical siblings ------
202
- // Logical longhand → its physical equivalent (LTR, horizontal-tb). Dropped when
203
- // the physical one changed identically, so each change appears once.
204
- const LOGICAL_TO_PHYSICAL = (() => {
205
- const m = {};
206
- const sides = {
207
- 'block-start': 'top',
208
- 'block-end': 'bottom',
209
- 'inline-start': 'left',
210
- 'inline-end': 'right',
211
- };
212
- for (const [l, p] of Object.entries(sides)) {
213
- for (const k of ['color', 'width', 'style'])
214
- m[`border-${l}-${k}`] = `border-${p}-${k}`;
215
- m[`margin-${l}`] = `margin-${p}`;
216
- m[`padding-${l}`] = `padding-${p}`;
217
- m[`inset-${l}`] = p;
218
- }
219
- const radius = {
220
- 'start-start': 'top-left',
221
- 'start-end': 'top-right',
222
- 'end-start': 'bottom-left',
223
- 'end-end': 'bottom-right',
224
- };
225
- for (const [l, p] of Object.entries(radius))
226
- m[`border-${l}-radius`] = `border-${p}-radius`;
227
- return m;
228
- })();
229
- // Length-valued 4-side families → CSS 1–4-value shorthand whenever all four
230
- // sides changed (e.g. `padding: 26px 24px → 28px`).
231
- const BOX4 = [
232
- { short: 'margin', parts: ['top', 'right', 'bottom', 'left'].map((s) => `margin-${s}`) },
233
- { short: 'padding', parts: ['top', 'right', 'bottom', 'left'].map((s) => `padding-${s}`) },
234
- { short: 'border-width', parts: ['top', 'right', 'bottom', 'left'].map((s) => `border-${s}-width`) },
235
- {
236
- short: 'border-radius',
237
- parts: ['top-left', 'top-right', 'bottom-right', 'bottom-left'].map((s) => `border-${s}-radius`),
238
- },
239
- ];
240
- // Colour/keyword families → one row only when all four sides match (else
241
- // per-side, which keeps each colour swatchable).
242
- const UNIFORM = [
243
- { short: 'border-color', parts: ['top', 'right', 'bottom', 'left'].map((s) => `border-${s}-color`) },
244
- { short: 'border-style', parts: ['top', 'right', 'bottom', 'left'].map((s) => `border-${s}-style`) },
245
- ];
246
- const boxShorthand = ([t, r, b, l]) => t === r && r === b && b === l
247
- ? t
248
- : t === b && r === l
249
- ? `${t} ${r}`
250
- : r === l
251
- ? `${t} ${r} ${b}`
252
- : `${t} ${r} ${b} ${l}`;
253
- const PROP_ORDER = [
254
- 'display',
255
- 'position',
256
- 'grid-template-columns',
257
- 'grid-template-rows',
258
- 'flex-direction',
259
- 'justify-content',
260
- 'align-items',
261
- 'gap',
262
- 'margin',
263
- 'padding',
264
- 'border-width',
265
- 'border-style',
266
- 'border-color',
267
- 'border-radius',
268
- 'outline',
269
- 'background-color',
270
- 'background-image',
271
- 'color',
272
- 'box-shadow',
273
- 'opacity',
274
- 'transform',
275
- 'font-family',
276
- 'font-size',
277
- 'font-weight',
278
- 'line-height',
279
- 'letter-spacing',
280
- 'text-transform',
281
- 'text-align',
282
- ];
283
- const orderIdx = (p) => {
284
- const i = PROP_ORDER.indexOf(p);
285
- return i === -1 ? PROP_ORDER.length : i;
286
- };
287
- function cleanVal(v) {
288
- // Verbatim by design: no rounding. Rounding once showed alpha 0.18 as 0.2 —
289
- // and could erase a real 0.18→0.2 diff entirely via the no-op filter below.
290
- let s = v;
291
- if (!s.includes('(')) {
292
- const toks = s.split(' ');
293
- if (toks.length > 1 && new Set(toks).size === 1)
294
- s = `${toks[0]} ×${toks.length}`;
295
- }
296
- return s.replace(/rgba\(\s*0,\s*0,\s*0,\s*0\s*\)/g, 'transparent');
297
- }
298
- // These default to `currentColor`, so a `color` change drags them all along —
299
- // pure echoes, dropped when they match the `color` change.
300
- const CURRENTCOLOR_FOLLOWERS = [
301
- 'caret-color',
302
- 'outline-color',
303
- 'column-rule-color',
304
- 'text-decoration-color',
305
- 'text-emphasis-color',
306
- '-webkit-text-fill-color',
307
- '-webkit-text-stroke-color',
308
- ];
309
- // "No value here" markers: a forced-state delta that doesn't apply, an unset
310
- // longhand, or a capture artifact where a path didn't line up. A change BETWEEN
311
- // two of these (e.g. `— → (gone)`) is meaningless and must never read as a diff.
312
- const NON_VALUE = new Set(['(state does not change it)', '(state no longer changes it)', '(unset)', '(gone)']);
313
- const isNonValue = (v) => NON_VALUE.has(v);
314
- /** Combine longhands into a shorthand value; all-non-value sides collapse to one. */
315
- const combineValues = (vals) => (vals.every(isNonValue) ? '(unset)' : vals.join(' '));
316
- export function summarizeProps(props) {
317
- const map = new Map(props.map((p) => [p.prop, { ...p }]));
318
- for (const [logical, physical] of Object.entries(LOGICAL_TO_PHYSICAL)) {
319
- const lo = map.get(logical);
320
- const ph = map.get(physical);
321
- if (lo && ph && lo.before === ph.before && lo.after === ph.after)
322
- map.delete(logical);
323
- }
324
- const color = map.get('color');
325
- if (color)
326
- for (const f of CURRENTCOLOR_FOLLOWERS) {
327
- const m = map.get(f);
328
- if (m && m.before === color.before && m.after === color.after)
329
- map.delete(f);
330
- }
331
- for (const fam of BOX4) {
332
- const members = fam.parts.map((p) => map.get(p));
333
- if (members.every((m) => !!m)) {
334
- fam.parts.forEach((p) => map.delete(p));
335
- map.set(fam.short, {
336
- prop: fam.short,
337
- before: boxShorthand(members.map((m) => m.before)),
338
- after: boxShorthand(members.map((m) => m.after)),
339
- });
340
- }
341
- }
342
- const rg = map.get('row-gap');
343
- const cg = map.get('column-gap');
344
- if (rg && cg) {
345
- map.delete('row-gap');
346
- map.delete('column-gap');
347
- map.set('gap', {
348
- prop: 'gap',
349
- before: rg.before === cg.before ? rg.before : `${rg.before} ${cg.before}`,
350
- after: rg.after === cg.after ? rg.after : `${rg.after} ${cg.after}`,
351
- });
352
- }
353
- for (const fam of UNIFORM) {
354
- const members = fam.parts.map((p) => map.get(p)).filter((m) => !!m);
355
- if (members.length === fam.parts.length &&
356
- members.every((m) => m.before === members[0].before && m.after === members[0].after)) {
357
- fam.parts.forEach((p) => map.delete(p));
358
- map.set(fam.short, { prop: fam.short, before: members[0].before, after: members[0].after });
359
- }
360
- }
361
- // outline-width/-style/-color → one `outline` row (offset stays separate).
362
- const ow = map.get('outline-width');
363
- const os = map.get('outline-style');
364
- const oc = map.get('outline-color');
365
- if (ow && os && oc) {
366
- map.delete('outline-width');
367
- map.delete('outline-style');
368
- map.delete('outline-color');
369
- map.set('outline', {
370
- prop: 'outline',
371
- before: combineValues([ow.before, os.before, oc.before]),
372
- after: combineValues([ow.after, os.after, oc.after]),
373
- });
374
- }
375
- return ([...map.values()]
376
- .map((p) => ({ prop: p.prop, before: cleanVal(p.before), after: cleanVal(p.after) }))
377
- // Drop no-ops: a value that didn't actually change, or a change between two
378
- // "no value here" markers (`— → (gone)`), which carries no information.
379
- .filter((p) => p.before !== p.after && !(isNonValue(p.before) && isNonValue(p.after)))
380
- .sort((a, b) => orderIdx(a.prop) - orderIdx(b.prop) || a.prop.localeCompare(b.prop)));
381
- }
382
- /** `div.who-grid`, `a.nav-cta`, `h3` — the semantic marker class, else the tag. */
383
- export function prettyLabel(p, cls) {
384
- const tag = (p.split('>').pop() ?? '').trim().replace(/:nth-child\(\d+\)/, '') || 'el';
385
- const first = cls.split(/\s+/)[0] ?? '';
386
- return /^[a-z][a-z0-9-]*$/.test(first) ? `${tag}.${first}` : tag;
387
- }
388
- // Surface keys originate from artifact filenames — attacker-controlled in the
389
- // fork capture/report split, and they flow into the PRIVILEGED PR-comment summary
390
- // (the Action slices report.md above the first `### `). Strip the Markdown/HTML
391
- // control characters (`` ` ``, [ ] ( ), < >, |) that could inject a link, image,
392
- // or table into that bot comment. Escaping at the render boundary — the keys stay
393
- // legible; only the injection surface is removed. (Crop FILENAMES are separately
394
- // restricted to [a-z0-9-]; this is the display-side equivalent.)
395
- const safeKey = (s) => s.replace(/[`[\]()<>|]/g, '-');
396
- const surfaceBase = (s) => s.replace(/@\d+$/, '');
397
- const surfaceWidth = (s) => Number(s.match(/@(\d+)$/)?.[1] ?? 0);
398
- function pushSurfaceWidth(byBase, base, surface) {
399
- const arr = byBase.get(base) ?? [];
400
- arr.push(surfaceWidth(surface));
401
- byBase.set(base, arr);
402
- }
403
- function renderSurfaceGroups(byBase) {
404
- return [...byBase]
405
- .map(([base, ws]) => {
406
- const widths = ws.filter((w) => w > 0).sort((a, b) => b - a);
407
- return widths.length ? `${safeKey(base)} @ ${widths.join(', ')}` : safeKey(base);
408
- })
409
- .join(' · ');
410
- }
411
- /** "landing @ 1280, 1080, 390 · landing-nav-open @ 1080" from the surface keys. */
412
- function formatSurfaceList(surfaces) {
413
- const byBase = new Map();
414
- for (const s of surfaces)
415
- pushSurfaceWidth(byBase, surfaceBase(s), s);
416
- return renderSurfaceGroups(byBase);
417
- }
197
+ // --- readable findings: the dedupe/summarise/label brain lives in
198
+ // change-groups.ts (shared with the CLI). report.ts renders crops on top. ---
418
199
  function surfaceContext(...maps) {
419
200
  const metadata = maps.find((m) => m?.metadata)?.metadata;
420
201
  if (!metadata?.variantKey)
@@ -457,47 +238,6 @@ function collectLiveCandidateLabels(beforeDir, afterDir) {
457
238
  }
458
239
  return [...seen].sort();
459
240
  }
460
- // Grid-track longhands compute to width-dependent pixels (`282px ×2` at one width,
461
- // `282px 228px` at another), so the SAME responsive change would otherwise get a
462
- // different signature per width. Key them by track COUNT — what actually
463
- // identifies the change — so responsive variants group into one section.
464
- function sigValue(c) {
465
- if (c.prop === 'grid-template-columns' || c.prop === 'grid-template-rows') {
466
- return `${c.prop}=${trackCount(c.before)}t>${trackCount(c.after)}t`;
467
- }
468
- return `${c.prop}=${c.before}>${c.after}`;
469
- }
470
- /** Canonical signature of a surface's findings: surfaces that changed in the
471
- * same way collapse into one section + one image (the rects differ per width;
472
- * the change itself does not). */
473
- function signatureOf(findings) {
474
- return JSON.stringify(findings
475
- .map((f) => ({
476
- p: f.path,
477
- k: f.kind,
478
- t: f.kind === 'dom' ? f.change : f.kind === 'state' ? f.state : (f.pseudo ?? ''),
479
- v: f.kind === 'dom' ? '' : summarizeProps(f.props).map(sigValue).join('|'),
480
- }))
481
- .sort((a, b) => `${a.p}|${a.k}|${a.t}`.localeCompare(`${b.p}|${b.k}|${b.t}`)));
482
- }
483
- /** A one-line heading for a change group: "1 element added", "2 elements restyled". */
484
- function groupTitle(findings) {
485
- const added = new Set(findings.filter((f) => f.kind === 'dom' && f.change === 'added').map((f) => f.path));
486
- const removed = new Set(findings.filter((f) => f.kind === 'dom' && f.change === 'removed').map((f) => f.path));
487
- const retagged = new Set(findings.filter((f) => f.kind === 'dom' && f.change === 'retagged').map((f) => f.path));
488
- const restyled = new Set(findings.filter((f) => f.kind !== 'dom' && !added.has(f.path) && !removed.has(f.path)).map((f) => f.path));
489
- const n = (c, w) => `${c} ${w}${c === 1 ? '' : 's'}`;
490
- const parts = [];
491
- if (added.size)
492
- parts.push(`${n(added.size, 'element')} added`);
493
- if (removed.size)
494
- parts.push(`${n(removed.size, 'element')} removed`);
495
- if (retagged.size)
496
- parts.push(`${n(retagged.size, 'element')} retagged`);
497
- if (restyled.size)
498
- parts.push(`${n(restyled.size, 'element')} restyled`);
499
- return parts.join(', ') || `${n(new Set(findings.map((f) => f.path)).size, 'element')} changed`;
500
- }
501
241
  /**
502
242
  * A crop's heading: the element it's anchored on, then what happened inside it —
503
243
  * `` `who-grid` · 5 elements restyled ``. Naming the anchor is what ties the
@@ -704,80 +444,6 @@ function renderCropChanges(findings, foldAt, ctx) {
704
444
  const summary = bullets.length ? bullets.map((b) => `- ${b}`) : ['_see changes_'];
705
445
  return ['', ...summary, '', '<details>', `<summary>${foldSummary(findings)}</summary>`, ...tables, '', '</details>'];
706
446
  }
707
- // Computed values that follow from an element's box size or position rather than
708
- // its styling. On any reflow they change all the way up the ancestor chain
709
- // (body, main, section…), so an element whose ONLY changes are these is a reflow
710
- // casualty: it must not anchor a crop region (that would zoom to the whole page)
711
- // nor clutter the findings. The certification differ keeps them — a reflow IS a
712
- // change to certify — but the visual report focuses on styling intent.
713
- const DERIVED_PROPS = new Set([
714
- 'width',
715
- 'height',
716
- 'block-size',
717
- 'inline-size',
718
- 'min-width',
719
- 'min-height',
720
- 'max-width',
721
- 'max-height',
722
- 'perspective-origin',
723
- 'transform-origin',
724
- // position offsets shift with the document on any reflow
725
- 'top',
726
- 'right',
727
- 'bottom',
728
- 'left',
729
- 'inset-block-start',
730
- 'inset-block-end',
731
- 'inset-inline-start',
732
- 'inset-inline-end',
733
- ]);
734
- // Props stripped from forced :hover/:focus/:active deltas specifically. Layout
735
- // and grid-track values that shift when a state forces a relayout are capture
736
- // noise, not interaction feedback — a state finding is meant to catch a changed
737
- // hover/focus/active *style* (colour, outline, shadow), not a reflow.
738
- const STATE_STRIP = new Set([
739
- ...DERIVED_PROPS,
740
- 'grid-template-columns',
741
- 'grid-template-rows',
742
- 'grid-template-areas',
743
- 'grid-auto-columns',
744
- 'grid-auto-rows',
745
- 'grid-auto-flow',
746
- ]);
747
- /**
748
- * Strip the noise the visual report shouldn't carry, cross-referencing each
749
- * element's layers so the forced-state layer stops echoing the base:
750
- * - base/pseudo styles: drop size/position-derived longhands (reflow casualties);
751
- * - forced states: drop derived + grid-track props, drop a delta the BASE
752
- * already changed (a `:hover color` that just follows a recoloured base is an
753
- * echo, not a dropped variant), and drop non-value↔non-value rows;
754
- * - any finding left with no props is removed entirely.
755
- */
756
- function cleanFindings(findings) {
757
- const out = [];
758
- for (const group of groupByPath(findings)) {
759
- const base = group.find((f) => f.kind === 'style' && f.pseudo === null);
760
- const baseChanged = new Set(base?.props.map((p) => p.prop) ?? []);
761
- // For an ADDED element the base style is a full (unset)→value snapshot, not a
762
- // delta — so a forced-state value is never an "echo" of a base *change*; keep
763
- // every state row (suppressing them would drop a real :hover/:focus value).
764
- const isAdded = group.some((f) => f.kind === 'dom' && f.change === 'added');
765
- for (const f of group) {
766
- if (f.kind === 'dom') {
767
- out.push(f);
768
- continue;
769
- }
770
- const props = f.kind === 'style'
771
- ? f.props.filter((p) => !DERIVED_PROPS.has(p.prop))
772
- : f.props.filter((p) => !STATE_STRIP.has(p.prop) &&
773
- (isAdded || !baseChanged.has(p.prop)) &&
774
- !(isNonValue(p.before) && isNonValue(p.after)));
775
- if (props.length)
776
- out.push({ ...f, props });
777
- }
778
- }
779
- return out;
780
- }
781
447
  /** Per-element view for the plain-English summariser: the base deltas (summarised)
782
448
  * plus which interactive states genuinely changed. */
783
449
  function buildElementChanges(findings) {
@@ -923,13 +589,29 @@ function determinismLine(det) {
923
589
  return `- **Determinism** — ✗ NOT proven (base ${det.base}, head ${det.head}) — a clean diff could be two nondeterministic reads`;
924
590
  return '- **Determinism** — ⚠ unknown (a capture predates the determinism ledger)';
925
591
  }
592
+ // Truncated, escaped, comma-joined key list — the same discipline for removals and
593
+ // additions, so neither can inject Markdown into the privileged PR-comment summary.
594
+ function keyList(items) {
595
+ const keys = items.map((i) => safeKey(i.key));
596
+ return `${keys.slice(0, 8).join(', ')}${keys.length > 8 ? ', …' : ''}`;
597
+ }
598
+ // Additions never gate, but the report must not contradict the diff (which prints
599
+ // them) — so echo them as an informational, still-✓-class clause. Returns a leading
600
+ // `; …` fragment to append after whatever the removal side decided, or '' when none.
601
+ function additionsClause(added) {
602
+ if (added.length === 0)
603
+ return '';
604
+ return `; ${added.length} navigable affordance(s) added: ${keyList(added)} (additions don't gate)`;
605
+ }
926
606
  function inventoryLine(inv) {
927
- if (inv.unexplained.length > 0) {
928
- const keys = inv.unexplained.map((i) => safeKey(i.key));
929
- return `- **Inventory** — ⚠ ${inv.unexplained.length} navigable affordance(s) removed, unacknowledged: ${keys.slice(0, 8).join(', ')}${keys.length > 8 ? ', …' : ''}`;
930
- }
607
+ const added = additionsClause(inv.delta.added);
608
+ if (inv.unexplained.length > 0)
609
+ return `- **Inventory** — ⚠ ${inv.unexplained.length} navigable affordance(s) removed, unacknowledged: ${keyList(inv.unexplained)}${added}`;
931
610
  if (inv.delta.removed.length > 0)
932
- return `- **Inventory** — ✓ ${inv.delta.removed.length} removal(s), all acknowledged`;
611
+ return `- **Inventory** — ✓ ${inv.delta.removed.length} removal(s), all acknowledged${added}`;
612
+ // Addition-only: drop the leading `; ` so the clause reads as the whole ✓ line.
613
+ if (added)
614
+ return `- **Inventory** — ✓${added.slice(1)}`;
933
615
  return '- **Inventory** — ✓ navigable set unchanged';
934
616
  }
935
617
  // Acknowledged removals for the report — lenient: a missing OR malformed ack file
@@ -944,6 +626,29 @@ function readAcknowledgedRemovals() {
944
626
  return {};
945
627
  }
946
628
  }
629
+ // Lenient acknowledged-residue read — same advisory degradation as the inventory one.
630
+ function readAcknowledgedResidue() {
631
+ try {
632
+ return readResidueAckFile();
633
+ }
634
+ catch {
635
+ return {};
636
+ }
637
+ }
638
+ // One data-residue clause. A failing data endpoint captured the fallback branch, so its
639
+ // response-driven states are unproven; an ARMED gate escalates an unacknowledged one to ✗.
640
+ function dataResidueLine(res) {
641
+ const { residue, unacknowledged, staleAcknowledgements, armed } = res;
642
+ if (armed && (unacknowledged.length > 0 || staleAcknowledgements.length > 0)) {
643
+ const stale = staleAcknowledgements.length ? `; ${staleAcknowledgements.length} stale acknowledgement(s)` : '';
644
+ return `- **Data residue** — ✗ ${unacknowledged.length} failing data endpoint(s), unacknowledged: ${keyList(unacknowledged)}${stale}`;
645
+ }
646
+ if (unacknowledged.length > 0)
647
+ return `- **Data residue** — ⚠ ${unacknowledged.length} failing data endpoint(s) (fallback branch captured): ${keyList(unacknowledged)} — recorded, not gating (\`dataResidue: 'warn'\` opt-out)`;
648
+ if (residue.length > 0)
649
+ return `- **Data residue** — ✓ ${residue.length} failing endpoint(s), all acknowledged`;
650
+ return '- **Data residue** — ✓ no failing data-boundary request during capture';
651
+ }
947
652
  /**
948
653
  * The certification block a reviewer reads FIRST — the source-of-truth gates (coverage
949
654
  * complete? determinism proven? did the navigable set shrink?), not just the pixel diff.
@@ -953,15 +658,20 @@ function certificationLines(beforeDir, afterDir) {
953
658
  const baseLedger = readLedgerFile(beforeDir);
954
659
  const headLedger = readLedgerFile(afterDir);
955
660
  const inv = auditRunInventory(readInventories(beforeDir), readInventories(afterDir), readAcknowledgedRemovals());
661
+ const res = auditRunResidue(readResidue(afterDir), readAcknowledgedResidue(), headLedger?.dataResidue === 'gate');
956
662
  const hasLedger = baseLedger !== null || headLedger !== null;
957
663
  const hasInvChange = inv.delta.removed.length > 0 || inv.delta.added.length > 0;
958
- if (!hasLedger && !hasInvChange)
664
+ const hasResidue = res.residue.length > 0 || res.armed;
665
+ if (!hasLedger && !hasInvChange && !hasResidue)
959
666
  return [];
960
667
  return [
961
668
  '**Certification**',
962
669
  coverageLine(auditCoverage(surfaceKeysIn(afterDir), headLedger)),
963
670
  determinismLine(auditDeterminism(baseLedger, headLedger)),
964
671
  inventoryLine(inv),
672
+ // Only add the residue line when there's residue or the gate was armed — an ordinary
673
+ // bundle (no failing endpoint, not armed) keeps its exact prior 3-line block.
674
+ ...(hasResidue ? [dataResidueLine(res)] : []),
965
675
  '',
966
676
  ];
967
677
  }
@@ -1221,6 +931,24 @@ function renderNewSurface(p, ctx, cropSeq) {
1221
931
  md.push('', `_No baseline to compare against — this surface is new. Review and approve it before it becomes part of the baseline._`);
1222
932
  return { md, json, cropSeq };
1223
933
  }
934
+ /** The shared-chrome tier banner (#193), emitted once above the promoted groups:
935
+ * "the frame every view draws changed" — so the reviewer reads it as one global
936
+ * change, not a per-view surprise. The affected group(s) render in full beneath.
937
+ * `nChrome` is how many distinct chrome changes were promoted; `nSurfaces` is the
938
+ * captured-surface-base count they span. */
939
+ function chromeCalloutLines(nChrome, nSurfaces) {
940
+ const what = nChrome === 1 ? 'change' : 'changes';
941
+ return [
942
+ '',
943
+ '---',
944
+ '',
945
+ `## 🧱 Global chrome ${what} — across all ${nSurfaces} surface(s)`,
946
+ '',
947
+ `_${nChrome} change(s) rode the shared frame every view draws (a persistent nav, header, or footer): ` +
948
+ `each touched every surface that renders the affected element, so it reads as ONE global change, not a ` +
949
+ `per-view one. The detail is folded beneath — review it once._`,
950
+ ];
951
+ }
1224
952
  /** The one-time banner where report.md switches from full detail to one-line
1225
953
  * summaries, so the reader knows nothing is missing — only relocated to report.json. */
1226
954
  function cappedNoticeLines(budget) {
@@ -1271,6 +999,14 @@ export function generateStyleMapReport(opts) {
1271
999
  .filter((p) => p.sd.missing || p.findings.length > 0);
1272
1000
  const missing = prepared.filter((p) => p.sd.missing);
1273
1001
  const changeGroups = groupBySignature(prepared);
1002
+ // Shared-chrome tier (#193): promote a change that rode the frame every view
1003
+ // draws (nav rail, header) to a callout, so the reviewer reads "the nav changed
1004
+ // everywhere" once instead of inferring it from a long surface list on several
1005
+ // entries. Purely presentational — counts, groups, exit code, and report.json
1006
+ // are unchanged; only the render order and one heading differ. In the common
1007
+ // small-surface case (e.g. the demo) nothing qualifies and this is a no-op.
1008
+ const { chrome, rest } = classifyChrome(changeGroups, surfaceElementPaths(beforeDir, afterDir));
1009
+ const orderedGroups = [...chrome, ...rest];
1274
1010
  const shown = countShownChanges(changeGroups);
1275
1011
  // Surfaces carrying a reviewable change — NOT the new (one-sided) ones, which
1276
1012
  // have no baseline to compare and are summarised on their own line below so the
@@ -1331,12 +1067,23 @@ export function generateStyleMapReport(opts) {
1331
1067
  md.push(summary);
1332
1068
  reportBytes += summary.length + 1;
1333
1069
  };
1334
- for (const cg of changeGroups) {
1070
+ // The captured-surface-base count (all surfaces, not just changed ones) so the
1071
+ // chrome callout can read "N of M surfaces". M is bases, matching the tier's
1072
+ // base-keyed coverage rule.
1073
+ const totalSurfaceBases = new Set(surfaceKeysIn(afterDir).map(surfaceBase)).size;
1074
+ const chromeSet = new Set(chrome);
1075
+ let chromeHeaderEmitted = false;
1076
+ for (const cg of orderedGroups) {
1335
1077
  const r = renderChangeGroup(cg, ctx, maxCrops, cropSeq);
1336
1078
  json.push(r.json);
1337
1079
  totalFindings += r.findingCount;
1338
1080
  cropSeq = r.cropSeq;
1339
- emitDetail(r.md, compactChangeSummary(cg, r.json, img));
1081
+ // Prepend the shared-chrome banner once, above the first promoted group. It
1082
+ // rides on the same emitDetail budget so the cap still applies.
1083
+ const detail = chromeSet.has(cg) && !chromeHeaderEmitted
1084
+ ? ((chromeHeaderEmitted = true), [...chromeCalloutLines(chrome.length, totalSurfaceBases), ...r.md])
1085
+ : r.md;
1086
+ emitDetail(detail, compactChangeSummary(cg, r.json, img));
1340
1087
  }
1341
1088
  for (const p of missing) {
1342
1089
  const r = renderNewSurface(p, ctx, cropSeq);
package/dist/runner.d.ts CHANGED
@@ -177,6 +177,20 @@ export type DefineOptions = {
177
177
  * See `docs/inventory-guard.md`.
178
178
  */
179
179
  inventory?: boolean;
180
+ /**
181
+ * Data-residue guard. During capture, any request matching the data boundary
182
+ * (`replayUrl`, default `**\/api/**`) that FAILS — a network error or a 4xx/5xx —
183
+ * means the captured state renders that endpoint's FALLBACK branch, so states driven
184
+ * by its real responses are uncaptured and unproven (issue #205). Such a failure is
185
+ * ALWAYS named on stderr and recorded on the capture (`StyleMap.dataResidue`) so the
186
+ * diff/report can surface it. `'gate'` (the default) makes an UNACKNOWLEDGED failing
187
+ * endpoint block `styleproof-diff` (exit 1); acknowledge intentional ones in
188
+ * `styleproof.data-residue.json` (`key -> reason`). `'warn'` is the explicit opt-out —
189
+ * failures are still named + recorded but never block. A capture with no failing data
190
+ * request is byte-identical either way. A 2xx that merely wasn't fixtured is NEVER
191
+ * flagged (recording legitimately records live 2xx).
192
+ */
193
+ dataResidue?: 'warn' | 'gate';
180
194
  };
181
195
  export declare function selfCheckErrorMessage(surfaceKey: string, drift: Finding[], volatile?: string[], liveCandidates?: LiveRegionCandidate[]): string;
182
196
  type ResolvedPopupCaptureOptions = Required<PopupCaptureOptions>;
@@ -237,6 +251,12 @@ export declare function resolveBaseDir(baseDir: string | undefined, env?: string
237
251
  * can generate reviewable reports without recapturing.
238
252
  */
239
253
  export declare function resolveScreenshots(screenshots: boolean | undefined, env?: string | undefined): boolean;
254
+ /**
255
+ * The data-residue guard mode: `'gate'` (the v4 default) blocks the diff on an
256
+ * unacknowledged failing data endpoint; `'warn'` is the explicit opt-out that records +
257
+ * warns without gating. Single source of truth for the default, so the flip lives here.
258
+ */
259
+ export declare function resolveDataResidue(mode: 'warn' | 'gate' | undefined): 'warn' | 'gate';
240
260
  /** The capture settings every capturer shares (everything bar the surface set). */
241
261
  type CaptureConfig = Omit<DefineOptions, 'surfaces' | 'expected' | 'exclude'>;
242
262
  export declare function defineStyleMapCapture(options: DefineOptions): void;