@crouton-kit/capture 1.3.8 → 1.3.10

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.
Files changed (136) hide show
  1. package/bin/capture +29841 -8539
  2. package/package.json +6 -3
  3. package/src/capture.ts +86 -173
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -205
  6. package/src/cdp/bridge/protocol.ts +93 -6
  7. package/src/cdp/bridge/server.ts +118 -53
  8. package/src/cdp/bridge/spawn.ts +53 -6
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +20 -14
  11. package/src/cdp/commands/cdp.ts +259 -90
  12. package/src/cdp/commands/gate-guard.ts +43 -0
  13. package/src/cdp/commands/lib.ts +339 -213
  14. package/src/cdp/commands/measure/census.ts +76 -20
  15. package/src/cdp/commands/measure/check.ts +100 -26
  16. package/src/cdp/commands/measure/diff.ts +109 -21
  17. package/src/cdp/commands/measure/explain.ts +121 -19
  18. package/src/cdp/commands/measure/index.ts +31 -39
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +41 -13
  21. package/src/cdp/commands/measure/map-layers.ts +49 -15
  22. package/src/cdp/commands/measure/map-scroll.ts +85 -15
  23. package/src/cdp/commands/measure/snap.ts +405 -25
  24. package/src/cdp/commands/measure/sweep.ts +217 -20
  25. package/src/cdp/commands/motion/index.ts +18 -17
  26. package/src/cdp/commands/motion/jank.ts +119 -9
  27. package/src/cdp/commands/motion/mask.ts +81 -12
  28. package/src/cdp/commands/motion/rec.ts +520 -19
  29. package/src/cdp/commands/motion/response.ts +65 -15
  30. package/src/cdp/commands/motion/timeline.ts +139 -14
  31. package/src/cdp/commands/page/click.ts +194 -0
  32. package/src/cdp/commands/page/elements.ts +154 -0
  33. package/src/cdp/commands/page/exec.ts +317 -0
  34. package/src/cdp/commands/page/index.ts +72 -0
  35. package/src/cdp/commands/page/navigate.ts +330 -0
  36. package/src/cdp/commands/page/scroll.ts +107 -0
  37. package/src/cdp/commands/page/shot.ts +208 -0
  38. package/src/cdp/commands/page/type.ts +104 -0
  39. package/src/cdp/commands/tab/index.ts +59 -0
  40. package/src/cdp/commands/tab/list.ts +167 -0
  41. package/src/cdp/commands/tab/network.ts +162 -0
  42. package/src/cdp/commands/tab/open.ts +90 -0
  43. package/src/cdp/commands/tab/reset.ts +160 -0
  44. package/src/cdp/connection.ts +368 -37
  45. package/src/cdp/detect.ts +42 -13
  46. package/src/cdp/dispatch.ts +74 -28
  47. package/src/cdp/har-recorder.ts +648 -115
  48. package/src/cdp/leaf-grammar.ts +99 -0
  49. package/src/cdp/measure/census.ts +205 -0
  50. package/src/cdp/measure/check.ts +318 -0
  51. package/src/cdp/measure/collectors/animation.ts +385 -0
  52. package/src/cdp/measure/collectors/ax.ts +278 -0
  53. package/src/cdp/measure/collectors/focus.ts +1041 -0
  54. package/src/cdp/measure/collectors/forms.ts +905 -0
  55. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  56. package/src/cdp/measure/collectors/hittest.ts +835 -0
  57. package/src/cdp/measure/collectors/layers.ts +697 -0
  58. package/src/cdp/measure/collectors/media.ts +522 -0
  59. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  60. package/src/cdp/measure/collectors/queries.ts +464 -0
  61. package/src/cdp/measure/collectors/scroll.ts +599 -0
  62. package/src/cdp/measure/collectors/states.ts +992 -0
  63. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  64. package/src/cdp/measure/collectors/styles.ts +355 -0
  65. package/src/cdp/measure/collectors/text.ts +713 -0
  66. package/src/cdp/measure/diff.ts +470 -0
  67. package/src/cdp/measure/explain.ts +498 -0
  68. package/src/cdp/measure/map-ax.ts +633 -0
  69. package/src/cdp/measure/map-focus.ts +188 -0
  70. package/src/cdp/measure/map-layers.ts +177 -0
  71. package/src/cdp/measure/map-scroll.ts +317 -0
  72. package/src/cdp/measure/redaction.ts +51 -0
  73. package/src/cdp/measure/settle.ts +1051 -0
  74. package/src/cdp/measure/snapshot.ts +390 -0
  75. package/src/cdp/measure/sweep.ts +330 -0
  76. package/src/cdp/measure/types.ts +220 -0
  77. package/src/cdp/measure/v2/collectors.ts +19 -0
  78. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  79. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  80. package/src/cdp/measure/v2/target-lock.ts +15 -0
  81. package/src/cdp/measure/v2/target.ts +16 -0
  82. package/src/cdp/motion/jank.ts +433 -0
  83. package/src/cdp/motion/mask.ts +421 -0
  84. package/src/cdp/motion/recorder.ts +764 -0
  85. package/src/cdp/motion/response.ts +356 -0
  86. package/src/cdp/motion/timeline.ts +215 -0
  87. package/src/cdp/record.ts +5 -212
  88. package/src/cdp/recorder-bridge.ts +1478 -105
  89. package/src/cdp/recorder-client.ts +326 -0
  90. package/src/cdp/scope-lock.ts +73 -0
  91. package/src/cdp/screenshot.ts +199 -66
  92. package/src/cdp/targets.ts +21 -1
  93. package/src/cdp/timing.ts +15 -10
  94. package/src/cdp/types.ts +22 -7
  95. package/src/cdp/viewport.ts +21 -0
  96. package/src/cdp.ts +12 -58
  97. package/src/command-help.ts +29 -0
  98. package/src/command-resolution.ts +22 -0
  99. package/src/command-walker.ts +35 -0
  100. package/src/contracts/a11y.ts +33 -0
  101. package/src/contracts/cursor.ts +35 -0
  102. package/src/contracts/index.ts +15 -0
  103. package/src/contracts/motion.ts +19 -0
  104. package/src/contracts/neutral.ts +56 -0
  105. package/src/contracts/primitives.ts +205 -0
  106. package/src/contracts/release.ts +17 -0
  107. package/src/contracts/results.ts +208 -0
  108. package/src/contracts/routes.ts +140 -0
  109. package/src/contracts/snapshot.ts +92 -0
  110. package/src/cursor/index.ts +13 -0
  111. package/src/cursor/service.ts +310 -0
  112. package/src/errors.ts +71 -0
  113. package/src/har-manager.ts +992 -41
  114. package/src/interact.ts +410 -98
  115. package/src/output/artifact-lifecycle.ts +371 -0
  116. package/src/output/artifact.ts +6 -0
  117. package/src/output/exact-raw.ts +114 -0
  118. package/src/output/render.ts +396 -2
  119. package/src/output/structured.ts +211 -0
  120. package/src/registry.ts +208 -0
  121. package/src/session/artifacts.ts +452 -200
  122. package/src/session/commands.ts +1162 -397
  123. package/src/session/coordinator.ts +251 -0
  124. package/src/session/har-filter.ts +57 -0
  125. package/src/session/log-tailer.ts +653 -0
  126. package/src/session-context.ts +279 -51
  127. package/src/vault/bundle.ts +32 -17
  128. package/src/cdp/commands/exec.ts +0 -105
  129. package/src/cdp/commands/har.ts +0 -128
  130. package/src/cdp/commands/tabs.ts +0 -183
  131. package/src/cdp/commands/traffic.ts +0 -121
  132. package/src/cdp/commands/ui.ts +0 -172
  133. package/src/cdp/execute.ts +0 -113
  134. package/src/cdp/har-output.ts +0 -47
  135. package/src/cdp/usage.ts +0 -79
  136. package/src/tab-session.ts +0 -91
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Pure, side-effect-free leaf grammar validators shared between the central
3
+ * pre-dispatch validator (`validateCliInvocation` in `args.ts`) and the leaf
4
+ * commands themselves. Every check here must run BEFORE session/endpoint
5
+ * resolution and before any artifact allocation, so a malformed leaf argument
6
+ * can never read/clean the active-session pointer or allocate a one-shot dir
7
+ * before it is rejected. The leaves import the same functions so the two
8
+ * layers never drift into divergent predicates.
9
+ *
10
+ * Keep this module dependency-light (only `errors.js`) — `args.ts` imports it,
11
+ * so pulling in a heavy leaf module here would drag browser/recorder code into
12
+ * the pure parse/validate path.
13
+ */
14
+ import { invalidInput } from '../errors.js';
15
+
16
+ /** WHATWG-parseable URL check. */
17
+ export function isParseableUrl(value: string): boolean {
18
+ try {
19
+ // eslint-disable-next-line no-new
20
+ new URL(value);
21
+ return true;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ /** Rejects a non-parseable URL as an invocation error (never a leaf runtime error). */
28
+ export function assertParseableUrl(value: string, label: string): void {
29
+ if (!isParseableUrl(value)) throw invalidInput(`Invalid ${label}: ${value}.`);
30
+ }
31
+
32
+ export const SWEEP_NUMERIC_AXES = ['width', 'dpr', 'zoom'] as const;
33
+ export const SWEEP_ENUM_AXES = ['color-scheme', 'reduced-motion'] as const;
34
+ export type SweepAxisName = (typeof SWEEP_NUMERIC_AXES)[number] | (typeof SWEEP_ENUM_AXES)[number];
35
+
36
+ /** Full unsigned/positive finite-number grammar for a sweep numeric bound. Returns
37
+ * the fallback when the token is absent so callers share one predicate. */
38
+ export function parsePositiveNumber(raw: string | undefined, fallback: number, flag: string): number {
39
+ if (raw === undefined) return fallback;
40
+ const value = Number(raw);
41
+ if (!Number.isFinite(value) || value <= 0) throw invalidInput(`${flag} must be a positive number.`);
42
+ return value;
43
+ }
44
+
45
+ const SWEEP_ENUM_VALUES: Record<(typeof SWEEP_ENUM_AXES)[number], readonly [string, string]> = {
46
+ 'color-scheme': ['light', 'dark'],
47
+ 'reduced-motion': ['no-preference', 'reduce'],
48
+ };
49
+
50
+ /** Validates `--from`/`--to` against the axis domain without allocating anything. */
51
+ export function assertSweepBounds(axis: SweepAxisName, from: string | undefined, to: string | undefined): void {
52
+ if ((SWEEP_NUMERIC_AXES as readonly string[]).includes(axis)) {
53
+ parsePositiveNumber(from, 1, '--from');
54
+ parsePositiveNumber(to, 1, '--to');
55
+ return;
56
+ }
57
+ const [a, b] = SWEEP_ENUM_VALUES[axis as (typeof SWEEP_ENUM_AXES)[number]];
58
+ const fromValue = from ?? a;
59
+ const toValue = to ?? b;
60
+ if (![a, b].includes(fromValue) || ![a, b].includes(toValue) || fromValue === toValue) {
61
+ throw invalidInput(`${axis} uses distinct --from/--to values from ${a},${b}.`);
62
+ }
63
+ }
64
+
65
+ /** Shared `top|bottom|px` grammar for page and recorder scroll actions. */
66
+ export function isScrollDestination(value: string): boolean {
67
+ return value === 'top' || value === 'bottom' || (value.trim() !== '' && Number.isFinite(Number(value)));
68
+ }
69
+
70
+ export function assertScrollDestination(value: string): void {
71
+ if (!isScrollDestination(value)) throw invalidInput('Scroll destination must be top, bottom, or a pixel offset.');
72
+ }
73
+
74
+ export type DoAction =
75
+ | { verb: 'click'; target: string }
76
+ | { verb: 'scroll'; target: string; to: string };
77
+
78
+ /**
79
+ * Parses the deliberately narrow one-shot `motion rec --do` action grammar
80
+ * (targets use the unified driving-verb target grammar; `text:` is rejected
81
+ * only later at live resolution, not here). Pure: throws an invocation error
82
+ * on malformed grammar and never touches the filesystem.
83
+ */
84
+ export function parseDoAction(action: string): DoAction {
85
+ if (action.startsWith('click:')) {
86
+ const target = action.slice('click:'.length);
87
+ if (!target) throw invalidInput('Invalid --do action: click requires a target — a css selector, ax:<name>, axid:<id>, or backend:<id>.');
88
+ return { verb: 'click', target };
89
+ }
90
+ if (action.startsWith('scroll:')) {
91
+ const spec = action.slice('scroll:'.length);
92
+ const comma = spec.lastIndexOf(',to=');
93
+ if (comma <= 0) throw invalidInput('Invalid --do action: scroll requires `scroll:<target>,to=<top|bottom|px>`.');
94
+ const to = spec.slice(comma + ',to='.length);
95
+ if (!isScrollDestination(to)) throw invalidInput('Invalid --do action: scroll requires `scroll:<target>,to=<top|bottom|px>`.');
96
+ return { verb: 'scroll', target: spec.slice(0, comma), to };
97
+ }
98
+ throw invalidInput('Unsupported --do action. Supported actions: click:<target>; scroll:<target>,to=<top|bottom|px> — target is a css selector, ax:<name>, axid:<id>, or backend:<id>.');
99
+ }
@@ -0,0 +1,205 @@
1
+ import {
2
+ annotateUnstableFacts,
3
+ readAnimation,
4
+ readAx,
5
+ readGeometry,
6
+ readMedia,
7
+ readMeta,
8
+ readQueries,
9
+ readStyles,
10
+ readText,
11
+ unstableRegionsFor,
12
+ type SnapRef,
13
+ } from '../../output/artifact.js';
14
+ import { fact, line, lineList, text, type FactLine } from '../../output/render.js';
15
+
16
+ export const CENSUS_AXES = ['color', 'font', 'spacing', 'radius', 'shadow', 'animation', 'geometry', 'media', 'queries'] as const;
17
+ export type CensusAxis = (typeof CENSUS_AXES)[number];
18
+
19
+ interface Rect { x: number; y: number; width?: number; height?: number; w?: number; h?: number }
20
+ interface GeometryElement { id: string; selector?: string; rect?: Rect; layout?: Record<string, unknown> }
21
+ interface GeometryReport { elements: GeometryElement[] }
22
+ interface StyleElement { id: string; selector?: string; computed?: Record<string, string | null>; winningDeclarations?: Array<Record<string, unknown>> }
23
+ interface StylesReport { elements: StyleElement[] }
24
+ interface TextElement { id: string; selector?: string; rect?: Rect; font?: { family?: string; size?: string; weight?: string; lineHeight?: string }; platformFonts?: Array<{ familyName?: string }>; lines?: Array<{ baseline?: number | null; rect?: Rect }> }
25
+ interface TextReport { elements: TextElement[] }
26
+ interface AnimationReport { animations: Array<{ id: string; selector?: string | null; animationName?: string | null; durationMs?: number | null; iterationCount?: number | string | null; infinite?: boolean; playState?: string }> }
27
+ interface QueryReport { environment?: Record<string, unknown>; mediaQueries?: Array<Record<string, unknown>>; containerQueries?: Array<Record<string, unknown>> }
28
+
29
+ export interface CensusReport {
30
+ readonly axis: CensusAxis;
31
+ readonly snapshots: readonly SnapRef[];
32
+ readonly distinct: number;
33
+ readonly lines: readonly FactLine[];
34
+ }
35
+
36
+ function count(values: Iterable<string>): Array<[string, number]> {
37
+ const counts = new Map<string, number>();
38
+ for (const value of values) {
39
+ if (value) counts.set(value, (counts.get(value) ?? 0) + 1);
40
+ }
41
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
42
+ }
43
+
44
+ function distribution(label: string, values: Iterable<string>): FactLine {
45
+ const entries = count(values);
46
+ const body = entries.length ? entries.map(([value, n]) => `${value} ×${n}`).join(' · ') : '(none recorded)';
47
+ return fact`${label}: ${body}`;
48
+ }
49
+
50
+ function numeric(value: string | null | undefined): number | undefined {
51
+ if (!value) return undefined;
52
+ const match = /^(-?(?:\d+\.?\d*|\.\d+))px$/.exec(value.trim());
53
+ return match ? Number(match[1]) : undefined;
54
+ }
55
+
56
+ function hexRgb(value: string): [number, number, number] | undefined {
57
+ const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(value.trim());
58
+ if (hex) {
59
+ const s = hex[1].length === 3 ? hex[1].split('').map((c) => c + c).join('') : hex[1];
60
+ return [Number.parseInt(s.slice(0, 2), 16), Number.parseInt(s.slice(2, 4), 16), Number.parseInt(s.slice(4, 6), 16)];
61
+ }
62
+ const rgb = /^rgba?\((\d+),\s*(\d+),\s*(\d+)/i.exec(value.trim());
63
+ return rgb ? [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])] : undefined;
64
+ }
65
+
66
+ function colorDistance(a: string, b: string): number | undefined {
67
+ const aa = hexRgb(a); const bb = hexRgb(b);
68
+ if (!aa || !bb) return undefined;
69
+ return Math.sqrt((aa[0] - bb[0]) ** 2 + (aa[1] - bb[1]) ** 2 + (aa[2] - bb[2]) ** 2) / 255 * 100;
70
+ }
71
+
72
+ function caveated(lines: Array<{ elementId?: string; rect?: { x: number; y: number; w: number; h: number }; line: FactLine }>, snap: SnapRef): FactLine[] {
73
+ return annotateUnstableFacts(lines, unstableRegionsFor(snap)).map(({ fact: item, caveats }) =>
74
+ caveats.length
75
+ ? line(item.line, fact` — nondeterminism caveat: unstable region ${caveats.map((c) => c.regionId).join(', ')}`)
76
+ : item.line,
77
+ );
78
+ }
79
+
80
+ function rectOf(rect: Rect | undefined): { x: number; y: number; w: number; h: number } | undefined {
81
+ if (!rect) return undefined;
82
+ const w = rect.w ?? rect.width; const h = rect.h ?? rect.height;
83
+ return typeof w === 'number' && typeof h === 'number' ? { x: rect.x, y: rect.y, w, h } : undefined;
84
+ }
85
+
86
+ function provenanceLines(styles: StylesReport): FactLine[] {
87
+ const sources: string[] = [];
88
+ for (const element of styles.elements) for (const declaration of element.winningDeclarations ?? []) {
89
+ const authored = declaration.authored as { file?: string; line?: number; column?: number } | undefined;
90
+ const generated = declaration.generated as { sourceURL?: string; line?: number; column?: number } | undefined;
91
+ const source = authored?.file ? `${authored.file}:${authored.line ?? 0}:${authored.column ?? 0}` : generated?.sourceURL ? `${generated.sourceURL}:${generated.line ?? 0}:${generated.column ?? 0}` : declaration.sourceStyleSheetUrl ?? declaration.selector;
92
+ if (typeof source === 'string') sources.push(source);
93
+ }
94
+ return sources.length ? [distribution('Winning declaration provenance', sources)] : [];
95
+ }
96
+
97
+ function nearDuplicateLines(colors: readonly string[]): FactLine[] {
98
+ const distinct = [...new Set(colors)];
99
+ const near: FactLine[] = [];
100
+ for (let i = 0; i < distinct.length; i++) for (let j = i + 1; j < distinct.length; j++) {
101
+ const delta = colorDistance(distinct[i], distinct[j]);
102
+ if (delta !== undefined && delta < 1) near.push(fact`Near-duplicate: ${distinct[i]} and ${distinct[j]} (ΔE-like RGB distance ${delta.toFixed(2)})`);
103
+ }
104
+ return near;
105
+ }
106
+
107
+ function geometryIdentity(element: StyleElement, geometry: GeometryReport): { elementId?: string; rect?: { x: number; y: number; w: number; h: number } } {
108
+ const match = geometry.elements.find((candidate) => candidate.selector && candidate.selector === element.selector);
109
+ return match ? { elementId: match.id, rect: rectOf(match.rect) } : { elementId: element.id };
110
+ }
111
+
112
+ function colorLines(snap: SnapRef, styles: StylesReport, geometry: GeometryReport): FactLine[] {
113
+ const facts: Array<{ elementId?: string; rect?: { x: number; y: number; w: number; h: number }; line: FactLine }> = [];
114
+ const colors: string[] = [];
115
+ for (const e of styles.elements) for (const prop of ['color', 'background-color'] as const) {
116
+ const value = e.computed?.[prop];
117
+ if (value) { colors.push(value); facts.push({ ...geometryIdentity(e, geometry), line: fact`${prop} ${value}` }); }
118
+ }
119
+ return [distribution('Color values', colors), ...nearDuplicateLines(colors), ...caveated(facts.slice(0, 20), snap), ...provenanceLines(styles)];
120
+ }
121
+
122
+ function styleValueLines(snap: SnapRef, styles: StylesReport, geometry: GeometryReport, axis: 'font' | 'spacing' | 'radius' | 'shadow'): FactLine[] {
123
+ const props = axis === 'font' ? ['font-family', 'font-size', 'font-weight', 'line-height'] : axis === 'spacing'
124
+ ? ['margin-top', 'margin-right', 'margin-bottom', 'margin-left', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left']
125
+ : axis === 'radius' ? ['border-radius'] : ['box-shadow', 'text-shadow'];
126
+ const values: string[] = [];
127
+ const facts: Array<{ elementId?: string; line: FactLine }> = [];
128
+ for (const e of styles.elements) for (const prop of props) {
129
+ const value = e.computed?.[prop];
130
+ if (value) { values.push(`${prop}: ${value}`); facts.push({ ...geometryIdentity(e, geometry), line: fact`${prop}: ${value}` }); }
131
+ }
132
+ return [distribution(`${axis} values`, values), ...caveated(facts.slice(0, 20), snap), ...provenanceLines(styles)];
133
+ }
134
+
135
+ function geometryLines(snap: SnapRef, geometry: GeometryReport, textReport: TextReport): FactLine[] {
136
+ const edges = geometry.elements.flatMap((e) => e.rect ? [String(e.rect.x), String(e.rect.y)] : []);
137
+ const sorted = geometry.elements.map((e) => ({ e, r: rectOf(e.rect) })).filter((x): x is { e: GeometryElement; r: { x: number; y: number; w: number; h: number } } => !!x.r).sort((a, b) => a.r.y - b.r.y);
138
+ const gaps: string[] = [];
139
+ for (let i = 1; i < sorted.length; i++) {
140
+ const gap = sorted[i].r.y - (sorted[i - 1].r.y + sorted[i - 1].r.h);
141
+ if (gap >= 0) gaps.push(`${Math.round(gap)}px`);
142
+ }
143
+ const baselines = textReport.elements.flatMap((e) => e.lines?.map((l) => l.baseline).filter((v): v is number => typeof v === 'number') ?? []).map((v) => `${Math.round(v)}px`);
144
+ const grid = geometry.elements.filter((e) => !!(e.layout?.grid)).length;
145
+ const area = sorted.reduce((total, { r }) => total + r.w * r.h, 0);
146
+ const repeated = count(geometry.elements.map((e) => e.selector ?? e.id).filter(Boolean)).filter(([, n]) => n > 1);
147
+ const repeatedLine = repeated.length
148
+ ? fact`Repeated-component selector counts: ${repeated.map(([selector, n]) => `${selector} ×${n}`).join(' · ')}.`
149
+ : text`Repeated-component selector counts: no repeated recorded selectors.`;
150
+ const lines = [distribution('Edge clusters', edges), distribution('Vertical gap distribution', gaps), distribution('Baseline clusters', baselines), fact`Grid occupancy: ${grid} grid-item record(s); summed element box area: ${Math.round(area)}px² (overlapping boxes are counted independently).`, repeatedLine, text`Whitespace/ink: raster coverage is not recorded unless this snapshot includes pixel substrate; geometry reports box-area coverage only.`];
151
+ return [...lines, ...caveated(sorted.slice(0, 20).map(({ e, r }) => ({ elementId: e.id, rect: r, line: fact`Geometry record ${e.selector ?? e.id}: x=${r.x} y=${r.y} w=${r.w} h=${r.h}` })), snap)];
152
+ }
153
+
154
+ function animationLines(snap: SnapRef, report: AnimationReport): FactLine[] {
155
+ const values = report.animations.map((a) => `${a.animationName ?? '(unnamed)'} ${a.durationMs ?? 'unknown'}ms ${a.iterationCount ?? 'unknown'} iterations ${a.playState ?? 'unknown'}`);
156
+ return [fact`Animations: ${report.animations.length}; infinite: ${report.animations.filter((a) => a.infinite).length}.`, distribution('Animation distribution', values), ...caveated(report.animations.map((a) => ({ elementId: a.id, line: fact`${a.selector ?? a.id}: ${a.animationName ?? '(unnamed)'}` })), snap)];
157
+ }
158
+
159
+ function mediaLines(snap: SnapRef): FactLine[] {
160
+ const media = readMedia<{ elements: Array<{ id: string; tag?: string; selector?: string; naturalWidth?: number | null; naturalHeight?: number | null; renderedWidth?: number; renderedHeight?: number; currentSrc?: string | null; rect?: Rect }> }>(snap);
161
+ const values = media.elements.map((m) => `${m.tag ?? 'media'} ${m.naturalWidth ?? '?'}×${m.naturalHeight ?? '?'} → ${m.renderedWidth ?? '?'}×${m.renderedHeight ?? '?'}`);
162
+ return [fact`Replaced-element records: ${media.elements.length}.`, distribution('Media dimensions', values), ...caveated(media.elements.map((m) => ({ elementId: m.id, rect: rectOf(m.rect), line: fact`${m.selector ?? m.id}: ${m.currentSrc ?? '(no current source)'}` })), snap)];
163
+ }
164
+
165
+ function queryLines(report: QueryReport): FactLine[] {
166
+ const media = report.mediaQueries ?? []; const containers = report.containerQueries ?? [];
167
+ return [fact`Environment: ${JSON.stringify(report.environment ?? {})}`, fact`Active media-query records: ${media.length}; container-query records: ${containers.length}.`, distribution('Media query conditions', media.map((q) => String(q.query ?? q.error ?? 'unknown'))), distribution('Container query conditions', containers.map((q) => String(q.query ?? 'unknown')))];
168
+ }
169
+
170
+ function roleLines(snap: SnapRef): FactLine[] {
171
+ const ax = readAx<{ nodes?: Array<{ role?: { value?: string } | string; ignored?: boolean }>; axNodes?: Array<{ role?: { value?: string } | string; ignored?: boolean }> }>(snap);
172
+ const nodes = ax.nodes ?? ax.axNodes ?? [];
173
+ const roles = nodes.filter((node) => !node.ignored).map((node) => typeof node.role === 'string' ? node.role : node.role?.value ?? 'unknown');
174
+ return [distribution('Accessibility role instances', roles)];
175
+ }
176
+
177
+ /** Builds a factual census from already-resolved snapshot artifacts. */
178
+ export function buildCensus(axis: CensusAxis, snapshots: readonly SnapRef[]): CensusReport {
179
+ const lines: FactLine[] = [];
180
+ // A multi-snapshot census has an explicit set-level distribution in addition
181
+ // to the per-page measurements below; page summaries retain route context.
182
+ if (axis === 'color') {
183
+ const values = snapshots.flatMap((snap) => readStyles<StylesReport>(snap).elements.flatMap((e) => ['color', 'background-color'].map((prop) => e.computed?.[prop]).filter((v): v is string => !!v)));
184
+ lines.push(distribution('Color values across snapshots', values), ...nearDuplicateLines(values));
185
+ }
186
+ for (const snap of snapshots) {
187
+ const geometry = readGeometry<GeometryReport>(snap);
188
+ const styles = readStyles<StylesReport>(snap);
189
+ const textReport = readText<TextReport>(snap);
190
+ const meta = readMeta<{ settled?: boolean }>(snap);
191
+ lines.push(fact`Snapshot ${snap.id}: ${geometry.elements.length} geometry records; settled=${meta.settled === true ? 'true' : 'false'}.`);
192
+ if (axis === 'color') lines.push(...colorLines(snap, styles, geometry));
193
+ else if (axis === 'font' || axis === 'spacing' || axis === 'radius' || axis === 'shadow') lines.push(...styleValueLines(snap, styles, geometry, axis));
194
+ else if (axis === 'geometry') lines.push(...geometryLines(snap, geometry, textReport), ...roleLines(snap));
195
+ else if (axis === 'animation') lines.push(...animationLines(snap, readAnimation<AnimationReport>(snap)));
196
+ else if (axis === 'queries') lines.push(...queryLines(readQueries<QueryReport>(snap)));
197
+ else if (axis === 'media') lines.push(...mediaLines(snap));
198
+ }
199
+ const distinct = new Set(lines.map((l) => JSON.stringify(l))).size;
200
+ return { axis, snapshots, distinct, lines };
201
+ }
202
+
203
+ export function censusResultLines(report: CensusReport): FactLine {
204
+ return lineList(report.lines.length ? report.lines : [text`No census facts were recorded.`]);
205
+ }
@@ -0,0 +1,318 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { PNG } from 'pngjs';
4
+
5
+ import type { SnapRef, Rect } from '../../output/artifact.js';
6
+ import { artifactExists, artifactPath, readAnimation, readForms, readGeometry, readHittest, readMeta, readText, unstableRegionsFor, annotateUnstableFacts } from '../../output/artifact.js';
7
+ import { writeBinaryPrivate } from '../../session/artifacts.js';
8
+
9
+ export const CHECK_NAMES = ['overlap', 'offscreen', 'overflow', 'tap-targets', 'contrast', 'hit-test', 'truncation', 'forms', 'media', 'animation'] as const;
10
+ export type CheckName = (typeof CHECK_NAMES)[number];
11
+
12
+ export interface CheckFinding {
13
+ readonly kind: CheckName;
14
+ readonly elementId?: string;
15
+ readonly selector?: string;
16
+ readonly rect?: Rect;
17
+ readonly detail: string;
18
+ readonly provenance?: string;
19
+ readonly crop?: string;
20
+ readonly caveats: readonly { readonly regionId: string; readonly selector?: string; readonly reason?: string }[];
21
+ }
22
+
23
+ interface GeometryElement {
24
+ id: string;
25
+ selector?: string;
26
+ domPath?: string;
27
+ tag?: string;
28
+ backendNodeId?: number | null;
29
+ rect: { x: number; y: number; width: number; height: number };
30
+ visibility?: { visible?: boolean; opacity?: number };
31
+ zIndex?: string;
32
+ stackingContext?: { creates?: boolean; reasons?: string[] };
33
+ clipping?: { clippedBy?: string; clippedFraction?: number } | null;
34
+ layout?: { scrollWidth?: number; clientWidth?: number; scrollHeight?: number; clientHeight?: number; position?: string; overflowX?: string };
35
+ }
36
+
37
+ function rectOf(r: { x: number; y: number; width: number; height: number }): Rect { return { x: r.x, y: r.y, w: r.width, h: r.height }; }
38
+ function intersects(a: GeometryElement['rect'], b: GeometryElement['rect']): boolean { return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y; }
39
+ function label(e: { selector?: string; id?: string }): string { return e.selector || e.id || '(unidentified element)'; }
40
+
41
+ // --- Overlap: opaque direct-sibling intersection detection ------------------
42
+ // The naive all-pairs intersection reports every ancestor–descendant pair
43
+ // (a child rect sits inside its parent rect) AND every cousin/descendant-of-
44
+ // sibling pair, flooding the report. A meaningful overlap is between two
45
+ // DIRECT DOM siblings (same parent) whose rects intersect and whose actual
46
+ // top painter — resolved from the artifact's authoritative paint order — is
47
+ // effectively opaque, i.e. a real visual occlusion, per the design contract's
48
+ // "opaque sibling intersections".
49
+
50
+ /** The parent domPath of an element: everything before the final `/` segment.
51
+ * `body[0]/div[1]/span[0]` → `body[0]/div[1]`; a root-level `body[0]` → `''`.
52
+ * Undefined when domPath is absent (sibling relationship unprovable). */
53
+ function parentPath(domPath?: string): string | undefined {
54
+ if (domPath == null) return undefined;
55
+ const i = domPath.lastIndexOf('/');
56
+ return i < 0 ? '' : domPath.slice(0, i);
57
+ }
58
+
59
+ /** True when `a` and `b` are DIRECT DOM siblings — same parent domPath,
60
+ * distinct elements. Requires both domPaths: without them the sibling
61
+ * relationship cannot be proven, so the pair is not reported. This alone
62
+ * excludes ancestor–descendant containment (an ancestor and descendant never
63
+ * share a parent) and cousin / descendant-of-sibling pairs (different
64
+ * parents), collapsing what were previously many noisy cross-subtree findings
65
+ * to the single direct-sibling intersection that actually occludes. */
66
+ function areDirectSiblings(a: GeometryElement, b: GeometryElement): boolean {
67
+ const pa = parentPath(a.domPath), pb = parentPath(b.domPath);
68
+ return pa !== undefined && pb !== undefined && pa === pb && a.domPath !== b.domPath;
69
+ }
70
+
71
+ /** Parse a CSS alpha token — a bare number (`0.8`) or a percentage (`50%`). */
72
+ function parseAlphaToken(token: string): number {
73
+ const t = token.trim();
74
+ return t.endsWith('%') ? Number(t.slice(0, -1)) / 100 : Number(t);
75
+ }
76
+
77
+ /** Alpha of a CSS color string across the syntaxes a real browser computes:
78
+ * legacy comma `rgba(r,g,b,a)`/`hsla(...)` (fourth channel), hex (`#RGBA`/
79
+ * `#RRGGBBAA` alpha nibble/byte, else 1), and modern space-separated color
80
+ * functions — `rgb(r g b / a)`, `oklch(l c h / a)`, `oklab(l a b / a)`,
81
+ * `hsl`, `hwb`, `lab`, `lch`, `color(...)` — whose optional alpha follows a
82
+ * `/`. A recognized color with no alpha component is opaque (1);
83
+ * `transparent`/empty is 0. Undefined when the value is unrecognized so the
84
+ * caller can treat opacity as unknown. */
85
+ function colorAlpha(value: string | null | undefined): number | undefined {
86
+ if (value == null) return undefined;
87
+ const v = value.trim().toLowerCase();
88
+ if (v === 'transparent' || v === '') return 0;
89
+ const hex = /^#([0-9a-f]+)$/.exec(v);
90
+ if (hex) {
91
+ const h = hex[1];
92
+ if (h.length === 4) return parseInt(h[3] + h[3], 16) / 255;
93
+ if (h.length === 8) return parseInt(h.slice(6, 8), 16) / 255;
94
+ if (h.length === 3 || h.length === 6) return 1;
95
+ return undefined;
96
+ }
97
+ const fn = /^[a-z-]+\((.*)\)$/.exec(v);
98
+ if (fn) {
99
+ const inner = fn[1];
100
+ if (inner.includes(',')) {
101
+ // Legacy comma form: rgba/hsla carry alpha as the fourth channel.
102
+ const parts = inner.split(',');
103
+ if (parts.length >= 4) { const a = parseAlphaToken(parts[3]); return Number.isFinite(a) ? Math.min(1, Math.max(0, a)) : undefined; }
104
+ return 1;
105
+ }
106
+ // Modern space form: alpha, when present, follows a `/`.
107
+ const slash = inner.split('/');
108
+ if (slash.length >= 2) { const a = parseAlphaToken(slash[slash.length - 1]); return Number.isFinite(a) ? Math.min(1, Math.max(0, a)) : undefined; }
109
+ return 1;
110
+ }
111
+ if (/^[a-z]+$/.test(v)) return 1;
112
+ return undefined;
113
+ }
114
+
115
+ interface BackgroundMap { byNode: Map<number, number>; bySelector: Map<string, number> }
116
+
117
+ /** Per-element background alpha, keyed by backendNodeId and selector, read
118
+ * from styles.json. Empty when styles.json is unavailable (overlap then
119
+ * reports nothing rather than flooding — an opaque occluder cannot be proven
120
+ * without the computed background). */
121
+ function backgroundAlphaMap(ref: SnapRef): BackgroundMap {
122
+ const byNode = new Map<number, number>();
123
+ const bySelector = new Map<string, number>();
124
+ try {
125
+ const styles = readRequired<{ elements?: Array<{ selector?: string; backendNodeId?: number | null; computed?: Record<string, string | null> }> }>(ref, 'styles.json');
126
+ for (const s of styles.elements ?? []) {
127
+ const alpha = colorAlpha(s.computed?.backgroundColor ?? s.computed?.['background-color']);
128
+ if (alpha === undefined) continue;
129
+ if (s.backendNodeId != null) byNode.set(s.backendNodeId, alpha);
130
+ if (s.selector) bySelector.set(s.selector, alpha);
131
+ }
132
+ } catch { /* styles.json unavailable — leave maps empty */ }
133
+ return { byNode, bySelector };
134
+ }
135
+
136
+ /** Effective opacity of an element: its own `opacity` multiplied by that of
137
+ * every DOM ancestor (opacity establishes a group whose transparency applies
138
+ * to the whole subtree). Walks the ancestor chain by domPath prefix. An
139
+ * element is only a full occluder when its effective opacity is 1. */
140
+ function effectiveOpacity(e: GeometryElement, byPath: Map<string, GeometryElement>): number {
141
+ let opacity = e.visibility?.opacity ?? 1;
142
+ let p = parentPath(e.domPath);
143
+ while (p) {
144
+ const ancestor = byPath.get(p);
145
+ if (!ancestor) break;
146
+ opacity *= ancestor.visibility?.opacity ?? 1;
147
+ p = parentPath(ancestor.domPath);
148
+ }
149
+ return opacity;
150
+ }
151
+
152
+ /** An element occludes what it overlaps only when it is effectively fully
153
+ * opaque (own + ancestor opacity all 1) AND paints an opaque background. */
154
+ function isOpaque(e: GeometryElement, bg: BackgroundMap, byPath: Map<string, GeometryElement>): boolean {
155
+ if (effectiveOpacity(e, byPath) < 1) return false;
156
+ const alpha = (e.backendNodeId != null ? bg.byNode.get(e.backendNodeId) : undefined) ?? (e.selector ? bg.bySelector.get(e.selector) : undefined);
157
+ return alpha !== undefined && alpha >= 1;
158
+ }
159
+
160
+ /** The artifact's AUTHORITATIVE paint order (`layers.json.paintOrder`): a
161
+ * back-to-front list of backendNodeIds, mapped to their paint index (higher =
162
+ * painted later = on top). This is Chrome's real DOMSnapshot paint order,
163
+ * sound across stacking contexts and CSS paint phases — unlike a global
164
+ * z-index guess. Empty when layers.json lacks a resolved paint order, in which
165
+ * case no occlusion can be proven and overlap reports nothing. */
166
+ function paintOrderMap(ref: SnapRef): Map<number, number> {
167
+ try {
168
+ const layers = readRequired<{ paintOrder?: { available?: boolean; backendNodeIds?: number[] } }>(ref, 'layers.json');
169
+ const po = layers.paintOrder;
170
+ const index = new Map<number, number>();
171
+ if (po?.available && Array.isArray(po.backendNodeIds)) po.backendNodeIds.forEach((id, i) => index.set(id, i));
172
+ return index;
173
+ } catch { return new Map(); }
174
+ }
175
+
176
+ /** Whether `a` paints above `b`, resolved SOLELY from the artifact's
177
+ * authoritative paint order. Returns undefined when either element is absent
178
+ * from that order: DOM order is not paint order for positioned/z-indexed
179
+ * elements or stacking contexts, so without authoritative evidence for both
180
+ * the top painter is unknowable and the tool must not claim an occlusion. */
181
+ function paintsAbove(a: GeometryElement, b: GeometryElement, paint: Map<number, number>): boolean | undefined {
182
+ const ia = a.backendNodeId != null ? paint.get(a.backendNodeId) : undefined;
183
+ const ib = b.backendNodeId != null ? paint.get(b.backendNodeId) : undefined;
184
+ if (ia === undefined || ib === undefined) return undefined;
185
+ return ia > ib;
186
+ }
187
+
188
+ export function parseChecks(value?: string): CheckName[] {
189
+ if (!value || value === 'all') return [...CHECK_NAMES];
190
+ const categories: Record<string, CheckName[]> = {
191
+ geometry: ['overlap', 'offscreen', 'overflow', 'tap-targets'],
192
+ content: ['truncation', 'media'],
193
+ targetability: ['tap-targets', 'contrast', 'hit-test'],
194
+ forms: ['forms'],
195
+ animation: ['animation'],
196
+ };
197
+ const selected = new Set<CheckName>();
198
+ for (const part of value.split(',').map((x) => x.trim()).filter(Boolean)) {
199
+ if (part in categories) categories[part].forEach((check) => selected.add(check));
200
+ else if ((CHECK_NAMES as readonly string[]).includes(part)) selected.add(part as CheckName);
201
+ else throw new Error(`unknown check ${JSON.stringify(part)}; use geometry, content, targetability, forms, animation, all, or ${CHECK_NAMES.join(', ')}`);
202
+ }
203
+ return [...selected];
204
+ }
205
+
206
+ function viewport(meta: { viewport?: string | null }, elements: GeometryElement[]): { width: number; height: number } {
207
+ const match = /^\s*(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)\s*$/i.exec(meta.viewport ?? '');
208
+ if (match) return { width: Number(match[1]), height: Number(match[2]) };
209
+ return { width: Math.max(0, ...elements.map((e) => e.rect.x + e.rect.width)), height: Math.max(0, ...elements.map((e) => e.rect.y + e.rect.height)) };
210
+ }
211
+
212
+ function readRequired<T>(ref: SnapRef, filename: string): T {
213
+ return JSON.parse(fs.readFileSync(artifactPath(ref, filename), 'utf8')) as T;
214
+ }
215
+
216
+ function finding(kind: CheckName, element: GeometryElement | undefined, detail: string, provenance?: string): Omit<CheckFinding, 'caveats'> {
217
+ return { kind, elementId: element?.id, selector: element?.selector, rect: element ? rectOf(element.rect) : undefined, detail, provenance };
218
+ }
219
+
220
+ function rgb(value: string | null | undefined): [number, number, number] | undefined {
221
+ const m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i.exec(value ?? '');
222
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined;
223
+ }
224
+ function contrastRatio(foreground: [number, number, number], background: [number, number, number]): number {
225
+ const lum = (channels: [number, number, number]) => channels.reduce((sum, channel, index) => { const v = channel / 255; return sum + (v <= .03928 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4) * [0.2126, 0.7152, 0.0722][index]; }, 0);
226
+ const a = lum(foreground), b = lum(background);
227
+ return (Math.max(a, b) + .05) / (Math.min(a, b) + .05);
228
+ }
229
+
230
+ /** Read-only analysis over a completed snapshot. Browser driving belongs solely to measure snap. */
231
+ export function checkSnapshot(ref: SnapRef, requested: readonly CheckName[]): { findings: CheckFinding[]; elementCount: number; settled: boolean; viewport: { width: number; height: number } } {
232
+ const geometry = readGeometry<{ elements: GeometryElement[] }>(ref);
233
+ const elements = geometry.elements ?? [];
234
+ const meta = readMeta<{ settled: boolean; viewport?: string | null }>(ref);
235
+ const selected = new Set(requested);
236
+ const findings: Array<Omit<CheckFinding, 'caveats'>> = [];
237
+ const vp = viewport(meta, elements);
238
+ const visible = elements.filter((e) => e.visibility?.visible !== false && e.rect.width > 0 && e.rect.height > 0);
239
+
240
+ if (selected.has('overlap')) {
241
+ const bg = backgroundAlphaMap(ref);
242
+ const paint = paintOrderMap(ref);
243
+ const byPath = new Map<string, GeometryElement>();
244
+ for (const e of elements) if (e.domPath) byPath.set(e.domPath, e);
245
+ for (let i = 0; i < visible.length; i++) for (let j = i + 1; j < visible.length; j++) {
246
+ const a = visible[i], b = visible[j];
247
+ if (!intersects(a.rect, b.rect)) continue;
248
+ if (!areDirectSiblings(a, b)) continue; // only direct-sibling intersections are reported; cousins/descendants are noise
249
+ const above = paintsAbove(a, b, paint);
250
+ if (above === undefined) continue; // top painter unprovable without authoritative paint order for BOTH — never infer occlusion from DOM order
251
+ const [over, under] = above ? [a, b] : [b, a];
252
+ if (!isOpaque(over, bg, byPath)) continue; // the actual TOP painter must be opaque to occlude what is under it
253
+ const ix = Math.max(a.rect.x, b.rect.x), iy = Math.max(a.rect.y, b.rect.y);
254
+ const iw = Math.min(a.rect.x + a.rect.width, b.rect.x + b.rect.width) - ix;
255
+ const ih = Math.min(a.rect.y + a.rect.height, b.rect.y + b.rect.height) - iy;
256
+ const underArea = under.rect.width * under.rect.height;
257
+ const pct = underArea > 0 ? Math.round((iw * ih) / underArea * 100) : 0;
258
+ const stacking = over.stackingContext?.creates ? `; ${label(over)} creates a stacking context` : '';
259
+ findings.push(finding('overlap', under,
260
+ `${label(under)} ${pct}% occluded by ${label(over)}; ${label(under)} x=${under.rect.x} y=${under.rect.y} w=${under.rect.width} h=${under.rect.height} and ${label(over)} x=${over.rect.x} y=${over.rect.y} w=${over.rect.width} h=${over.rect.height}; overlap ${iw}×${ih}px`,
261
+ `${label(over)} paints above ${label(under)} in DOMSnapshot paint order${stacking}`));
262
+ }
263
+ }
264
+ if (selected.has('offscreen')) for (const e of visible) {
265
+ const insideW = Math.max(0, Math.min(e.rect.x + e.rect.width, vp.width) - Math.max(e.rect.x, 0));
266
+ const insideH = Math.max(0, Math.min(e.rect.y + e.rect.height, vp.height) - Math.max(e.rect.y, 0));
267
+ if (insideW < e.rect.width || insideH < e.rect.height) findings.push(finding('offscreen', e, `${label(e)} has ${insideW}×${insideH}px inside viewport ${vp.width}×${vp.height}; rect x=${e.rect.x} y=${e.rect.y} w=${e.rect.width} h=${e.rect.height}`));
268
+ }
269
+ if (selected.has('overflow')) for (const e of elements) if ((e.layout?.scrollWidth ?? 0) > (e.layout?.clientWidth ?? 0)) findings.push(finding('overflow', e, `${label(e)} scrollWidth ${e.layout!.scrollWidth}px > clientWidth ${e.layout!.clientWidth}px`, `overflow-x ${e.layout?.overflowX ?? 'unavailable'}`));
270
+ if (selected.has('tap-targets')) for (const e of visible) if (/^(button|a|input|select|textarea)$/i.test((e as { tag?: string }).tag ?? '') && (e.rect.width < 44 || e.rect.height < 44)) findings.push(finding('tap-targets', e, `${label(e)} measures ${e.rect.width}×${e.rect.height}px; threshold is 44×44px`));
271
+
272
+ if (selected.has('contrast')) {
273
+ const styles = readRequired<{ elements?: Array<{ selector?: string; backendNodeId?: number | null; computed?: Record<string, string | null> }> }>(ref, 'styles.json');
274
+ for (const style of styles.elements ?? []) {
275
+ const foreground = rgb(style.computed?.color);
276
+ const background = rgb(style.computed?.backgroundColor ?? style.computed?.['background-color']);
277
+ if (!foreground || !background) continue;
278
+ const ratio = contrastRatio(foreground, background);
279
+ if (ratio < 4.5) {
280
+ const e = elements.find((x) => (style.backendNodeId != null && x.backendNodeId === style.backendNodeId) || x.selector === style.selector);
281
+ findings.push(finding('contrast', e, `${style.selector ?? label(e ?? {})} foreground ${style.computed?.color} against background ${style.computed?.backgroundColor ?? style.computed?.['background-color']} has contrast ratio ${ratio.toFixed(2)}:1`, 'computed color and background-color'));
282
+ }
283
+ }
284
+ }
285
+ if (selected.has('hit-test')) {
286
+ const hit = readHittest<{ elements?: Array<{ selector?: string; backendNodeId?: number | null; selfHitCount?: number; selfHitTotal?: number; points?: Array<{ result?: { topReceiver?: { selector?: string } | null; x?: number; y?: number; stack?: Array<{ selector?: string; pointerEvents?: string; opacity?: number }> } }> }> }>(ref);
287
+ for (const sample of hit.elements ?? []) if ((sample.selfHitCount ?? 0) < (sample.selfHitTotal ?? 0)) {
288
+ const e = elements.find((x) => (sample.backendNodeId != null && x.backendNodeId === sample.backendNodeId) || (sample.selector && x.selector === sample.selector));
289
+ const point = sample.points?.find((p) => p.result?.topReceiver?.selector !== sample.selector)?.result;
290
+ findings.push(finding('hit-test', e, `${label(e ?? sample)} resolves ${sample.selfHitCount ?? 0} of ${sample.selfHitTotal ?? 0} sampled points to itself${point ? `; sampled point (${point.x},${point.y}) receiver ${point.topReceiver?.selector ?? 'none'}` : ''}`, point?.stack?.map((x) => `${x.selector ?? x.pointerEvents ?? 'element'}${x.opacity === 0 ? ' opacity 0' : ''}`).join(', ')));
291
+ }
292
+ }
293
+ if (selected.has('truncation')) for (const t of readText<{ elements?: Array<{ selector?: string; backendNodeId?: number | null; truncated?: boolean; scrollWidth?: number; clientWidth?: number }> }>(ref).elements ?? []) if (t.truncated) {
294
+ const e = elements.find((x) => (t.backendNodeId != null && x.backendNodeId === t.backendNodeId) || x.selector === t.selector);
295
+ findings.push(finding('truncation', e, `${t.selector ?? label(e ?? {})} scrollWidth ${t.scrollWidth ?? 0}px > clientWidth ${t.clientWidth ?? 0}px`));
296
+ }
297
+ if (selected.has('forms')) for (const control of readForms<{ controls?: Array<{ selector?: string; rect?: { x: number; y: number; width: number; height: number }; disabled?: boolean }> }>(ref).controls ?? []) if (control.rect && (control.rect.width === 0 || control.rect.height === 0 || control.rect.x + control.rect.width < 0 || control.rect.y + control.rect.height < 0 || control.rect.x > vp.width || control.rect.y > vp.height)) findings.push({ kind: 'forms', selector: control.selector, rect: rectOf(control.rect), detail: `${control.selector ?? 'form control'} rect x=${control.rect.x} y=${control.rect.y} w=${control.rect.width} h=${control.rect.height} is outside or zero-sized in viewport ${vp.width}×${vp.height}` });
298
+ if (selected.has('media')) for (const media of readRequired<{ elements?: Array<{ selector?: string; id?: string; rect: { x: number; y: number; width: number; height: number }; visible?: boolean | null; naturalWidth?: number | null; naturalHeight?: number | null; renderedWidth?: number; renderedHeight?: number; decodeState?: string | null; crop?: unknown }> }>(ref, 'media.json').elements ?? []) if (media.visible === false || (media.naturalWidth === 0 || media.naturalHeight === 0) || media.decodeState === 'loading') findings.push({ kind: 'media', elementId: media.id, selector: media.selector, rect: rectOf(media.rect), detail: `${media.selector ?? media.id ?? 'media element'} visible=${String(media.visible)} natural=${media.naturalWidth ?? 'unavailable'}×${media.naturalHeight ?? 'unavailable'} rendered=${media.renderedWidth ?? media.rect.width}×${media.renderedHeight ?? media.rect.height} decode=${media.decodeState ?? 'unavailable'}` });
299
+ if (selected.has('animation')) for (const animation of readAnimation<{ animations?: Array<{ id?: string; selector?: string | null; infinite?: boolean; durationMs?: number | null; iterationCount?: number | string | null; playState?: string }> }>(ref).animations ?? []) if (animation.infinite) findings.push({ kind: 'animation', elementId: animation.id, selector: animation.selector ?? undefined, detail: `${animation.selector ?? animation.id ?? 'animation'} duration ${animation.durationMs ?? 'unavailable'}ms, iteration-count ${animation.iterationCount ?? 'unavailable'}, playState ${animation.playState ?? 'unavailable'}` });
300
+
301
+ const regions = unstableRegionsFor(ref);
302
+ const annotated = annotateUnstableFacts(findings.map((f) => ({ ...f, elementId: f.elementId, rect: f.rect })), regions);
303
+ return { findings: annotated.map(({ fact, caveats }) => ({ ...fact, caveats })), elementCount: elements.length, settled: meta.settled, viewport: vp };
304
+ }
305
+
306
+ /** Writes a bounded screenshot crop for a finding and returns its id-relative path. */
307
+ export function writeFindingCrop(ref: SnapRef, finding: CheckFinding, index: number): string | undefined {
308
+ if (!finding.rect || !artifactExists(ref, 'screenshot.png')) return undefined;
309
+ const source = PNG.sync.read(fs.readFileSync(artifactPath(ref, 'screenshot.png')));
310
+ const x = Math.max(0, Math.floor(finding.rect.x)); const y = Math.max(0, Math.floor(finding.rect.y));
311
+ const w = Math.max(1, Math.min(source.width - x, Math.ceil(finding.rect.w))); const h = Math.max(1, Math.min(source.height - y, Math.ceil(finding.rect.h)));
312
+ if (x >= source.width || y >= source.height || w <= 0 || h <= 0) return undefined;
313
+ const crop = new PNG({ width: w, height: h });
314
+ PNG.bitblt(source, crop, x, y, w, h, 0, 0);
315
+ const name = `${index + 1}-${finding.kind}.png`;
316
+ writeBinaryPrivate(path.join(ref.dir, 'findings', name), PNG.sync.write(crop));
317
+ return `${ref.id}/findings/${name}`;
318
+ }