@crouton-kit/capture 1.3.8 → 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 (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
@@ -1,31 +1,87 @@
1
+ import * as fs from 'fs';
2
+
1
3
  import { type ParsedArgs } from '../../types.js';
2
- import { emitResult, fact, type RenderableResult } from '../../../output/render.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';
3
8
 
4
- const USAGE = `Usage: capture measure census [--snap <id>]... [--url <url>]... [--set-file <path>] --axis <axis>
9
+ const USAGE = `capture measure census --axis <axis> — value distributions and token-audit facts across one or more settled snapshots
5
10
 
6
- Value distributions and token audits across one or more snapshots. Each
7
- --snap/--url is repeatable to build a cross-page distribution; a --url
8
- target creates a one-shot snap first.
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`;
9
18
 
10
- Options:
11
- --snap <id> Existing snapshot id/path (repeatable)
12
- --url <url> URL to snap first (repeatable)
13
- --set-file <path> File listing additional snap/url targets, one per line
14
- --viewport <WxH> Viewport to snap at (repeatable)
15
- --axis <axis> color|font|spacing|radius|shadow|animation|geometry|media|queries
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
+ }
16
23
 
17
- Not yet implemented.`;
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
+ }
18
53
 
19
54
  export async function cmdMeasureCensus(parsed: ParsedArgs, _args: string[]): Promise<void> {
20
55
  if (parsed.help) {
21
56
  console.log(USAGE);
22
- process.exit(0);
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;
23
86
  }
24
- const result: RenderableResult = {
25
- tag: 'error',
26
- attrs: { command: 'measure census', status: 'not_implemented' },
27
- summary: fact`\`measure census\` is not implemented yet.`,
28
- };
29
- emitResult(result, { json: parsed.json });
30
- process.exit(1);
31
87
  }
@@ -1,35 +1,109 @@
1
1
  import { type ParsedArgs } from '../../types.js';
2
- import { emitResult, fact, type RenderableResult } from '../../../output/render.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';
3
6
 
4
- const USAGE = `Usage: capture measure check [url|snap] [--for <checks>] [--viewport <WxH>]... [--gate]
7
+ const USAGE = `capture measure check [url|snap] — read threshold/fact measurements from a settled snapshot
5
8
 
6
- Threshold/fact checks over a settled snapshot's substrate: overlap,
7
- offscreen, overflow, tap-target size, contrast, hit-test receiver,
8
- truncation, form visibility, media/replaced anomalies, and animation
9
- facts. A URL target creates a one-shot snap first.
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`;
10
16
 
11
- Options:
12
- --for <checks> Comma-separated check names (e.g.
13
- overlap,offscreen,overflow,tap-targets,contrast,
14
- hit-test,truncation) or a category
15
- (geometry|content|targetability|forms|animation|all).
16
- Omit for every check.
17
- --viewport <WxH> Viewport to check at (repeatable, for a discrete
18
- multi-viewport sweep)
19
- --gate Exit 2 if any check finds something (default: exit 0)
17
+ const DEFAULT_FINDING_LIMIT = 20;
20
18
 
21
- Not yet implemented.`;
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
+ }
22
51
 
23
52
  export async function cmdMeasureCheck(parsed: ParsedArgs, _args: string[]): Promise<void> {
24
- if (parsed.help) {
25
- console.log(USAGE);
26
- process.exit(0);
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;
27
108
  }
28
- const result: RenderableResult = {
29
- tag: 'error',
30
- attrs: { command: 'measure check', status: 'not_implemented' },
31
- summary: fact`\`measure check\` is not implemented yet.`,
32
- };
33
- emitResult(result, { json: parsed.json });
34
- process.exit(1);
35
109
  }
@@ -1,32 +1,120 @@
1
1
  import { type ParsedArgs } from '../../types.js';
2
- import { emitResult, fact, type RenderableResult } from '../../../output/render.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';
3
5
 
4
- const USAGE = `Usage: capture measure diff --before <snap> --after <snap> [--pixels] [--full] [--gate]
6
+ const USAGE = `capture measure diff — structured before/after diff over two settled snapshots
5
7
 
6
- Structured before/after diff over two snapshots: style/geometry/text/form/
7
- media deltas, cascade provenance, and reflow attribution. No positional
8
- target — both snapshots must already exist.
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`;
9
16
 
10
- Options:
11
- --before <snap> Earlier snapshot id/path (required)
12
- --after <snap> Later snapshot id/path (required)
13
- --pixels Also report raster regions not explained by geometry
14
- --full Expand to state-matrix deltas and complete per-element
15
- change records
16
- --gate Exit 2 if any element changed (default: exit 0)
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
+ }
17
33
 
18
- Not yet implemented.`;
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
+ }
19
43
 
20
44
  export async function cmdMeasureDiff(parsed: ParsedArgs, _args: string[]): Promise<void> {
21
45
  if (parsed.help) {
22
46
  console.log(USAGE);
23
- process.exit(0);
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;
24
119
  }
25
- const result: RenderableResult = {
26
- tag: 'error',
27
- attrs: { command: 'measure diff', status: 'not_implemented' },
28
- summary: fact`\`measure diff\` is not implemented yet.`,
29
- };
30
- emitResult(result, { json: parsed.json });
31
- process.exit(1);
32
120
  }
@@ -1,31 +1,133 @@
1
1
  import { type ParsedArgs } from '../../types.js';
2
- import { emitResult, fact, type RenderableResult } from '../../../output/render.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';
3
5
 
4
- const USAGE = `Usage: capture measure explain <snap> --selector <sel> [--size] [--text] [--form]
6
+ const USAGE = `capture measure explain <snap> --selector <sel> — per-element cascade/stacking/clipping explanation over one snapshot
5
7
 
6
- Per-element cascade/stacking/clipping explanation over one snapshot, plus
7
- optional detail sections.
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`;
8
16
 
9
- Options:
10
- --selector <sel> Element selector (required)
11
- --size Include size/layout provenance (flex/grid/intrinsic)
12
- --text Include text/line-box/wrap detail
13
- --form Include form/caret/selection/autofill detail (redacted)
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
+ }
14
45
 
15
- With no detail flag, returns the standard compact element explanation.
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
+ }
16
58
 
17
- Not yet implemented.`;
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
+ }
18
67
 
19
68
  export async function cmdMeasureExplain(parsed: ParsedArgs, _args: string[]): Promise<void> {
20
69
  if (parsed.help) {
21
70
  console.log(USAGE);
22
- process.exit(0);
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;
23
132
  }
24
- const result: RenderableResult = {
25
- tag: 'error',
26
- attrs: { command: 'measure explain', status: 'not_implemented' },
27
- summary: fact`\`measure explain\` is not implemented yet.`,
28
- };
29
- emitResult(result, { json: parsed.json });
30
- process.exit(1);
31
133
  }