@crouton-kit/capture 1.3.21 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/capture",
3
- "version": "1.3.21",
3
+ "version": "1.4.0",
4
4
  "description": "Browser automation and UI validation via CDP — session-based screenshots, HAR, a11y, click/type, and JS exec",
5
5
  "bin": {
6
6
  "capture": "./bin/capture"
package/src/capture.ts CHANGED
@@ -60,8 +60,9 @@ A CDP-enabled browser must be running; \`capture tab list\` is the probe, and
60
60
  \`capture tab launch\` starts one capture owns and reaps when nothing is (never
61
61
  hand-roll a detached browser — nothing reaps that). Capture never signals a
62
62
  browser it did not start, so attaching to your own with --port is unchanged.
63
- CDP_PORT / CDP_TARGET pin the browser + tab for orchestrators — precedence:
64
- explicit flag > active session > env.`;
63
+ CDP_PORT / CDP_TARGET pin the browser + tab for target-selecting commands —
64
+ precedence: explicit flag > active session > env. \`session start\` adopts only an
65
+ explicit --target.`;
65
66
  }
66
67
 
67
68
  function printVersion(): void {
package/src/cdp/args.ts CHANGED
@@ -19,6 +19,7 @@ const VALUE_FLAGS = new Set([
19
19
  '--filter', '--name', '--filter-url', '--filter-status', '--filter-method', '--limit', '--params', '--wait-event',
20
20
  '--timeout', '--socket', '--settle-timeout', '--state', '--for', '--before', '--after', '--snap', '--set-file',
21
21
  '--axis', '--from', '--to', '--viewport-height', '--rec-id', '--selector', '--do', '--element', '--prop', '--action', '--occurrence',
22
+ '--state-padding', '--crop', '--crop-selector', '--pad', '--zoom',
22
23
  ]);
23
24
 
24
25
  function valueFor(flag: string, next: string | undefined): string {
@@ -69,7 +70,7 @@ function validateKnownLeaf(command: string, positional: readonly string[]): void
69
70
  if (known && !known.includes(leaf)) throw invalidInput(`Unknown ${command} leaf: ${leaf}.`, 'unknown_command');
70
71
  if (command === 'measure' && leaf === 'map' && positional[1] !== undefined) {
71
72
  const mapLeaf = positional[1];
72
- if (!['focus', 'scroll', 'layers', 'ax'].includes(mapLeaf)) throw invalidInput(`Unknown measure map leaf: ${mapLeaf}.`, 'unknown_command');
73
+ if (!['focus', 'scroll', 'layers', 'ax', 'paint'].includes(mapLeaf)) throw invalidInput(`Unknown measure map leaf: ${mapLeaf}.`, 'unknown_command');
73
74
  }
74
75
  }
75
76
 
@@ -107,6 +108,9 @@ export function validateCliInvocation(parsed: ParsedArgs): void {
107
108
  };
108
109
  const [min, max] = ranges[leaf];
109
110
  requireCount(values, min, max, `session ${leaf}`);
111
+ if (leaf === 'start' && parsed.url !== undefined && parsed.target !== undefined) {
112
+ throw invalidInput('session start cannot combine --url and --target.');
113
+ }
110
114
  return;
111
115
  }
112
116
 
@@ -169,7 +173,8 @@ export function validateCliInvocation(parsed: ParsedArgs): void {
169
173
  const mapLeaf = values[0];
170
174
  if (mapLeaf === undefined) return;
171
175
  const targets = values.slice(1);
172
- requireCount(targets, mapLeaf === 'focus' || mapLeaf === 'ax' ? 1 : 0, 1, `measure map ${mapLeaf}`);
176
+ requireCount(targets, ['focus', 'ax', 'paint'].includes(mapLeaf) ? 1 : 0, 1, `measure map ${mapLeaf}`);
177
+ if (mapLeaf === 'paint' && !parsed.selector?.trim()) throw invalidInput('measure map paint requires --selector.');
173
178
  }
174
179
  return;
175
180
  }
@@ -261,6 +266,12 @@ export function parseCliSyntax(argv: string[]): ParsedArgs {
261
266
  else if (arg === '--skip-collector') { (parsed.skipCollector ??= []).push(valueFor(arg, next)); i++; }
262
267
  else if (arg === '--pixels') parsed.pixels = true;
263
268
  else if (arg === '--state') { (parsed.state ??= []).push(valueFor(arg, next)); i++; }
269
+ else if (arg === '--state-padding') { parsed.statePadding = integer(valueFor(arg, next), '--state-padding', 0, 16_384); i++; }
270
+ else if (arg === '--list-crops') parsed.listCrops = true;
271
+ else if (arg === '--crop') { parsed.crop = valueFor(arg, next); i++; }
272
+ else if (arg === '--crop-selector') { parsed.cropSelector = valueFor(arg, next); i++; }
273
+ else if (arg === '--pad') { parsed.pad = integer(valueFor(arg, next), '--pad', 0, 16_384); i++; }
274
+ else if (arg === '--zoom') { parsed.zoom = valueFor(arg, next); i++; }
264
275
  else if (arg === '--for') { parsed.for = valueFor(arg, next); i++; }
265
276
  else if (arg === '--before') { parsed.before = valueFor(arg, next); i++; }
266
277
  else if (arg === '--after') { parsed.after = valueFor(arg, next); i++; }
@@ -326,6 +337,10 @@ function needsEndpointResolution(parsed: ParsedArgs): boolean {
326
337
  */
327
338
  export function resolveCliContext(parsed: ParsedArgs): ParsedArgs {
328
339
  if (!needsEndpointResolution(parsed)) return parsed;
340
+ // Session start adopts only a target named in its invocation. CDP_TARGET is
341
+ // an ambient page-command default, not permission to publish a new session
342
+ // against an implicit tab.
343
+ const sessionStart = parsed.command === 'session' && parsed.positional[0] === 'start';
329
344
  const session = getActiveSession({ cleanStale: false });
330
345
  if (session) {
331
346
  if (!parsed.har && session.harId) parsed.har = session.harId;
@@ -344,7 +359,7 @@ export function resolveCliContext(parsed: ParsedArgs): ParsedArgs {
344
359
  // Preserve historical stale-index cleanup once endpoint validation has
345
360
  // succeeded; only the malformed-env throw path deliberately leaves it alone.
346
361
  if (!session) getActiveSession();
347
- if (!parsed.target && !parsed.url && process.env.CDP_TARGET) { parsed.target = process.env.CDP_TARGET; parsed.targetSource = 'env'; }
362
+ if (!sessionStart && !parsed.target && !parsed.url && process.env.CDP_TARGET) { parsed.target = process.env.CDP_TARGET; parsed.targetSource = 'env'; }
348
363
  return parsed;
349
364
  }
350
365
 
@@ -3,15 +3,16 @@ import { explainSnapshot, type ExplainMissingSelector } from '../../measure/expl
3
3
  import { ArtifactResolutionError, resolveSnapRef } from '../../../output/artifact.js';
4
4
  import { data, emitResult, fact, line, lineList, text, type FactLine, type RenderableResult } from '../../../output/render.js';
5
5
 
6
- const USAGE = `capture measure explain <snap> --selector <sel> — per-element cascade/stacking/clipping explanation over one snapshot
6
+ const USAGE = `capture measure explain <snap> --selector <sel> — per-element cascade, ink-box, stacking, and clipping measurements over one snapshot
7
7
 
8
8
  input:
9
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>
10
+ --selector <sel> element selector (required): CSS tag/#id/.class/[attr]/[attr="value"] compounds with descendant or child relations (or a verbatim recorded path), backend:<id>, axid:<id>, ax:<name>, or text:<text>
11
+ --state <name> one recorded forced state; derives border/ink and clipping comparisons from its post-force target values
11
12
  --size include size/layout provenance (box/flex/grid/constraints)
12
13
  --text include text line/baseline/font/wrap metrics
13
14
  --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
+ output: <explain selector=… matches=…> — border/ink boxes with named paint contributors and rectangular overflow padding-box comparisons, each naming its base or forced-state provenance; unavailable forced-state or captured box evidence is stated explicitly; --json mirrors
15
16
  effects: read-only — reads existing snapshot artifacts, never drives the browser`;
16
17
 
17
18
  function attestation(ref: { id: string; dir: string }, meta: { settled?: boolean; settleMs?: number }) {
@@ -44,13 +45,16 @@ function caveatSuffix(caveats: readonly { regionId: string; selector?: string; r
44
45
  }
45
46
 
46
47
  function recoverySections(missing: ExplainMissingSelector): FactLine[] {
47
- const { candidates, recordCount, kind } = missing.available;
48
+ const { candidates, recordCount, kind, interpretedValue } = missing.available;
48
49
  const label = kind === 'css' ? 'CSS selectors' : `${kind}: selector inputs`;
49
50
  const rows = candidates.length
50
51
  ? candidates.map((candidate, index) => fact`${index + 1}. ${candidate}`)
51
52
  : [fact`No recorded ${label} were available from geometry records.`];
53
+ const ranking = interpretedValue
54
+ ? fact`Recovery candidates: ${candidates.length} shown from ${recordCount} geometry record(s); AX-name/text inputs for attribute or quoted value ${interpretedValue} rank before generated CSS paths, then identifier similarity and string distance rank each group.`
55
+ : fact`Nearest recorded ${label}: ${candidates.length} shown from ${recordCount} geometry record(s), ranked by identifier similarity then string distance from the requested selector.`;
52
56
  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.`,
57
+ ranking,
54
58
  lineList(rows),
55
59
  text`All recorded selector and identity facts remain in this snapshot's geometry.json, ax.json, and text.json artifacts.`,
56
60
  ];
@@ -80,10 +84,20 @@ export async function cmdMeasureExplain(parsed: ParsedArgs, _args: string[]): Pr
80
84
  process.exitCode = 1;
81
85
  return;
82
86
  }
87
+ if ((parsed.state?.length ?? 0) > 1) {
88
+ emitResult(invalidInput(fact`Expected at most one --state value; received ${parsed.state!.length}.`), { json: parsed.json });
89
+ process.exitCode = 1;
90
+ return;
91
+ }
92
+ if (parsed.state?.[0] === '') {
93
+ emitResult(invalidInput(text`The --state value must be a nonempty recorded state name.`), { json: parsed.json });
94
+ process.exitCode = 1;
95
+ return;
96
+ }
83
97
 
84
98
  try {
85
99
  const ref = await resolveSnapRef(parsed.positional[0]!);
86
- const report = explainSnapshot(ref, parsed.selector, { size: parsed.size, text: parsed.text, form: parsed.form });
100
+ const report = explainSnapshot(ref, parsed.selector, { size: parsed.size, text: parsed.text, form: parsed.form, state: parsed.state?.[0] });
87
101
  if (report.kind === 'missing-selector') {
88
102
  emitResult({
89
103
  tag: 'error',
@@ -112,8 +126,9 @@ export async function cmdMeasureExplain(parsed: ParsedArgs, _args: string[]): Pr
112
126
  size: Boolean(parsed.size),
113
127
  text: Boolean(parsed.text),
114
128
  form: Boolean(parsed.form),
129
+ state: parsed.state?.[0] ?? 'base',
115
130
  },
116
- summary: fact`Recorded cascade, stacking, clipping, focus, scroll, query, and state context for ${targetSelector}.`,
131
+ summary: fact`Recorded cascade, border/ink geometry, stacking, clipping, focus, scroll, query, and state context for ${targetSelector}${parsed.state?.[0] ? ` under requested state ${parsed.state[0]}` : ' from base-state artifacts'}.`,
117
132
  sections,
118
133
  followUp: parsed.size || parsed.text || parsed.form
119
134
  ? text`Re-run against a new snapshot to compare the same recorded measurements after a page change.`
@@ -19,12 +19,13 @@ import { cmdMeasureMapFocus } from './map-focus.js';
19
19
  import { cmdMeasureMapScroll } from './map-scroll.js';
20
20
  import { cmdMeasureMapLayers } from './map-layers.js';
21
21
  import { cmdMeasureMapAx } from './map-ax.js';
22
+ import { cmdMeasureMapPaint } from './map-paint.js';
22
23
 
23
24
  /** Root-help representation of this branch, assembled by `src/capture.ts`. */
24
25
  export const COMMAND_BLOCK = `<command name="measure">
25
26
  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\`
27
+ use when measuring layout/content/targetability facts, diffing snapshots, or reading one facet (focus, scroll, layers, ax, paint) of the substrate
28
+ snap · check · diff · census · explain · sweep · map (focus|scroll|layers|ax|paint) — \`capture measure -h\`
28
29
  </command>`;
29
30
 
30
31
  export const MEASURE_USAGE = `capture measure — enriched snapshot substrate + read-only queries over it.
@@ -42,18 +43,19 @@ Findings exit 0 — a report, not a failure.
42
43
  <subcommand name="census" args="[--snap <id>]... [--url <url>]... [--set-file <path>] --axis <axis>" whenToUse="value distributions across one or more snapshots"/>
43
44
  <subcommand name="explain" args="<snap> --selector <sel> [--size] [--text] [--form]" whenToUse="per-element cascade/stacking/clipping/size/text/form explanation"/>
44
45
  <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
+ <subcommand name="map" args="focus|scroll|layers|ax [url|snap] | paint <snap> --selector <target> [--state <name>]" whenToUse="read one facet of a snapshot's substrate — capture measure map -h"/>
46
47
 
47
48
  capture measure <leaf> -h Per-leaf usage`;
48
49
 
49
50
  export const MEASURE_MAP_USAGE = `capture measure map — read one facet of a snapshot's substrate (no browser re-drive).
50
51
 
51
- A URL target first creates a snap; a snap target reads its existing artifact.
52
+ Focus, scroll, layers, and ax accept a URL target and create a snap first; paint requires an existing snapshot and reads it without browser driving.
52
53
 
53
54
  <subcommand name="focus" args="[url|snap]" whenToUse="keyboard traversal order (focus.json)"/>
54
55
  <subcommand name="scroll" args="[url|snap]" whenToUse="scroll-container topology (scroll.json)"/>
55
56
  <subcommand name="layers" args="[url|snap]" whenToUse="paint/compositor layer map (layers.json)"/>
56
57
  <subcommand name="ax" args="[url|snap]" whenToUse="AX-tree ↔ layout map (ax.json + geometry.json)"/>
58
+ <subcommand name="paint" args="<snap> --selector <target> [--state <name>]" whenToUse="elements painted above one target and their recorded AABB coverage (geometry.json + layers.json)"/>
57
59
 
58
60
  capture measure map <leaf> -h Per-leaf usage`;
59
61
 
@@ -97,6 +99,8 @@ async function measureMapMain(parsed: ParsedArgs, args: string[]): Promise<void>
97
99
  return cmdMeasureMapLayers(rest, args);
98
100
  case 'ax':
99
101
  return cmdMeasureMapAx(rest, args);
102
+ case 'paint':
103
+ return cmdMeasureMapPaint(rest, args);
100
104
  case undefined:
101
105
  console.log(MEASURE_MAP_USAGE);
102
106
  return;
@@ -0,0 +1,273 @@
1
+ import { type ParsedArgs } from '../../types.js';
2
+ import { ArtifactResolutionError, readGeometry, readLayers, readMeta, readStates, resolveSnapRef, type SnapRef } from '../../../output/artifact.js';
3
+ import { resolveSelectorInput, type ElementRecord } from '../../../output/selector.js';
4
+ import { deriveInkBox, type InkRect } from '../../measure/ink.js';
5
+ import { baseInkSource, forcedStateInkSource, isInkSource } from '../../measure/ink-source.js';
6
+ import { emitResult, fact, line, lineList, text, type FactLine, type RenderableResult } from '../../../output/render.js';
7
+
8
+ const USAGE = `capture measure map paint <snap> --selector <target> [--state <name>] — recorded AABB coverage by elements painted above one target
9
+
10
+ input:
11
+ <snap> snapshot id in the active session or absolute artifact path (required)
12
+ --selector <target> recorded element selector or backend:<id> (required)
13
+ --state <name> one forced state recorded in states.json; uses that target's post-force border box and computed paint styles
14
+ output: <paint-map …> — target border and ink boxes; visible non-subtree elements after the target in DOMSnapshot back-to-front paint order; each AABB intersection area and fraction of each target box; --json mirrors
15
+ limits: DOMSnapshot order ranks painted layout nodes, not pixel fragments. Coverage is axis-aligned border/ink-box intersection over recorded geometry, not a pixel-exact paint test.
16
+ effects: read-only — reads geometry.json, layers.json, and (with --state) states.json from an existing snapshot; never drives a browser or writes an artifact`;
17
+
18
+ interface GeometryElement extends ElementRecord {
19
+ readonly tag?: string;
20
+ readonly domPath?: string;
21
+ readonly frame?: { readonly frameId?: string };
22
+ readonly boxModel?: Record<string, unknown> | null;
23
+ readonly paint?: Record<string, string | null>;
24
+ readonly zIndex?: string;
25
+ readonly visibility?: { readonly visible?: boolean; readonly opacity?: number };
26
+ }
27
+
28
+ interface PaintOrderReport {
29
+ readonly paintOrder?: { readonly available?: boolean; readonly reason?: string; readonly backendNodeIds?: readonly number[]; readonly truncated?: number };
30
+ }
31
+
32
+ interface SourceBox {
33
+ readonly border: InkRect;
34
+ readonly ink: InkRect | null;
35
+ readonly provenance: string;
36
+ readonly inkUnavailable?: string;
37
+ }
38
+
39
+ interface Intersection {
40
+ readonly x: number;
41
+ readonly y: number;
42
+ readonly width: number;
43
+ readonly height: number;
44
+ readonly area: number;
45
+ }
46
+
47
+ interface Occluder {
48
+ readonly element: GeometryElement;
49
+ readonly rank: number;
50
+ readonly border: Intersection;
51
+ readonly ink: Intersection | null;
52
+ }
53
+
54
+ interface CoverageUnavailable {
55
+ readonly element: GeometryElement;
56
+ readonly rank: number;
57
+ readonly reason: string;
58
+ }
59
+
60
+ function area(rect: InkRect): number {
61
+ return rect.width * rect.height;
62
+ }
63
+
64
+ function intersection(a: InkRect, b: InkRect): Intersection | null {
65
+ const x = Math.max(a.x, b.x);
66
+ const y = Math.max(a.y, b.y);
67
+ const right = Math.min(a.x + a.width, b.x + b.width);
68
+ const bottom = Math.min(a.y + a.height, b.y + b.height);
69
+ const width = right - x;
70
+ const height = bottom - y;
71
+ if (width <= 0 || height <= 0) return null;
72
+ return { x, y, width, height, area: width * height };
73
+ }
74
+
75
+ function relatedInTree(a: GeometryElement, b: GeometryElement): boolean {
76
+ if (!a.domPath || !b.domPath) return false;
77
+ if (a.frame?.frameId !== b.frame?.frameId) return false;
78
+ return a.domPath.startsWith(`${b.domPath}/`) || b.domPath.startsWith(`${a.domPath}/`);
79
+ }
80
+
81
+ function paints(element: GeometryElement): boolean {
82
+ return element.visibility?.visible === true && typeof element.visibility.opacity === 'number' && element.visibility.opacity > 0;
83
+ }
84
+
85
+ function formatBox(box: InkRect): string {
86
+ return `x=${box.x}…${box.x + box.width} y=${box.y}…${box.y + box.height} (w=${box.width} h=${box.height} area=${area(box)}px²)`;
87
+ }
88
+
89
+ function formatIntersection(value: Intersection): string {
90
+ return `x=${value.x}…${value.x + value.width} y=${value.y}…${value.y + value.height}; area=${value.area}px²`;
91
+ }
92
+
93
+ function percentage(covered: number, total: number): string {
94
+ return total > 0 ? `${(covered / total * 100).toFixed(2)}%` : 'unavailable';
95
+ }
96
+
97
+ function targetBox(target: GeometryElement, state?: string, states?: unknown): SourceBox | { readonly unavailable: string } {
98
+ const source = state === undefined
99
+ ? baseInkSource(target)
100
+ : forcedStateInkSource(states, target, state);
101
+ if (!isInkSource(source)) return source;
102
+ const ink = deriveInkBox(source.rect, source.styles);
103
+ if (ink.inkBox) return { border: source.rect, ink: ink.inkBox, provenance: source.provenance };
104
+ const inkUnavailable = ink.missingStyles.length
105
+ ? `computed styles were not captured: ${ink.missingStyles.join(', ')}`
106
+ : `paint extents could not be resolved: ${ink.unresolved.join(', ')}`;
107
+ return { border: source.rect, ink: null, provenance: source.provenance, inkUnavailable };
108
+ }
109
+
110
+ function missingSelectorResult(ref: SnapRef, selector: string): RenderableResult {
111
+ return {
112
+ tag: 'error',
113
+ attestation: { kind: 'snapshot', id: ref.id, path: ref.dir },
114
+ attrs: { command: 'measure map paint', status: 'missing_selector', selector },
115
+ summary: fact`No geometry record matched selector input ${selector}.`,
116
+ followUp: text`Use a recorded selector or backend:<id> from geometry.json.`,
117
+ };
118
+ }
119
+
120
+ export function buildMeasureMapPaintResult(ref: SnapRef, selector: string, state?: string): RenderableResult {
121
+ const geometry = readGeometry<{ elements?: GeometryElement[] }>(ref).elements ?? [];
122
+ const matches = resolveSelectorInput(geometry, selector) as GeometryElement[];
123
+ if (!matches.length) return missingSelectorResult(ref, selector);
124
+
125
+ const target = matches[0]!;
126
+ const source = targetBox(target, state, state === undefined ? undefined : readStates(ref));
127
+ const meta = readMeta<{ settled?: boolean }>(ref);
128
+ const targetName = target.selector ?? target.id;
129
+ if ('unavailable' in source) {
130
+ return {
131
+ tag: 'paint-map',
132
+ attestation: { kind: 'snapshot', id: ref.id, path: ref.dir, note: meta.settled === false ? text`Snapshot was captured unsettled.` : text`Snapshot was settled before its geometry was captured.` },
133
+ attrs: { selector: targetName, matches: matches.length, state: state ?? 'base', status: 'ink_source_unavailable' },
134
+ summary: fact`Paint coverage could not use the target's ${state === undefined ? 'base-state' : `forced ${state} state`} border and ink source: ${source.unavailable}.`,
135
+ followUp: text`Reacquire the snapshot with the target's border box and required paint styles recorded.`,
136
+ };
137
+ }
138
+
139
+ const report = readLayers<PaintOrderReport>(ref);
140
+ const paintOrder = report.paintOrder;
141
+ const ids = paintOrder?.backendNodeIds ?? [];
142
+ const rankByBackend = new Map<number, number>();
143
+ ids.forEach((id, index) => rankByBackend.set(id, index));
144
+ const targetRank = typeof target.backendNodeId === 'number' ? rankByBackend.get(target.backendNodeId) : undefined;
145
+ const orderUnavailable = !paintOrder?.available
146
+ ? `DOMSnapshot paint order was unavailable${paintOrder?.reason ? `: ${paintOrder.reason}` : ''}`
147
+ : paintOrder.truncated && paintOrder.truncated > 0
148
+ ? `DOMSnapshot paint order omitted ${paintOrder.truncated} backend node id(s) at the collector cap`
149
+ : targetRank === undefined
150
+ ? `the target's backend node id was not present in recorded DOMSnapshot paint order`
151
+ : undefined;
152
+ if (orderUnavailable) {
153
+ return {
154
+ tag: 'paint-map',
155
+ attestation: { kind: 'snapshot', id: ref.id, path: ref.dir, note: meta.settled === false ? text`Snapshot was captured unsettled.` : text`Snapshot was settled before its geometry was captured.` },
156
+ attrs: { selector: targetName, matches: matches.length, state: state ?? 'base', status: 'paint_order_unavailable' },
157
+ summary: fact`Paint coverage is unavailable because ${orderUnavailable}.`,
158
+ sections: [
159
+ fact`Target border box from ${source.provenance}: ${formatBox(source.border)}.`,
160
+ source.ink ? fact`Target ink box: ${formatBox(source.ink)}.` : fact`Target ink box was unavailable because ${source.inkUnavailable}.`,
161
+ text`Criterion: DOMSnapshot backend-node paint order is required as the back-to-front stacking-order source; no z-index fallback is inferred.`,
162
+ ],
163
+ followUp: text`Reacquire a snapshot whose layers collector records DOMSnapshot paint order.`,
164
+ };
165
+ }
166
+
167
+ const occluders: Occluder[] = [];
168
+ const coverageUnavailable: CoverageUnavailable[] = [];
169
+ for (const candidate of geometry) {
170
+ if (candidate.id === target.id || typeof candidate.backendNodeId !== 'number' || !paints(candidate) || relatedInTree(candidate, target)) continue;
171
+ const rank = rankByBackend.get(candidate.backendNodeId);
172
+ if (rank === undefined || rank <= targetRank) continue;
173
+ const candidateBox = baseInkSource(candidate);
174
+ if (!isInkSource(candidateBox)) {
175
+ coverageUnavailable.push({ element: candidate, rank, reason: candidateBox.unavailable });
176
+ continue;
177
+ }
178
+ const border = intersection(source.border, candidateBox.rect);
179
+ if (!border) continue;
180
+ const ink = source.ink ? intersection(source.ink, candidateBox.rect) : null;
181
+ occluders.push({ element: candidate, rank, border, ink });
182
+ }
183
+ occluders.sort((a, b) => a.rank - b.rank || a.element.id.localeCompare(b.element.id));
184
+ coverageUnavailable.sort((a, b) => a.rank - b.rank || a.element.id.localeCompare(b.element.id));
185
+
186
+ const sections: FactLine[] = [
187
+ fact`Target ${targetName}${matches.length > 1 ? ` matched ${matches.length} geometry records; this report uses the first in snapshot order` : ''}.`,
188
+ fact`Target border box from ${source.provenance}: ${formatBox(source.border)}.`,
189
+ source.ink
190
+ ? fact`Target ink box from ${source.provenance}: ${formatBox(source.ink)}.`
191
+ : fact`Target ink box was unavailable because ${source.inkUnavailable}.`,
192
+ text`Paint-order criterion: DOMSnapshot backend-node paint order is ascending back-to-front. Eligible occluders have a recorded rank after the target, visible=true, opacity>0, and are outside the target's DOM subtree and ancestor chain. Coverage is AABB intersection over recorded border/ink geometry, not a pixel-exact paint test.`,
193
+ ];
194
+ if (coverageUnavailable.length) {
195
+ sections.push(lineList(coverageUnavailable.map((candidate, index) => {
196
+ const name = candidate.element.selector ?? candidate.element.id;
197
+ return fact`Coverage unavailable ${index + 1}. ${name}: DOMSnapshot paint rank ${candidate.rank}; ${candidate.reason}.`;
198
+ })));
199
+ }
200
+ if (!occluders.length) {
201
+ sections.push(coverageUnavailable.length
202
+ ? text`No measured AABB intersection was available among candidates with captured border boxes; the occluder set is not exhaustive because coverage is unavailable for the recorded candidate(s) above.`
203
+ : text`No eligible recorded elements painted above the target intersect either target box; the occluder set is empty.`);
204
+ } else {
205
+ const borderArea = area(source.border);
206
+ const inkArea = source.ink ? area(source.ink) : 0;
207
+ sections.push(lineList(occluders.map((occluder, index) => {
208
+ const name = occluder.element.selector ?? occluder.element.id;
209
+ const inkCoverage = source.ink
210
+ ? occluder.ink
211
+ ? `; ink-box intersection ${formatIntersection(occluder.ink)} (${percentage(occluder.ink.area, inkArea)} of target ink box)`
212
+ : `; ink-box intersection none (0.00% of target ink box)`
213
+ : `; ink-box coverage unavailable because the target ink box was unavailable`;
214
+ return fact`${index + 1}. ${name}: DOMSnapshot paint rank ${occluder.rank} (target rank ${targetRank}); z-index=${occluder.element.zIndex ?? 'not recorded'}; border-box intersection ${formatIntersection(occluder.border)} (${percentage(occluder.border.area, borderArea)} of target border box)${inkCoverage}.`;
215
+ })));
216
+ }
217
+
218
+ return {
219
+ tag: 'paint-map',
220
+ attestation: { kind: 'snapshot', id: ref.id, path: ref.dir, note: meta.settled === false ? text`Snapshot was captured unsettled.` : text`Snapshot was settled before its geometry was captured.` },
221
+ attrs: { selector: targetName, matches: matches.length, state: state ?? 'base', occluders: occluders.length, 'coverage-unavailable': coverageUnavailable.length, 'paint-order': 'domsnapshot-back-to-front', settled: meta.settled ?? 'unknown' },
222
+ summary: fact`Recorded elements painting above ${targetName} are measured against both its border box and its derived ink box.`,
223
+ sections,
224
+ followUp: text`Use capture measure explain <snap> --selector <selector> for the target's captured stacking and clipping provenance.`,
225
+ };
226
+ }
227
+
228
+ function invalidInput(message: FactLine): RenderableResult {
229
+ return {
230
+ tag: 'error',
231
+ attrs: { command: 'measure map paint', status: 'invalid_input' },
232
+ summary: message,
233
+ followUp: text`Run capture measure map paint <snap> --selector <target> [--state <name>].`,
234
+ };
235
+ }
236
+
237
+ export async function cmdMeasureMapPaint(parsed: ParsedArgs, _args: string[]): Promise<void> {
238
+ if (parsed.help) {
239
+ console.log(USAGE);
240
+ return;
241
+ }
242
+ if (parsed.positional.length !== 1) {
243
+ emitResult(invalidInput(fact`Expected exactly one snapshot target; received ${parsed.positional.length}.`), { json: parsed.json });
244
+ process.exitCode = 1;
245
+ return;
246
+ }
247
+ if (!parsed.selector?.trim()) {
248
+ emitResult(invalidInput(text`The --selector flag is required.`), { json: parsed.json });
249
+ process.exitCode = 1;
250
+ return;
251
+ }
252
+ if ((parsed.state?.length ?? 0) > 1) {
253
+ emitResult(invalidInput(fact`Expected at most one --state value; received ${parsed.state!.length}.`), { json: parsed.json });
254
+ process.exitCode = 1;
255
+ return;
256
+ }
257
+
258
+ try {
259
+ const ref = await resolveSnapRef(parsed.positional[0]!);
260
+ const result = buildMeasureMapPaintResult(ref, parsed.selector, parsed.state?.[0]);
261
+ emitResult(result, { json: parsed.json });
262
+ if (result.tag === 'error' || result.attrs?.status === 'ink_source_unavailable' || result.attrs?.status === 'paint_order_unavailable') process.exitCode = 1;
263
+ } catch (error) {
264
+ const detail = error instanceof ArtifactResolutionError || error instanceof Error ? error.message : String(error);
265
+ emitResult({
266
+ tag: 'error',
267
+ attrs: { command: 'measure map paint', status: 'artifact_unavailable' },
268
+ summary: fact`Paint-map facts could not be read: ${detail}`,
269
+ followUp: text`Pass a snapshot id or absolute artifact path created by capture measure snap.`,
270
+ }, { json: parsed.json });
271
+ process.exitCode = 1;
272
+ }
273
+ }
@@ -35,7 +35,9 @@ input:
35
35
  --capture-unsettled write the full substrate despite non-settlement, marking unstable regions
36
36
  --skip-collector <name> leave one collector out entirely (repeatable): ax, styles, queries, animation, layers, media, geometry, hittest, text, forms, focus, scroll, states, pixels. A collector that fails is recorded and skipped; every other collector's substrate is still written
37
37
  --pixels also write per-element raster crops
38
- --state <state[:selector]> force a pseudo-state or real control state (repeatable)
38
+ --list-crops print every crop artifact path (default prints crop count and crops/ directory only)
39
+ --state <state[:selector]> force a pseudo-state or real control state (repeatable); each supported forced element records target/descendant/following-peer style rows and state image/diff artifacts
40
+ --state-padding <px> CSS-pixel padding around the target border box in each forced-state diff crop (default 8)
39
41
  --viewport <WxH> temporarily capture at a CSS-pixel viewport (repeatable); exact <positive-safe-int>x<positive-safe-int> grammar with lowercase x and no whitespace
40
42
  --color-scheme <dark|light> temporarily capture under this prefers-color-scheme value; cleared afterward
41
43
  output: <snapshot id=… path=…> — the settled artifact directory (geometry, styles, hit-test, text, forms, animation, ax, queries, focus, scroll, layers; per-element crops with --pixels) every other measure/motion query leaf reads instead of re-driving the browser; --json mirrors
@@ -48,8 +50,11 @@ interface SnapshotMetaForBase {
48
50
  export interface MeasureSnapCapture {
49
51
  readonly id: string;
50
52
  readonly dir: string;
53
+ /** A bare ID only resolves while its owning session remains active. */
54
+ readonly queryRef: string;
51
55
  readonly base?: SnapRef;
52
56
  readonly artifacts: readonly string[];
57
+ readonly listCrops?: boolean;
53
58
  }
54
59
 
55
60
  interface ViewportSpec extends Viewport {
@@ -69,18 +74,38 @@ function readArrayCount(filePath: string, field: string): number | undefined {
69
74
  }
70
75
  }
71
76
 
72
- function artifactEntries(dir: string, artifacts: readonly string[]): ArtifactEntry[] {
77
+ function artifactEntries(dir: string, artifacts: readonly string[], listCrops: boolean): ArtifactEntry[] {
73
78
  const entries: ArtifactEntry[] = [];
74
79
  const geometryCount = readArrayCount(path.join(dir, 'geometry.json'), 'elements');
80
+ const crops = artifacts.filter((name) => name.startsWith('crops/'));
75
81
  for (const name of artifacts) {
82
+ if (!listCrops && name.startsWith('crops/')) continue;
76
83
  entries.push({
77
84
  name,
78
85
  ...(name === 'geometry.json' && geometryCount !== undefined ? { note: `${geometryCount} elements` } : {}),
79
86
  });
80
87
  }
88
+ if (!listCrops && crops.length) entries.push({ name: 'crops/', note: `${crops.length} crops; pass --list-crops for paths` });
81
89
  return entries;
82
90
  }
83
91
 
92
+ interface StateSummaryAffected { readonly relation?: string; readonly selector?: string; readonly style?: { readonly changed?: readonly string[] }; }
93
+ interface StateSummaryElement { readonly state?: string; readonly supported?: boolean; readonly style?: { readonly changed?: readonly string[] }; readonly affected?: { readonly elements?: readonly StateSummaryAffected[] }; }
94
+
95
+ function stateSummary(dir: string): ReturnType<typeof fact> | undefined {
96
+ try {
97
+ const value = JSON.parse(fs.readFileSync(path.join(dir, 'states.json'), 'utf8')) as { readonly elements?: readonly StateSummaryElement[] };
98
+ const lines = (value.elements ?? []).filter((element) => element.supported).map((element) => {
99
+ const target = element.style?.changed ?? [];
100
+ const descendants = (element.affected?.elements ?? []).filter((affected) => affected.relation !== 'target' && (affected.style?.changed?.length ?? 0) > 0).map((affected) => `${affected.relation} ${affected.selector ?? '<identity unavailable>'}: ${(affected.style?.changed ?? []).join(', ')}`);
101
+ return `${element.state ?? 'state'} target style changes=${target.length ? target.join(', ') : 'none'}${descendants.length ? `; descendant/peer changes=${descendants.join('; ')}` : ''}`;
102
+ });
103
+ return lines.length ? fact`Forced state measurements: ${lines.join(' | ')}.` : undefined;
104
+ } catch {
105
+ return undefined;
106
+ }
107
+ }
108
+
84
109
  type ViewportClient = { send: (method: string, params?: Record<string, unknown>) => Promise<unknown> };
85
110
 
86
111
  type ViewportOwnership =
@@ -277,13 +302,14 @@ export async function captureMeasureSnap(parsed: ParsedArgs, targetRef = parsed.
277
302
  viewport: viewport?.label ?? null,
278
303
  colorScheme,
279
304
  skipCollectors: parsed.skipCollector ?? [],
305
+ ...({ stateDiffPaddingCssPx: parsed.statePadding ?? 8 } as {}),
280
306
  });
281
307
  artifacts = result.artifacts;
282
308
  }));
283
309
  }, { settle: 0 });
284
310
 
285
311
  if (!snapDir) throw new Error('measure snap did not allocate an artifact directory');
286
- return { id: snapId, dir: snapDir, artifacts, ...(base ? { base } : {}) };
312
+ return { id: snapId, dir: snapDir, queryRef: active ? snapId : snapDir, artifacts, ...(base ? { base } : {}) };
287
313
  } catch (err) {
288
314
  if (allocatedRoot && !(err instanceof SnapshotCaptureTimeout)) removeArtifactTree(allocatedRoot);
289
315
  throw err;
@@ -304,8 +330,9 @@ function buildSnapshotResult(captured: MeasureSnapCapture): RenderableResult {
304
330
  }>({ kind: 'snap', id: captured.id, dir: captured.dir });
305
331
  const incomplete = (meta.collectors ?? []).filter((c) => c.status !== 'ok');
306
332
  const elements = readArrayCount(path.join(captured.dir, 'geometry.json'), 'elements');
307
- const artifacts = artifactEntries(captured.dir, captured.artifacts);
333
+ const artifacts = artifactEntries(captured.dir, captured.artifacts, Boolean(captured.listCrops));
308
334
  const capturedFullSubstrate = fs.existsSync(path.join(captured.dir, 'geometry.json'));
335
+ const stateMeasurement = stateSummary(captured.dir);
309
336
 
310
337
  return {
311
338
  tag: 'snapshot',
@@ -332,10 +359,10 @@ function buildSnapshotResult(captured: MeasureSnapCapture): RenderableResult {
332
359
  // not only in meta.json — its artifact is absent (timeout/error/skipped)
333
360
  // or partly built from timed-out reads (degraded), and a caller who reads
334
361
  // the remaining substrate as a whole page would be reading a hole.
335
- ...(incomplete.length ? { sections: [collectorNote(incomplete)] } : {}),
362
+ ...((incomplete.length || stateMeasurement) ? { sections: [...(incomplete.length ? [collectorNote(incomplete)] : []), ...(stateMeasurement ? [stateMeasurement] : [])] } : {}),
336
363
  artifacts: formatArtifactList(artifacts),
337
364
  followUp: meta.settled || capturedFullSubstrate
338
- ? fact`Query snapshot ${captured.id} with \`capture measure check ${captured.id}\`, \`capture measure census --snap ${captured.id} --axis color\`, or \`capture measure map focus ${captured.id}\`.`
365
+ ? fact`Query snapshot ${captured.id} with \`capture measure check ${captured.queryRef}\`, \`capture measure census --snap ${captured.queryRef} --axis color\`, or \`capture measure map focus ${captured.queryRef}\`.`
339
366
  : fact`Re-run with \`--freeze-animations\`, a longer \`--settle-timeout\`, or \`--capture-unsettled\` to choose a queryable capture.`,
340
367
  };
341
368
  }
@@ -417,7 +444,7 @@ export async function cmdMeasureSnap(parsed: ParsedArgs, _args: string[]): Promi
417
444
  const colorScheme = parseColorScheme(parsed.colorScheme);
418
445
  const results = [] as RenderableResult[];
419
446
  for (const viewport of viewports) {
420
- const capture = await captureMeasureSnap(parsed, parsed.positional[0], viewport?.label, colorScheme);
447
+ const capture = { ...(await captureMeasureSnap(parsed, parsed.positional[0], viewport?.label, colorScheme)), ...(parsed.listCrops ? { listCrops: true } : {}) };
421
448
  captures.push(capture);
422
449
  results.push(buildSnapshotResult(capture));
423
450
  }
@@ -50,7 +50,7 @@ input:
50
50
  <code> JS source as one argument (exactly one of <code> / --file)
51
51
  --file <path> read the JS source from a file instead of inline
52
52
  --settle <ms> network-settle window after execution so an active session's HAR captures the requests the code triggers (default: ${DEFAULT_SETTLE_MS}; 0 disables); the block reports the measured requested/waited settle
53
- --target <id> target a tab explicitly (default: the active session tab; with no active session, --target or --url is required)
53
+ --target <id> target a tab explicitly (default: the active session tab, else exactly one available page tab; multiple page tabs require --target or --url)
54
54
  --url <pattern> target the first tab whose URL matches <pattern>
55
55
  output:
56
56
  <exec-result result-chars=…> — the JSON-serialized return value inline, escaped and capped at ${GENEROUS_RESULT_CAP} chars; a larger result is also written whole to a private artifact file (the active session's page/ dir, else a one-shot artifact dir) whose absolute path appears in the block. --json mirrors the same block with the value at full fidelity.
@@ -22,7 +22,7 @@ import { cmdPageElements } from './elements.js';
22
22
  /** Root-help representation of this branch, assembled by `src/capture.ts`. */
23
23
  export const COMMAND_BLOCK = `<command name="page">
24
24
  verbs against the live session tab — act (click, type, scroll, navigate, exec) and look (shot, elements)
25
- use when driving or inspecting the page a session opened; all verbs auto-target the active session tab, --target/--url override
25
+ use when driving or inspecting the tab a session opened or adopted; all verbs auto-target the active session tab, --target/--url override
26
26
  click · type · scroll · navigate · exec · shot · elements — \`capture page -h\`
27
27
  </command>`;
28
28
 
@@ -39,7 +39,7 @@ During a live composed recording every verb routes through the recorder.
39
39
  <subcommand name="scroll" args="<target> --to <top|bottom|px> [--no-screenshot]" whenToUse="scroll one resolved container to a position"/>
40
40
  <subcommand name="navigate" args="<url> [--settle <ms>]" whenToUse="navigate the tab and wait for load + settle"/>
41
41
  <subcommand name="exec" args="<code> | --file <path>" whenToUse="run arbitrary JS in the tab (expressions, return, await)"/>
42
- <subcommand name="shot" args="[--viewport <WxH>] [--full-page] [--color-scheme <dark|light>] [--out <path>]" whenToUse="look at the page right now without acting"/>
42
+ <subcommand name="shot" args="[--viewport <WxH>] [--full-page] [--color-scheme <dark|light>] [--crop <x,y,w,h> | --crop-selector <sel> [--pad <px>]] [--zoom <factor>] [--out <path>]" whenToUse="look at the page right now without acting"/>
43
43
  <subcommand name="elements" args="[--all] [--limit <n>]" whenToUse="list what can be acted on — role, name, backend:<id> per element"/>
44
44
 
45
45
  capture page <leaf> -h Per-leaf usage`;