@crouton-kit/capture 1.3.7 → 1.3.9

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 (141) hide show
  1. package/bin/capture +32786 -9989
  2. package/package.json +9 -3
  3. package/src/capture.ts +86 -163
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -116
  6. package/src/cdp/bridge/protocol.ts +187 -1
  7. package/src/cdp/bridge/server.ts +211 -84
  8. package/src/cdp/bridge/spawn.ts +110 -1
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +35 -4
  11. package/src/cdp/commands/cdp.ts +266 -69
  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 +87 -0
  15. package/src/cdp/commands/measure/check.ts +109 -0
  16. package/src/cdp/commands/measure/diff.ts +120 -0
  17. package/src/cdp/commands/measure/explain.ts +133 -0
  18. package/src/cdp/commands/measure/index.ts +106 -0
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +52 -0
  21. package/src/cdp/commands/measure/map-layers.ts +59 -0
  22. package/src/cdp/commands/measure/map-scroll.ts +94 -0
  23. package/src/cdp/commands/measure/snap.ts +416 -0
  24. package/src/cdp/commands/measure/sweep.ts +227 -0
  25. package/src/cdp/commands/motion/index.ts +61 -0
  26. package/src/cdp/commands/motion/jank.ts +132 -0
  27. package/src/cdp/commands/motion/mask.ts +92 -0
  28. package/src/cdp/commands/motion/rec.ts +534 -0
  29. package/src/cdp/commands/motion/response.ts +76 -0
  30. package/src/cdp/commands/motion/timeline.ts +152 -0
  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/coordinates.ts +158 -0
  46. package/src/cdp/detect.ts +42 -13
  47. package/src/cdp/dispatch.ts +76 -26
  48. package/src/cdp/domains.ts +75 -0
  49. package/src/cdp/har-recorder.ts +648 -115
  50. package/src/cdp/leaf-grammar.ts +99 -0
  51. package/src/cdp/measure/census.ts +205 -0
  52. package/src/cdp/measure/check.ts +318 -0
  53. package/src/cdp/measure/collectors/animation.ts +385 -0
  54. package/src/cdp/measure/collectors/ax.ts +278 -0
  55. package/src/cdp/measure/collectors/focus.ts +1041 -0
  56. package/src/cdp/measure/collectors/forms.ts +905 -0
  57. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  58. package/src/cdp/measure/collectors/hittest.ts +835 -0
  59. package/src/cdp/measure/collectors/layers.ts +697 -0
  60. package/src/cdp/measure/collectors/media.ts +522 -0
  61. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  62. package/src/cdp/measure/collectors/queries.ts +464 -0
  63. package/src/cdp/measure/collectors/scroll.ts +599 -0
  64. package/src/cdp/measure/collectors/states.ts +992 -0
  65. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  66. package/src/cdp/measure/collectors/styles.ts +355 -0
  67. package/src/cdp/measure/collectors/text.ts +713 -0
  68. package/src/cdp/measure/diff.ts +470 -0
  69. package/src/cdp/measure/explain.ts +498 -0
  70. package/src/cdp/measure/map-ax.ts +633 -0
  71. package/src/cdp/measure/map-focus.ts +188 -0
  72. package/src/cdp/measure/map-layers.ts +177 -0
  73. package/src/cdp/measure/map-scroll.ts +317 -0
  74. package/src/cdp/measure/redaction.ts +51 -0
  75. package/src/cdp/measure/settle.ts +1051 -0
  76. package/src/cdp/measure/snapshot.ts +390 -0
  77. package/src/cdp/measure/sweep.ts +330 -0
  78. package/src/cdp/measure/types.ts +220 -0
  79. package/src/cdp/measure/v2/collectors.ts +19 -0
  80. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  81. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  82. package/src/cdp/measure/v2/target-lock.ts +15 -0
  83. package/src/cdp/measure/v2/target.ts +16 -0
  84. package/src/cdp/motion/jank.ts +433 -0
  85. package/src/cdp/motion/mask.ts +421 -0
  86. package/src/cdp/motion/recorder.ts +764 -0
  87. package/src/cdp/motion/response.ts +356 -0
  88. package/src/cdp/motion/timeline.ts +215 -0
  89. package/src/cdp/record.ts +5 -212
  90. package/src/cdp/recorder-bridge.ts +1894 -0
  91. package/src/cdp/recorder-client.ts +326 -0
  92. package/src/cdp/scope-lock.ts +73 -0
  93. package/src/cdp/screenshot.ts +199 -66
  94. package/src/cdp/source-map.ts +309 -0
  95. package/src/cdp/targets.ts +21 -1
  96. package/src/cdp/timing.ts +87 -0
  97. package/src/cdp/types.ts +87 -6
  98. package/src/cdp/viewport.ts +21 -0
  99. package/src/cdp.ts +12 -58
  100. package/src/command-help.ts +29 -0
  101. package/src/command-resolution.ts +22 -0
  102. package/src/command-walker.ts +35 -0
  103. package/src/contracts/a11y.ts +33 -0
  104. package/src/contracts/cursor.ts +35 -0
  105. package/src/contracts/index.ts +15 -0
  106. package/src/contracts/motion.ts +19 -0
  107. package/src/contracts/neutral.ts +56 -0
  108. package/src/contracts/primitives.ts +205 -0
  109. package/src/contracts/release.ts +17 -0
  110. package/src/contracts/results.ts +208 -0
  111. package/src/contracts/routes.ts +140 -0
  112. package/src/contracts/snapshot.ts +92 -0
  113. package/src/cursor/index.ts +13 -0
  114. package/src/cursor/service.ts +310 -0
  115. package/src/errors.ts +71 -0
  116. package/src/har-manager.ts +992 -41
  117. package/src/interact.ts +410 -98
  118. package/src/output/artifact-lifecycle.ts +371 -0
  119. package/src/output/artifact.ts +526 -0
  120. package/src/output/diff.ts +111 -0
  121. package/src/output/exact-raw.ts +114 -0
  122. package/src/output/render.ts +987 -0
  123. package/src/output/selector.ts +185 -0
  124. package/src/output/structured.ts +211 -0
  125. package/src/registry.ts +208 -0
  126. package/src/session/artifacts.ts +481 -0
  127. package/src/session/commands.ts +1237 -368
  128. package/src/session/coordinator.ts +251 -0
  129. package/src/session/har-filter.ts +57 -0
  130. package/src/session/log-tailer.ts +653 -0
  131. package/src/session-context.ts +302 -34
  132. package/src/vault/bundle.ts +32 -17
  133. package/src/cdp/commands/exec.ts +0 -105
  134. package/src/cdp/commands/har.ts +0 -128
  135. package/src/cdp/commands/tabs.ts +0 -183
  136. package/src/cdp/commands/traffic.ts +0 -121
  137. package/src/cdp/commands/ui.ts +0 -172
  138. package/src/cdp/execute.ts +0 -113
  139. package/src/cdp/har-output.ts +0 -47
  140. package/src/cdp/usage.ts +0 -77
  141. package/src/tab-session.ts +0 -91
@@ -0,0 +1,87 @@
1
+ import * as fs from 'fs';
2
+
3
+ import { type ParsedArgs } from '../../types.js';
4
+ import { captureMeasureSnap } from './snap.js';
5
+ import { ArtifactResolutionError, resolveSnapRef, type SnapRef } from '../../../output/artifact.js';
6
+ import { emitResult, fact, formatArtifactList, text, type RenderableResult } from '../../../output/render.js';
7
+ import { buildCensus, CENSUS_AXES, censusResultLines, type CensusAxis } from '../../measure/census.js';
8
+
9
+ const USAGE = `capture measure census --axis <axis> — value distributions and token-audit facts across one or more settled snapshots
10
+
11
+ input:
12
+ --axis <axis> color|font|spacing|radius|shadow|animation|geometry|media|queries (required)
13
+ --snap <id|path> existing snapshot id or absolute artifact path (repeatable)
14
+ --url <url> URL to snap first (repeatable)
15
+ --set-file <path> file listing one snapshot id/path or URL per line
16
+ output: <census axis=… snapshots=… distinct=…> — distributions with provenance where recorded, and per-region nondeterminism caveats for facts touching an unsettled capture; --json mirrors
17
+ effects: read-only over existing snapshot artifacts; each --url writes one one-shot snapshot first`;
18
+
19
+ function targetsFromFile(filePath: string): string[] {
20
+ const raw = fs.readFileSync(filePath, 'utf8');
21
+ return raw.split(/\r?\n/).map((line) => line.trim()).filter((line) => line && !line.startsWith('#'));
22
+ }
23
+
24
+ function isAxis(value: string | undefined): value is CensusAxis {
25
+ return value !== undefined && (CENSUS_AXES as readonly string[]).includes(value);
26
+ }
27
+
28
+ async function resolveTargets(parsed: ParsedArgs): Promise<SnapRef[]> {
29
+ const targetRefs = [...(parsed.snap ?? []), ...(parsed.urls ?? [])];
30
+ if (parsed.setFile) targetRefs.push(...targetsFromFile(parsed.setFile));
31
+ if (!targetRefs.length) throw new Error('census needs at least one --snap, --url, or --set-file target');
32
+
33
+ const snaps: SnapRef[] = [];
34
+ for (const target of targetRefs) {
35
+ snaps.push(await resolveSnapRef(target, {
36
+ onUrl: async (url) => {
37
+ const captured = await captureMeasureSnap({ ...parsed, positional: [], url, target: undefined }, url);
38
+ return captured;
39
+ },
40
+ }));
41
+ }
42
+ return snaps;
43
+ }
44
+
45
+ function errorResult(message: string, status: string): RenderableResult {
46
+ return {
47
+ tag: 'error',
48
+ attrs: { command: 'measure census', status },
49
+ summary: fact`Census could not read the requested measurement artifacts: ${message}`,
50
+ followUp: text`Create a settled snapshot with capture measure snap, then pass its id or absolute artifact path.`,
51
+ };
52
+ }
53
+
54
+ export async function cmdMeasureCensus(parsed: ParsedArgs, _args: string[]): Promise<void> {
55
+ if (parsed.help) {
56
+ console.log(USAGE);
57
+ return;
58
+ }
59
+ if (parsed.positional.length) {
60
+ emitResult(errorResult('this leaf takes targets only through repeatable --snap and --url flags.', 'invalid_input'), { json: parsed.json });
61
+ process.exitCode = 1;
62
+ return;
63
+ }
64
+ if (!isAxis(parsed.axis)) {
65
+ emitResult(errorResult(`--axis must be one of ${CENSUS_AXES.join('|')}; received ${parsed.axis ?? '(missing)'}.`, 'invalid_axis'), { json: parsed.json });
66
+ process.exitCode = 1;
67
+ return;
68
+ }
69
+
70
+ try {
71
+ const snapshots = await resolveTargets(parsed);
72
+ const report = buildCensus(parsed.axis, snapshots);
73
+ const result: RenderableResult = {
74
+ tag: 'census',
75
+ attrs: { axis: report.axis, snapshots: snapshots.length, distinct: report.distinct },
76
+ summary: fact`Distribution facts were measured from ${snapshots.length} snapshot artifact(s).`,
77
+ artifacts: formatArtifactList(snapshots.map((snap) => ({ name: snap.dir, note: snap.id }))),
78
+ sections: [censusResultLines(report)],
79
+ followUp: fact`Use capture measure explain ${snapshots[0].id} --selector <selector> for one element's recorded provenance.`,
80
+ };
81
+ emitResult(result, { json: parsed.json });
82
+ } catch (err) {
83
+ const detail = err instanceof ArtifactResolutionError || err instanceof Error ? err.message : String(err);
84
+ emitResult(errorResult(detail, 'artifact_unavailable'), { json: parsed.json });
85
+ process.exitCode = 1;
86
+ }
87
+ }
@@ -0,0 +1,109 @@
1
+ import { type ParsedArgs } from '../../types.js';
2
+ import { resolveSnapRef, readMeta, type ArtifactResolutionError } from '../../../output/artifact.js';
3
+ import { emitResult, fact, line, text, formatFindings, type FindingInput, type RenderableResult } from '../../../output/render.js';
4
+ import { captureMeasureSnap } from './snap.js';
5
+ import { checkSnapshot, parseChecks, writeFindingCrop } from '../../measure/check.js';
6
+
7
+ const USAGE = `capture measure check [url|snap] — read threshold/fact measurements from a settled snapshot
8
+
9
+ input:
10
+ [url|snap] a URL creates one one-shot snapshot first; a snap id or absolute path is read without re-driving the browser
11
+ --for <checks> geometry|content|targetability|forms|animation|all, or comma-separated overlap,offscreen,overflow,tap-targets,contrast,hit-test,truncation,forms,media,animation
12
+ --limit <n> render at most n representative findings in prose (default: 20; --json retains all findings)
13
+ --gate exit 2 when the report contains findings (default: exit 0)
14
+ output: <checks result=… findings=…> — findings with coordinates and collection provenance; measurements, not a pass/fail judgment; --json mirrors
15
+ effects: read-only over an existing snapshot artifact; a URL target writes one one-shot snapshot first`;
16
+
17
+ const DEFAULT_FINDING_LIMIT = 20;
18
+
19
+ function representativeFindings<T extends { kind: string }>(findings: readonly T[], limit: number): T[] {
20
+ const byKind = new Map<string, T[]>();
21
+ for (const finding of findings) {
22
+ const group = byKind.get(finding.kind) ?? [];
23
+ group.push(finding);
24
+ byKind.set(finding.kind, group);
25
+ }
26
+ const displayed: T[] = [];
27
+ for (let index = 0; displayed.length < limit; index++) {
28
+ let added = false;
29
+ for (const group of byKind.values()) {
30
+ const finding = group[index];
31
+ if (!finding) continue;
32
+ displayed.push(finding);
33
+ added = true;
34
+ if (displayed.length === limit) break;
35
+ }
36
+ if (!added) break;
37
+ }
38
+ return displayed;
39
+ }
40
+
41
+ function rollup(findings: readonly { kind: string }[]) {
42
+ const counts = new Map<string, number>();
43
+ for (const finding of findings) counts.set(finding.kind, (counts.get(finding.kind) ?? 0) + 1);
44
+ return [...counts.entries()].map(([kind, count]) => fact`${kind}=${count}`).reduce<ReturnType<typeof fact> | undefined>((line, entry) => line ? [...line, ...text`, `, ...entry] : entry, undefined);
45
+ }
46
+
47
+ function caveatLine(caveats: readonly { regionId: string; selector?: string; reason?: string }[]) {
48
+ if (!caveats.length) return undefined;
49
+ return fact`Nondeterminism caveat: unstable region ${caveats.map((c) => `${c.regionId}${c.selector ? ` (${c.selector})` : ''}${c.reason ? `: ${c.reason}` : ''}`).join('; ')}.`;
50
+ }
51
+
52
+ export async function cmdMeasureCheck(parsed: ParsedArgs, _args: string[]): Promise<void> {
53
+ if (parsed.help) { console.log(USAGE); return; }
54
+ if (parsed.positional.length > 1) {
55
+ emitResult({ tag: 'error', attrs: { command: 'measure check', status: 'invalid_input' }, summary: text`measure check accepts at most one URL or snapshot reference.` }, { json: parsed.json });
56
+ process.exitCode = 1;
57
+ return;
58
+ }
59
+ try {
60
+ const target = parsed.positional[0];
61
+ if (!target) throw new Error('missing snapshot target; pass a snapshot id/path or URL');
62
+ const checks = parseChecks(parsed.for);
63
+ const ref = await resolveSnapRef(target, { onUrl: async (url) => captureMeasureSnap({ ...parsed, positional: [url] }, url) });
64
+ const report = checkSnapshot(ref, checks);
65
+ const proseFindings = representativeFindings(report.findings, parsed.limit ?? DEFAULT_FINDING_LIMIT);
66
+ const findingsForOutput = parsed.json ? report.findings : proseFindings;
67
+ const withCrops = findingsForOutput.map((finding, index) => ({ ...finding, crop: writeFindingCrop(ref, finding, index) }));
68
+ const meta = readMeta<{ settled: boolean; capturedAt?: string }> (ref);
69
+ const findingSections: FindingInput[] = withCrops.map((finding) => ({
70
+ kind: finding.kind,
71
+ headline: fact`${finding.detail}`,
72
+ detail: [
73
+ ...(finding.provenance ? [fact`Provenance: ${finding.provenance}`] : []),
74
+ ...(caveatLine(finding.caveats) ? [caveatLine(finding.caveats)!] : []),
75
+ ],
76
+ artifactPath: finding.crop,
77
+ }));
78
+ const result: RenderableResult = {
79
+ tag: 'checks',
80
+ attestation: {
81
+ kind: 'snapshot', id: ref.id, path: ref.dir,
82
+ note: meta.settled ? text`Measurements read from a settled snapshot.` : text`Measurements read from a queryable unsettled snapshot; affected facts carry per-region nondeterminism caveats.`,
83
+ },
84
+ attrs: { result: report.findings.length ? 'findings' : 'clean', checks: checks.join(','), elements: report.elementCount, findings: report.findings.length, displayed: withCrops.length, settled: report.settled },
85
+ summary: report.findings.length
86
+ ? fact`${report.findings.length} measured fact(s) matched the selected thresholds in viewport ${report.viewport.width}×${report.viewport.height}; ${withCrops.length} representative record(s) rendered.`
87
+ : fact`No selected measurement threshold was crossed among ${report.elementCount} recorded elements in viewport ${report.viewport.width}×${report.viewport.height}.`,
88
+ sections: [
89
+ ...(report.findings.length ? [line(text`Finding counts: `, rollup(report.findings)!)] : []),
90
+ ...formatFindings(findingSections),
91
+ ],
92
+ followUp: parsed.json
93
+ ? fact`All ${report.findings.length} measured finding record(s) are included in this JSON result.`
94
+ : fact`Use --json to read all ${report.findings.length} measured finding record(s) from this snapshot.`,
95
+ };
96
+ emitResult(result, { json: parsed.json });
97
+ if (parsed.gate && report.findings.length) process.exitCode = 2;
98
+ } catch (err) {
99
+ const resolution = err as Partial<ArtifactResolutionError>;
100
+ const detail = err instanceof Error ? err.message : 'unknown artifact read failure';
101
+ emitResult({
102
+ tag: 'error',
103
+ attrs: { command: 'measure check', status: resolution.name === 'ArtifactResolutionError' ? 'artifact_unavailable' : 'check_failed' },
104
+ summary: fact`Measure check could not read the requested artifact: ${detail}`,
105
+ followUp: text`Create a settled snapshot with capture measure snap <url>, then pass its id or absolute path.`,
106
+ }, { json: parsed.json });
107
+ process.exitCode = 1;
108
+ }
109
+ }
@@ -0,0 +1,120 @@
1
+ import { type ParsedArgs } from '../../types.js';
2
+ import { resolveSnapRef, ArtifactResolutionError } from '../../../output/artifact.js';
3
+ import { diffSnapshots, type ElementDiff } from '../../measure/diff.js';
4
+ import { emitResult, fact, lineList, text, type FactLine, type RenderableResult } from '../../../output/render.js';
5
+
6
+ const USAGE = `capture measure diff — structured before/after diff over two settled snapshots
7
+
8
+ input:
9
+ --before <snap> earlier snapshot id or absolute path (required; no positional target — both snapshots must already exist)
10
+ --after <snap> later snapshot id or absolute path (required)
11
+ --pixels write and report a full-raster PNG diff
12
+ --full include state-matrix and unchanged per-element records
13
+ --gate exit 2 if a measured delta exists (default: exit 0)
14
+ output: <diff …> — style, geometry, text, form, and media deltas, cascade provenance, and geometry movement/size facts; --json mirrors
15
+ effects: read-only over the two snapshot artifacts; --pixels additionally writes the reported raster-diff PNG`;
16
+
17
+ function caveatLine(caveats: readonly { regionId: string; selector?: string; reason?: string; snapshot: 'before' | 'after' }[]): FactLine | undefined {
18
+ if (!caveats.length) return undefined;
19
+ return fact`nondeterminism caveat: ${caveats.map((caveat) => `${caveat.snapshot} snapshot ${caveat.regionId}${caveat.selector ? ` (${caveat.selector})` : ''}${caveat.reason ? `: ${caveat.reason}` : ''}`).join('; ')}`;
20
+ }
21
+
22
+ function recordLine(record: ElementDiff, full: boolean): FactLine {
23
+ const selector = record.selector ?? record.key;
24
+ const facts: string[] = [];
25
+ if (record.styleDeltas.length) facts.push(`style properties ${record.styleDeltas.map((delta) => `${delta.property} ${String(delta.before)}→${String(delta.after)}`).join(', ')}`);
26
+ if (record.geometryChanged) facts.push(`geometry changed${record.reflow ? ' (position/size delta)' : ''}`);
27
+ if (record.textChanged) facts.push('text facts changed');
28
+ if (record.formChanged) facts.push('form facts changed');
29
+ if (record.mediaChanged) facts.push('media facts changed');
30
+ if (!facts.length && full) facts.push('no compared delta');
31
+ return fact`${selector} — ${facts.join('; ')}`;
32
+ }
33
+
34
+ function provenanceLines(records: readonly ElementDiff[]): FactLine[] {
35
+ const lines: FactLine[] = [];
36
+ for (const record of records) {
37
+ for (const provenance of record.provenance) {
38
+ lines.push(fact`${record.selector ?? record.key} ${provenance.property}: cascade provenance before=${JSON.stringify(provenance.beforeProvenance ?? null)} after=${JSON.stringify(provenance.afterProvenance ?? null)}${provenance.changed ? '' : provenance.declarationChanged ? ' (unchanged computed value; winning declaration changed)' : ' (unchanged value)'}`);
39
+ }
40
+ }
41
+ return lines;
42
+ }
43
+
44
+ export async function cmdMeasureDiff(parsed: ParsedArgs, _args: string[]): Promise<void> {
45
+ if (parsed.help) {
46
+ console.log(USAGE);
47
+ return;
48
+ }
49
+ if (parsed.positional.length > 0 || !parsed.before || !parsed.after) {
50
+ const result: RenderableResult = {
51
+ tag: 'error',
52
+ attrs: { command: 'measure diff', status: 'invalid_input' },
53
+ summary: text`This command requires --before SNAP and --after SNAP and accepts no positional target.`,
54
+ followUp: text`Use capture measure diff --before SNAP --after SNAP.`,
55
+ };
56
+ emitResult(result, { json: parsed.json });
57
+ process.exitCode = 1;
58
+ return;
59
+ }
60
+
61
+ try {
62
+ const before = await resolveSnapRef(parsed.before);
63
+ const after = await resolveSnapRef(parsed.after);
64
+ const report = diffSnapshots(before, after, { pixels: parsed.pixels, full: parsed.full });
65
+ const changedRecords = report.changes.filter((record) => record.geometryChanged || record.textChanged || record.formChanged || record.mediaChanged || record.styleDeltas.length > 0);
66
+ const recordLines = report.changes
67
+ .filter((record) => Boolean(parsed.full) || record.geometryChanged || record.textChanged || record.formChanged || record.mediaChanged || record.styleDeltas.length > 0)
68
+ .map((record) => recordLine(record, Boolean(parsed.full)));
69
+ const caveats = report.changes.flatMap((record) => record.caveats);
70
+ const sections: FactLine[] = [];
71
+ if (recordLines.length) sections.push(lineList(recordLines));
72
+ const provenance = provenanceLines(report.changes);
73
+ if (provenance.length) sections.push(lineList(provenance));
74
+ if (parsed.full) {
75
+ sections.push(fact`state-matrix records: ${report.stateDeltas.length}; changed state records: ${report.stateDeltas.filter((state) => state.changed).length}.`);
76
+ if (report.stateDeltas.length) sections.push(lineList(report.stateDeltas.map((state) => fact`${state.key} — ${state.changed ? 'state delta' : 'no state delta'}`)));
77
+ }
78
+ if (report.raster) {
79
+ const raster = report.raster.outcome;
80
+ sections.push(raster.ok
81
+ ? fact`Raster diff: ${raster.diffPixelCount} changed pixels across ${raster.width}×${raster.height}; diff artifact ${report.raster.path}. Raster regions without a geometry delta: ${report.raster.unexplainedRegions}.`
82
+ : fact`Raster diff was not computed: ${raster.message}.`);
83
+ if (raster.ok && report.raster.regions.length) {
84
+ sections.push(lineList(report.raster.regions.map((region) => fact`raster region x=${region.x} y=${region.y} w=${region.w} h=${region.h} changed-pixels=${region.changedPixels} explained-by-geometry=${String(region.explainedByGeometry)}`)));
85
+ }
86
+ }
87
+ const caveat = caveatLine(caveats);
88
+ if (caveat) sections.push(caveat);
89
+
90
+ const result: RenderableResult = {
91
+ tag: 'diff',
92
+ attestation: {
93
+ kind: 'snapshot',
94
+ id: after.id,
95
+ path: after.dir,
96
+ note: fact`Compared before snapshot ${before.id} (settled=${String(report.beforeMeta.settled ?? 'unknown')}) with after snapshot ${after.id} (settled=${String(report.afterMeta.settled ?? 'unknown')}).`,
97
+ },
98
+ attrs: { before: before.id, after: after.id, 'changed-elements': changedRecords.length, ...(report.raster?.outcome.ok ? { 'raster-regions': report.raster.unexplainedRegions } : {}) },
99
+ summary: report.changed
100
+ ? fact`${changedRecords.length} element record(s) contain measured style, geometry, text, form, or media deltas.`
101
+ : text`No measured style, geometry, text, form, media, state, or requested raster delta was recorded.`,
102
+ sections,
103
+ followUp: parsed.full
104
+ ? fact`Inspect a changed element with capture measure explain ${after.id} --selector SELECTOR.`
105
+ : fact`Use --full for state-matrix and unchanged per-element provenance records.`,
106
+ };
107
+ emitResult(result, { json: parsed.json });
108
+ if (parsed.gate && report.changed) process.exitCode = 2;
109
+ } catch (error) {
110
+ const detail = error instanceof ArtifactResolutionError ? error.message : error instanceof Error ? error.message : String(error);
111
+ const result: RenderableResult = {
112
+ tag: 'error',
113
+ attrs: { command: 'measure diff', status: 'artifact_unavailable' },
114
+ summary: fact`The requested snapshot comparison could not be read: ${detail}`,
115
+ followUp: text`Create settled snapshots with capture measure snap, then pass their ids or absolute artifact paths.`,
116
+ };
117
+ emitResult(result, { json: parsed.json });
118
+ process.exitCode = 1;
119
+ }
120
+ }
@@ -0,0 +1,133 @@
1
+ import { type ParsedArgs } from '../../types.js';
2
+ import { explainSnapshot, type ExplainMissingSelector } from '../../measure/explain.js';
3
+ import { ArtifactResolutionError, resolveSnapRef } from '../../../output/artifact.js';
4
+ import { data, emitResult, fact, line, lineList, text, type FactLine, type RenderableResult } from '../../../output/render.js';
5
+
6
+ const USAGE = `capture measure explain <snap> --selector <sel> — per-element cascade/stacking/clipping explanation over one snapshot
7
+
8
+ input:
9
+ <snap> snapshot id in the active session or absolute artifact path (required)
10
+ --selector <sel> element selector (required): CSS, backend:<id>, axid:<id>, ax:<name>, or text:<text>
11
+ --size include size/layout provenance (box/flex/grid/constraints)
12
+ --text include text line/baseline/font/wrap metrics
13
+ --form include form value/geometry/caret/selection/autofill facts
14
+ output: <explain selector=… matches=…> — the compact element explanation, plus one section per requested detail flag; --json mirrors
15
+ effects: read-only — reads existing snapshot artifacts, never drives the browser`;
16
+
17
+ function attestation(ref: { id: string; dir: string }, meta: { settled?: boolean; settleMs?: number }) {
18
+ return {
19
+ kind: 'snapshot' as const,
20
+ id: ref.id,
21
+ path: ref.dir,
22
+ note: meta.settled === false
23
+ ? text`Snapshot was captured unsettled; each explanation fact intersecting an unstable region carries its own nondeterminism caveat.`
24
+ : meta.settled === true
25
+ ? fact`Snapshot settled${meta.settleMs === undefined ? '' : ` after ${meta.settleMs}ms`}.`
26
+ : text`Snapshot settledness was not recorded.`,
27
+ };
28
+ }
29
+
30
+ function caveatSuffix(caveats: readonly { regionId: string; selector?: string; reason?: string }[]): FactLine | undefined {
31
+ if (!caveats.length) return undefined;
32
+ const entries = caveats.map((caveat) => {
33
+ const parts: FactLine[] = [data(caveat.regionId)];
34
+ if (caveat.selector) parts.push(line(text` (`, data(caveat.selector), text`)`));
35
+ if (caveat.reason) parts.push(line(text`: `, data(caveat.reason)));
36
+ return line(...parts);
37
+ });
38
+ const joined: FactLine[] = [];
39
+ entries.forEach((entry, index) => {
40
+ if (index) joined.push(text`; `);
41
+ joined.push(entry);
42
+ });
43
+ return line(text` — nondeterminism caveat: `, ...joined);
44
+ }
45
+
46
+ function recoverySections(missing: ExplainMissingSelector): FactLine[] {
47
+ const { candidates, recordCount, kind } = missing.available;
48
+ const label = kind === 'css' ? 'CSS selectors' : `${kind}: selector inputs`;
49
+ const rows = candidates.length
50
+ ? candidates.map((candidate, index) => fact`${index + 1}. ${candidate}`)
51
+ : [fact`No recorded ${label} were available from geometry records.`];
52
+ return [
53
+ fact`Nearest recorded ${label}: ${candidates.length} shown from ${recordCount} geometry record(s), ranked by identifier similarity then string distance from the requested selector.`,
54
+ lineList(rows),
55
+ text`All recorded selector and identity facts remain in this snapshot's geometry.json, ax.json, and text.json artifacts.`,
56
+ ];
57
+ }
58
+
59
+ function invalidInput(message: FactLine): RenderableResult {
60
+ return {
61
+ tag: 'error',
62
+ attrs: { command: 'measure explain', status: 'invalid_input' },
63
+ summary: message,
64
+ followUp: text`Run capture measure explain <snap> --selector <CSS|backend:id|axid:id|ax:name|text:value>.`,
65
+ };
66
+ }
67
+
68
+ export async function cmdMeasureExplain(parsed: ParsedArgs, _args: string[]): Promise<void> {
69
+ if (parsed.help) {
70
+ console.log(USAGE);
71
+ return;
72
+ }
73
+ if (parsed.positional.length !== 1) {
74
+ emitResult(invalidInput(fact`Expected exactly one snapshot target; received ${parsed.positional.length}.`), { json: parsed.json });
75
+ process.exitCode = 1;
76
+ return;
77
+ }
78
+ if (!parsed.selector) {
79
+ emitResult(invalidInput(text`The --selector flag is required.`), { json: parsed.json });
80
+ process.exitCode = 1;
81
+ return;
82
+ }
83
+
84
+ try {
85
+ const ref = await resolveSnapRef(parsed.positional[0]!);
86
+ const report = explainSnapshot(ref, parsed.selector, { size: parsed.size, text: parsed.text, form: parsed.form });
87
+ if (report.kind === 'missing-selector') {
88
+ emitResult({
89
+ tag: 'error',
90
+ attestation: attestation(report.ref, report.meta),
91
+ attrs: { command: 'measure explain', status: 'missing_selector', selector: report.selector },
92
+ summary: fact`No geometry element matched selector input ${report.selector}. Bounded recovery candidates from this snapshot follow.`,
93
+ sections: recoverySections(report),
94
+ followUp: text`The selector input also accepts CSS, backend:, axid:, ax:, and text: forms.`,
95
+ }, { json: parsed.json });
96
+ process.exitCode = 1;
97
+ return;
98
+ }
99
+
100
+ const sections = report.sections.map((section) => lineList(section.facts.map(({ fact: item, caveats }) => {
101
+ const suffix = caveatSuffix(caveats);
102
+ return suffix ? line(item.line, suffix) : item.line;
103
+ })));
104
+ const targetSelector = report.element.selector ?? report.element.id;
105
+ const result: RenderableResult = {
106
+ tag: 'explain',
107
+ attestation: attestation(report.ref, report.meta),
108
+ attrs: {
109
+ selector: targetSelector,
110
+ matches: report.matchCount,
111
+ settled: report.meta.settled ?? 'unknown',
112
+ size: Boolean(parsed.size),
113
+ text: Boolean(parsed.text),
114
+ form: Boolean(parsed.form),
115
+ },
116
+ summary: fact`Recorded cascade, stacking, clipping, focus, scroll, query, and state context for ${targetSelector}.`,
117
+ sections,
118
+ followUp: parsed.size || parsed.text || parsed.form
119
+ ? text`Re-run against a new snapshot to compare the same recorded measurements after a page change.`
120
+ : text`Add --size, --text, or --form to include only those optional detail sections.`,
121
+ };
122
+ emitResult(result, { json: parsed.json });
123
+ } catch (error) {
124
+ const detail = error instanceof ArtifactResolutionError || error instanceof Error ? error.message : String(error);
125
+ emitResult({
126
+ tag: 'error',
127
+ attrs: { command: 'measure explain', status: 'artifact_unavailable' },
128
+ summary: fact`The requested snapshot explanation could not be read: ${detail}`,
129
+ followUp: text`Create a settled snapshot with capture measure snap, then pass its id or absolute artifact path.`,
130
+ }, { json: parsed.json });
131
+ process.exitCode = 1;
132
+ }
133
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * `capture measure` branch router.
3
+ *
4
+ * Shifts the consumed leaf token(s) out of `parsed.positional` before
5
+ * handing off, so every leaf command still finds its own primary target
6
+ * (a URL, a snap id, ...) at `positional[0]` exactly as every other capture
7
+ * command does — leaf commands never need to know they're nested under a
8
+ * branch.
9
+ */
10
+ import { invalidInput } from '../../../errors.js';
11
+ import { type ParsedArgs } from '../../types.js';
12
+ import { cmdMeasureSnap } from './snap.js';
13
+ import { cmdMeasureCheck } from './check.js';
14
+ import { cmdMeasureDiff } from './diff.js';
15
+ import { cmdMeasureCensus } from './census.js';
16
+ import { cmdMeasureExplain } from './explain.js';
17
+ import { cmdMeasureSweep } from './sweep.js';
18
+ import { cmdMeasureMapFocus } from './map-focus.js';
19
+ import { cmdMeasureMapScroll } from './map-scroll.js';
20
+ import { cmdMeasureMapLayers } from './map-layers.js';
21
+ import { cmdMeasureMapAx } from './map-ax.js';
22
+
23
+ /** Root-help representation of this branch, assembled by `src/capture.ts`. */
24
+ export const COMMAND_BLOCK = `<command name="measure">
25
+ static facts over a settled snapshot — \`snap\` writes the substrate, every other leaf is a read-only query over it
26
+ use when measuring layout/content/targetability facts, diffing snapshots, or reading one facet (focus, scroll, layers, ax) of the substrate
27
+ snap · check · diff · census · explain · sweep · map — \`capture measure -h\`
28
+ </command>`;
29
+
30
+ export const MEASURE_USAGE = `capture measure — enriched snapshot substrate + read-only queries over it.
31
+
32
+ \`snap\` drives the page (or a base snapshot) and writes one settled artifact
33
+ directory; every other leaf below is a cheap read over that artifact and
34
+ never re-drives the browser unless its target is a URL (which snaps first).
35
+ Every leaf defaults to rendered prose; --json mirrors the same result.
36
+ Findings exit 0 — a report, not a failure.
37
+ \`--gate\` (exit 2 on findings/changes) is accepted only by check and diff.
38
+
39
+ <subcommand name="snap" args="[url|snap] [--freeze-animations] [--settle-timeout <ms>] [--capture-unsettled] [--pixels] [--state <state[:selector]>]... [--viewport <WxH>]..." whenToUse="drive + write the settled snapshot substrate every other leaf reads"/>
40
+ <subcommand name="check" args="[url|snap] [--for <checks>] [--gate]" whenToUse="read threshold/fact measurements from one snapshot"/>
41
+ <subcommand name="diff" args="--before <snap> --after <snap> [--pixels] [--full] [--gate]" whenToUse="structured before/after delta between two snapshots"/>
42
+ <subcommand name="census" args="[--snap <id>]... [--url <url>]... [--set-file <path>] --axis <axis>" whenToUse="value distributions across one or more snapshots"/>
43
+ <subcommand name="explain" args="<snap> --selector <sel> [--size] [--text] [--form]" whenToUse="per-element cascade/stacking/clipping/size/text/form explanation"/>
44
+ <subcommand name="sweep" args="[url] --axis <axis> [--from <val>] [--to <val>] [--viewport-height <val>]" whenToUse="responsive/environment sampling across an axis"/>
45
+ <subcommand name="map" args="focus|scroll|layers|ax [url|snap]" whenToUse="read one facet of a snapshot's substrate — capture measure map -h"/>
46
+
47
+ capture measure <leaf> -h Per-leaf usage`;
48
+
49
+ export const MEASURE_MAP_USAGE = `capture measure map — read one facet of a snapshot's substrate (no browser re-drive).
50
+
51
+ A URL target first creates a snap; a snap target reads its existing artifact.
52
+
53
+ <subcommand name="focus" args="[url|snap]" whenToUse="keyboard traversal order (focus.json)"/>
54
+ <subcommand name="scroll" args="[url|snap]" whenToUse="scroll-container topology (scroll.json)"/>
55
+ <subcommand name="layers" args="[url|snap]" whenToUse="paint/compositor layer map (layers.json)"/>
56
+ <subcommand name="ax" args="[url|snap]" whenToUse="AX-tree ↔ layout map (ax.json + geometry.json)"/>
57
+
58
+ capture measure map <leaf> -h Per-leaf usage`;
59
+
60
+ export async function measureMain(parsed: ParsedArgs, args: string[]): Promise<void> {
61
+ const leaf = parsed.positional[0];
62
+ const rest: ParsedArgs = { ...parsed, positional: parsed.positional.slice(1) };
63
+
64
+ switch (leaf) {
65
+ case 'snap':
66
+ return cmdMeasureSnap(rest, args);
67
+ case 'check':
68
+ return cmdMeasureCheck(rest, args);
69
+ case 'diff':
70
+ return cmdMeasureDiff(rest, args);
71
+ case 'census':
72
+ return cmdMeasureCensus(rest, args);
73
+ case 'explain':
74
+ return cmdMeasureExplain(rest, args);
75
+ case 'sweep':
76
+ return cmdMeasureSweep(rest, args);
77
+ case 'map':
78
+ return measureMapMain(rest, args);
79
+ case undefined:
80
+ console.log(MEASURE_USAGE);
81
+ return;
82
+ default:
83
+ throw invalidInput(`Unknown measure leaf: ${leaf}.`, 'unknown_command');
84
+ }
85
+ }
86
+
87
+ async function measureMapMain(parsed: ParsedArgs, args: string[]): Promise<void> {
88
+ const sub = parsed.positional[0];
89
+ const rest: ParsedArgs = { ...parsed, positional: parsed.positional.slice(1) };
90
+
91
+ switch (sub) {
92
+ case 'focus':
93
+ return cmdMeasureMapFocus(rest, args);
94
+ case 'scroll':
95
+ return cmdMeasureMapScroll(rest, args);
96
+ case 'layers':
97
+ return cmdMeasureMapLayers(rest, args);
98
+ case 'ax':
99
+ return cmdMeasureMapAx(rest, args);
100
+ case undefined:
101
+ console.log(MEASURE_MAP_USAGE);
102
+ return;
103
+ default:
104
+ throw invalidInput(`Unknown measure map leaf: ${sub}.`, 'unknown_command');
105
+ }
106
+ }
@@ -0,0 +1,52 @@
1
+ import { type ParsedArgs } from '../../types.js';
2
+ import { captureMeasureSnap } from './snap.js';
3
+ import { buildMeasureMapAxResult } from '../../measure/map-ax.js';
4
+ import { ArtifactResolutionError, resolveSnapRef } from '../../../output/artifact.js';
5
+ import { emitResult, fact, type RenderableResult } from '../../../output/render.js';
6
+
7
+ const USAGE = `capture measure map ax [url|snap] — accessibility-tree facts recorded in a snapshot's ax.json joined against geometry.json
8
+
9
+ input:
10
+ [url|snap] required target: a URL creates a settled snapshot first; a snapshot id or absolute path is read without re-driving the browser
11
+ output: <ax-map …> — non-ignored AX nodes with role, name, states, backendNodeId, and top-viewport rect; ignored AX nodes with their ignored-reasons; DOM elements with rendered boxes but no non-ignored AX node; and AX nodes whose rect is offscreen, clipped, or zero-size; --json mirrors
12
+ effects: read-only over an existing snapshot artifact; a URL target writes one settled snapshot first`;
13
+
14
+ function recoveryResult(err: unknown): RenderableResult {
15
+ const detail = err instanceof ArtifactResolutionError || err instanceof Error
16
+ ? err.message
17
+ : 'The snapshot artifacts could not be resolved.';
18
+ return {
19
+ tag: 'error',
20
+ attrs: { command: 'measure map ax', status: 'artifact_unavailable' },
21
+ summary: fact`AX-map facts could not be read: ${detail}`,
22
+ followUp: fact`Create a settled snapshot with \`capture measure snap <url>\`, then run \`capture measure map ax <snap>\`.`,
23
+ };
24
+ }
25
+
26
+ export async function cmdMeasureMapAx(parsed: ParsedArgs, _args: string[]): Promise<void> {
27
+ if (parsed.help) {
28
+ console.log(USAGE);
29
+ return;
30
+ }
31
+ if (parsed.positional.length !== 1) {
32
+ emitResult({
33
+ tag: 'error',
34
+ attrs: { command: 'measure map ax', status: 'invalid_target' },
35
+ summary: fact`Expected exactly one URL or snapshot target; received ${parsed.positional.length} positional target(s).`,
36
+ followUp: fact`Run \`capture measure map ax <snap>\` or \`capture measure map ax <url>\`.`,
37
+ }, { json: parsed.json });
38
+ process.exitCode = 1;
39
+ return;
40
+ }
41
+
42
+ try {
43
+ const target = parsed.positional[0]!;
44
+ const ref = await resolveSnapRef(target, {
45
+ onUrl: async (url) => captureMeasureSnap({ ...parsed, positional: [url] }, url),
46
+ });
47
+ emitResult(buildMeasureMapAxResult(ref), { json: parsed.json });
48
+ } catch (err) {
49
+ emitResult(recoveryResult(err), { json: parsed.json });
50
+ process.exitCode = 1;
51
+ }
52
+ }
@@ -0,0 +1,52 @@
1
+ import { type ParsedArgs } from '../../types.js';
2
+ import { captureMeasureSnap } from './snap.js';
3
+ import { mapFocus } from '../../measure/map-focus.js';
4
+ import { ArtifactResolutionError, resolveSnapRef } from '../../../output/artifact.js';
5
+ import { emitResult, fact, type RenderableResult } from '../../../output/render.js';
6
+
7
+ const USAGE = `capture measure map focus [url|snap] — keyboard traversal facts recorded in a snapshot's focus.json
8
+
9
+ input:
10
+ [url|snap] required target: a URL creates a settled snapshot first; a snapshot id or absolute path is read without re-driving the browser
11
+ output: <focus-map …> — forward and reverse Tab sequences, top-viewport rects, scroll jumps, focus-visible style facts, and unreached focusable elements; --json mirrors
12
+ effects: read-only over an existing snapshot artifact; a URL target writes one settled snapshot first`;
13
+
14
+ function recoveryResult(err: unknown): RenderableResult {
15
+ const detail = err instanceof ArtifactResolutionError || err instanceof Error
16
+ ? err.message
17
+ : 'The focus artifact could not be resolved.';
18
+ return {
19
+ tag: 'error',
20
+ attrs: { command: 'measure map focus', status: 'artifact_unavailable' },
21
+ summary: fact`Focus-map facts could not be read: ${detail}`,
22
+ followUp: fact`Create a settled snapshot with \`capture measure snap <url>\`, then run \`capture measure map focus <snap>\`.`,
23
+ };
24
+ }
25
+
26
+ export async function cmdMeasureMapFocus(parsed: ParsedArgs, _args: string[]): Promise<void> {
27
+ if (parsed.help) {
28
+ console.log(USAGE);
29
+ return;
30
+ }
31
+ if (parsed.positional.length !== 1) {
32
+ emitResult({
33
+ tag: 'error',
34
+ attrs: { command: 'measure map focus', status: 'invalid_target' },
35
+ summary: fact`Expected exactly one URL or snapshot target; received ${parsed.positional.length} positional target(s).`,
36
+ followUp: fact`Run \`capture measure map focus <snap>\` or \`capture measure map focus <url>\`.`,
37
+ }, { json: parsed.json });
38
+ process.exitCode = 1;
39
+ return;
40
+ }
41
+
42
+ try {
43
+ const target = parsed.positional[0]!;
44
+ const ref = await resolveSnapRef(target, {
45
+ onUrl: async (url) => captureMeasureSnap({ ...parsed, positional: [url] }, url),
46
+ });
47
+ emitResult(mapFocus(ref), { json: parsed.json });
48
+ } catch (err) {
49
+ emitResult(recoveryResult(err), { json: parsed.json });
50
+ process.exitCode = 1;
51
+ }
52
+ }