@crouton-kit/capture 1.4.2 → 1.4.11

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 (110) hide show
  1. package/README.md +187 -0
  2. package/bin/capture +31278 -7312
  3. package/package.json +3 -1
  4. package/src/capture.ts +21 -18
  5. package/src/cdp/args.ts +269 -49
  6. package/src/cdp/bridge/server.ts +6 -5
  7. package/src/cdp/bridge/spawn.ts +41 -97
  8. package/src/cdp/commands/bridge-serve.ts +11 -21
  9. package/src/cdp/commands/cdp.ts +44 -22
  10. package/src/cdp/commands/heap/census.ts +61 -0
  11. package/src/cdp/commands/heap/common.ts +77 -0
  12. package/src/cdp/commands/heap/diff.ts +92 -0
  13. package/src/cdp/commands/heap/index.ts +38 -0
  14. package/src/cdp/commands/heap/objects.ts +42 -0
  15. package/src/cdp/commands/heap/retainers.ts +52 -0
  16. package/src/cdp/commands/heap/snapshot.ts +94 -0
  17. package/src/cdp/commands/lib.ts +9 -17
  18. package/src/cdp/commands/lighthouse.ts +414 -0
  19. package/src/cdp/commands/measure/census.ts +2 -1
  20. package/src/cdp/commands/measure/check.ts +70 -16
  21. package/src/cdp/commands/measure/diff.ts +1 -2
  22. package/src/cdp/commands/measure/explain.ts +44 -7
  23. package/src/cdp/commands/measure/index.ts +24 -32
  24. package/src/cdp/commands/measure/map-ax.ts +2 -1
  25. package/src/cdp/commands/measure/map-focus.ts +2 -1
  26. package/src/cdp/commands/measure/map-layers.ts +5 -3
  27. package/src/cdp/commands/measure/map-scroll.ts +2 -1
  28. package/src/cdp/commands/measure/snap.ts +5 -4
  29. package/src/cdp/commands/measure/sweep.ts +16 -11
  30. package/src/cdp/commands/measure/text.ts +147 -0
  31. package/src/cdp/commands/motion/index.ts +10 -18
  32. package/src/cdp/commands/motion/jank.ts +13 -22
  33. package/src/cdp/commands/motion/mask.ts +10 -9
  34. package/src/cdp/commands/motion/rec.ts +228 -26
  35. package/src/cdp/commands/motion/timeline.ts +1 -1
  36. package/src/cdp/commands/page/click.ts +15 -5
  37. package/src/cdp/commands/page/elements.ts +43 -11
  38. package/src/cdp/commands/page/exec.ts +53 -18
  39. package/src/cdp/commands/page/index.ts +20 -20
  40. package/src/cdp/commands/page/inspect.ts +338 -0
  41. package/src/cdp/commands/page/navigate.ts +83 -38
  42. package/src/cdp/commands/page/repeat.ts +176 -0
  43. package/src/cdp/commands/page/scroll.ts +10 -5
  44. package/src/cdp/commands/page/shot.ts +66 -31
  45. package/src/cdp/commands/page/type.ts +6 -2
  46. package/src/cdp/commands/perf/index.ts +32 -0
  47. package/src/cdp/commands/perf/insights.ts +214 -0
  48. package/src/cdp/commands/perf/trace.ts +235 -0
  49. package/src/cdp/commands/perf/vitals.ts +76 -0
  50. package/src/cdp/commands/session/collectors.ts +54 -0
  51. package/src/cdp/commands/stub.ts +10 -0
  52. package/src/cdp/commands/tab/close.ts +2 -2
  53. package/src/cdp/commands/tab/index.ts +16 -23
  54. package/src/cdp/commands/tab/launch.ts +1 -1
  55. package/src/cdp/commands/tab/mock/index.ts +24 -0
  56. package/src/cdp/commands/tab/mock/lifecycle.ts +88 -0
  57. package/src/cdp/commands/tab/mock/start.ts +65 -0
  58. package/src/cdp/commands/tab/mock/stop.ts +39 -0
  59. package/src/cdp/commands/tab/network.ts +22 -13
  60. package/src/cdp/commands/tab/reset.ts +11 -12
  61. package/src/cdp/connection.ts +19 -39
  62. package/src/cdp/detect.ts +27 -7
  63. package/src/cdp/dispatch.ts +25 -4
  64. package/src/cdp/domains.ts +2 -2
  65. package/src/cdp/har-recorder.ts +0 -1
  66. package/src/cdp/heap-snapshot.ts +647 -0
  67. package/src/cdp/host/client.ts +22 -0
  68. package/src/cdp/host/collector.ts +74 -0
  69. package/src/cdp/host/collectors/heap.ts +79 -0
  70. package/src/cdp/host/collectors/intercept.ts +247 -0
  71. package/src/cdp/{recorder-bridge.ts → host/collectors/motion.ts} +388 -473
  72. package/src/cdp/host/collectors/trace.ts +108 -0
  73. package/src/cdp/host/core.ts +217 -0
  74. package/src/cdp/host/handle.ts +47 -0
  75. package/src/cdp/host/held-client.ts +91 -0
  76. package/src/cdp/host/kinds.ts +97 -0
  77. package/src/cdp/host/lifecycle.ts +123 -0
  78. package/src/cdp/host/server.ts +88 -0
  79. package/src/cdp/measure/check.ts +181 -16
  80. package/src/cdp/measure/collectors/hittest.ts +16 -10
  81. package/src/cdp/measure/collectors/scroll.ts +15 -7
  82. package/src/cdp/measure/collectors/style-provenance.ts +11 -0
  83. package/src/cdp/measure/collectors/styles.ts +4 -4
  84. package/src/cdp/measure/explain.ts +14 -2
  85. package/src/cdp/measure/map-layers.ts +52 -8
  86. package/src/cdp/measure/snapshot.ts +5 -3
  87. package/src/cdp/measure/sweep.ts +29 -6
  88. package/src/cdp/measure/text.ts +227 -0
  89. package/src/cdp/motion/jank.ts +29 -83
  90. package/src/cdp/motion/mask.ts +18 -9
  91. package/src/cdp/motion/recorder.ts +86 -731
  92. package/src/cdp/scope-lock.ts +9 -9
  93. package/src/cdp/screenshot.ts +51 -21
  94. package/src/cdp/timing.ts +5 -5
  95. package/src/cdp/trace-analysis.ts +320 -0
  96. package/src/cdp/types.ts +31 -1
  97. package/src/cdp.ts +4 -6
  98. package/src/har-manager.ts +2 -4
  99. package/src/interact.ts +50 -23
  100. package/src/output/artifact.ts +49 -5
  101. package/src/output/render.ts +60 -9
  102. package/src/output/selection.ts +6 -0
  103. package/src/session/artifacts.ts +122 -12
  104. package/src/session/commands.ts +348 -113
  105. package/src/session/coordinator.ts +0 -93
  106. package/src/session/har-redact.ts +29 -23
  107. package/src/session-context.ts +13 -9
  108. package/src/cdp/bridge/protocol.ts +0 -224
  109. package/src/cdp/recorder-client.ts +0 -326
  110. package/src/cdp/tab-lock.ts +0 -132
@@ -0,0 +1,123 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { stopBridge } from '../bridge/spawn.js';
4
+ import { processPidBirthProvider, readPrivateFile, removeArtifactTree, sameBirth, unlinkPrivateFile, writeJsonPrivate } from '../../session/artifacts.js';
5
+ import { collectorKind, COLLECTOR_KINDS } from './kinds.js';
6
+ import { sendHostRequest } from './client.js';
7
+ import { scanCollectorHost, type CollectorHostHandle } from './handle.js';
8
+ import type { CollectorKind, TeardownOutcome } from './collector.js';
9
+
10
+ export type CollectorHostTeardown =
11
+ | { status: 'absent' }
12
+ | { status: 'drained'; outcomes: readonly TeardownOutcome[] }
13
+ | { status: 'reaped'; outcomes: readonly TeardownOutcome[] }
14
+ | { status: 'terminal'; error: string };
15
+
16
+ export class TerminalCollectorHostStopFailure extends Error {
17
+ constructor(readonly outcome: Extract<CollectorHostTeardown, { status: 'terminal' }>) {
18
+ super(`collector host teardown failed: ${outcome.error}`);
19
+ this.name = 'TerminalCollectorHostStopFailure';
20
+ }
21
+ }
22
+
23
+ export function isTerminalCollectorHostStopFailure(error: unknown): error is TerminalCollectorHostStopFailure {
24
+ return error instanceof TerminalCollectorHostStopFailure;
25
+ }
26
+
27
+ function isCollectorKind(value: unknown): value is CollectorKind {
28
+ return typeof value === 'string' && value in COLLECTOR_KINDS;
29
+ }
30
+
31
+ function readCollecting(dir: string): { id: string; kind: CollectorKind; startedAt: string } | null {
32
+ try {
33
+ const raw = JSON.parse(readPrivateFile(path.join(dir, 'collecting.json')).toString('utf8')) as Record<string, unknown>;
34
+ if (typeof raw.id !== 'string' || !isCollectorKind(raw.kind) || typeof raw.startedAt !== 'string') return null;
35
+ return { id: raw.id, kind: raw.kind, startedAt: raw.startedAt };
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ function collectingDirectories(root: string): string[] {
42
+ const dirs: string[] = [];
43
+ const walk = (dir: string): void => {
44
+ let entries: fs.Dirent[];
45
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
46
+ if (entries.some(entry => entry.isFile() && entry.name === 'collecting.json')) dirs.push(dir);
47
+ for (const entry of entries) if (entry.isDirectory()) walk(path.join(dir, entry.name));
48
+ };
49
+ walk(root);
50
+ return dirs;
51
+ }
52
+
53
+ function reapOrphanedCollectors(sessionDir: string): TeardownOutcome[] {
54
+ const outcomes: TeardownOutcome[] = [];
55
+ for (const dir of collectingDirectories(sessionDir)) {
56
+ const collecting = readCollecting(dir);
57
+ if (!collecting) continue;
58
+ try {
59
+ const reconstructed = collectorKind(collecting.kind).reconstruct(dir);
60
+ const summary = reconstructed.summary && typeof reconstructed.summary === 'object' && !Array.isArray(reconstructed.summary)
61
+ ? reconstructed.summary as Record<string, unknown>
62
+ : { value: reconstructed.summary };
63
+ writeJsonPrivate(path.join(dir, 'meta.json'), {
64
+ id: collecting.id,
65
+ kind: collecting.kind,
66
+ completion: 'orphaned',
67
+ reason: 'host_died',
68
+ startedAt: collecting.startedAt,
69
+ endedAt: new Date().toISOString(),
70
+ files: reconstructed.files,
71
+ summary,
72
+ ...summary,
73
+ });
74
+ unlinkPrivateFile(path.join(dir, 'collecting.json'));
75
+ outcomes.push({ status: 'reaped', id: collecting.id, kind: collecting.kind, dir });
76
+ } catch (error) {
77
+ outcomes.push({ status: 'terminal', id: collecting.id, kind: collecting.kind, dir, error: error instanceof Error ? error.message : String(error) });
78
+ }
79
+ }
80
+ return outcomes;
81
+ }
82
+
83
+ async function waitForHostExit(handle: CollectorHostHandle, timeoutMs = 2_000): Promise<void> {
84
+ const deadline = Date.now() + timeoutMs;
85
+ for (;;) {
86
+ const observed = processPidBirthProvider.read(handle.pid);
87
+ if (observed.status === 'absent' || (observed.status === 'found' && !sameBirth(observed.identity, handle.birth))) return;
88
+ if (Date.now() >= deadline) {
89
+ stopBridge(handle.pid, handle.socketPath);
90
+ return;
91
+ }
92
+ await new Promise(resolve => setTimeout(resolve, 25));
93
+ }
94
+ }
95
+
96
+ function terminal(error: string): CollectorHostTeardown {
97
+ return { status: 'terminal', error };
98
+ }
99
+
100
+ /** Stops a session's host before immutable bundle construction. A terminal result means the caller must not commit. */
101
+ export async function stopAndReapCollectorHostAtSessionStop(sessionDir: string): Promise<CollectorHostTeardown> {
102
+ const scanned = scanCollectorHost(sessionDir);
103
+ if (scanned.classification === 'absent') return { status: 'absent' };
104
+ if (!scanned.handle) return terminal(`collector host handle is ${scanned.classification}`);
105
+ if (scanned.classification === 'unknown') return terminal('collector host liveness is unknown');
106
+ if (scanned.classification === 'dead') {
107
+ const outcomes = reapOrphanedCollectors(sessionDir);
108
+ removeArtifactTree(scanned.path);
109
+ const failure = outcomes.find(outcome => outcome.status === 'terminal');
110
+ return failure ? terminal(failure.error) : { status: 'reaped', outcomes };
111
+ }
112
+ try {
113
+ const response = await sendHostRequest(scanned.handle.socketPath, { type: 'session-teardown', nonce: scanned.handle.nonce }, 30_000);
114
+ if (!response.ok) return terminal(response.error ?? 'collector host refused session teardown');
115
+ const outcomes = Array.isArray(response.outcomes) ? response.outcomes as TeardownOutcome[] : [];
116
+ const failure = outcomes.find(outcome => outcome.status === 'terminal');
117
+ if (failure) return terminal(failure.error);
118
+ await waitForHostExit(scanned.handle);
119
+ return { status: 'drained', outcomes };
120
+ } catch (error) {
121
+ return terminal(error instanceof Error ? error.message : String(error));
122
+ }
123
+ }
@@ -0,0 +1,88 @@
1
+ import * as crypto from 'node:crypto';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import { CDPClient } from '../client.js';
5
+ import { findTabByIdAcrossEndpoints } from '../targets.js';
6
+ import { closeNdjsonSocket, installProcessCleanup, listenNdjsonSocket } from '../bridge/server.js';
7
+ import { collectorHostSocketPath } from '../bridge/spawn.js';
8
+ import { processPidBirthProvider, removeArtifactTree, writeJsonPrivate } from '../../session/artifacts.js';
9
+ import { readSessionState } from '../../session-context.js';
10
+ import { applyActiveSessionNetworkConditions } from '../connection.js';
11
+ import { CollectorHost, type HostSnapshot } from './core.js';
12
+ import type { CollectorKind } from './collector.js';
13
+
14
+ export interface RunCollectorHostOptions { socketPath: string; sessionDir: string; targetId: string; port: number; }
15
+
16
+ function sameNonce(expected: string, actual: unknown): boolean {
17
+ if (typeof actual !== 'string') return false;
18
+ const a = Buffer.from(expected); const b = Buffer.from(actual);
19
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
20
+ }
21
+
22
+ export async function runCollectorHost(options: RunCollectorHostOptions): Promise<void> {
23
+ if (options.socketPath !== collectorHostSocketPath(options.sessionDir)) throw new Error('collector host socket path must match its session');
24
+ const target = await findTabByIdAcrossEndpoints(options.targetId, options.port);
25
+ if (!target?.tab.webSocketDebuggerUrl) throw new Error(`No tab found for target "${options.targetId}" on port ${options.port}.`);
26
+ const birth = processPidBirthProvider.read(process.pid);
27
+ if (birth.status !== 'found') throw new Error('could not establish collector host process identity');
28
+ const nonce = crypto.randomBytes(32).toString('hex');
29
+ const client = new CDPClient(target.tab.webSocketDebuggerUrl);
30
+ await client.waitReady();
31
+ const session = readSessionState(options.sessionDir);
32
+ if (session.targetId !== options.targetId) throw new Error('collector host target does not match session state');
33
+ await applyActiveSessionNetworkConditions(client, session, options.targetId);
34
+ let server: import('node:net').Server | undefined;
35
+ const handlePath = path.join(options.sessionDir, '.collector-host.json');
36
+ const publish = (snapshot: HostSnapshot): void => writeJsonPrivate(handlePath, {
37
+ pid: process.pid, birth: birth.identity, socketPath: options.socketPath, targetId: options.targetId, nonce, startedAt: new Date().toISOString(), ...snapshot,
38
+ });
39
+ const cleanup = (): void => {
40
+ if (server) closeNdjsonSocket(server, options.socketPath);
41
+ try { client.close(); } catch {}
42
+ };
43
+ const host = new CollectorHost(client, options.sessionDir, { pid: process.pid, birth: birth.identity, targetId: options.targetId }, publish, () => {
44
+ cleanup();
45
+ try { removeArtifactTree(handlePath); } catch {}
46
+ });
47
+ publish(host.snapshot());
48
+ server = await listenNdjsonSocket(options.socketPath, async (line, socket) => {
49
+ let request: Record<string, unknown>;
50
+ try { request = JSON.parse(line) as Record<string, unknown>; } catch { return; }
51
+ const reqId = typeof request.reqId === 'number' ? request.reqId : 0;
52
+ const type = typeof request.type === 'string' ? request.type : 'cdp';
53
+ const answer = (body: Record<string, unknown>): void => { socket.write(JSON.stringify({ reqId, type, ...body }) + '\n'); };
54
+ if (!sameNonce(nonce, request.nonce)) { answer({ ok: false, error: 'unauthorized' }); return; }
55
+ try {
56
+ switch (type) {
57
+ case 'host-status': answer({ ok: true, ...host.snapshot() }); return;
58
+ case 'claim-reserve': {
59
+ const claims = Array.isArray(request.claims) ? request.claims as never : [];
60
+ const reservation = host.reserve(claims, String(request.holderLabel ?? 'external command'), Number(request.pid), request.birth as never, request.exclusive === true);
61
+ answer({ ok: true, reservation }); return;
62
+ }
63
+ case 'claim-release': host.releaseReservation(String(request.token ?? '')); answer({ ok: true }); return;
64
+ case 'collector-start': {
65
+ const row = await host.start(request.kind as CollectorKind, request.config);
66
+ answer({ ok: true, collector: row }); return;
67
+ }
68
+ case 'collector-stop': answer({ ok: true, ...(await host.stop(String(request.id ?? ''))) }); return;
69
+ case 'session-teardown': answer({ ok: true, outcomes: await host.teardown('session-stop') }); cleanup(); return;
70
+ case 'har-flush': await host.control({ type: 'har-flush' }); answer({ ok: true }); return;
71
+ case 'cdp': {
72
+ const dispatched = await host.dispatch(
73
+ String(request.method ?? ''),
74
+ request.params as Record<string, unknown> ?? {},
75
+ typeof request.annotation === 'string' ? request.annotation : undefined,
76
+ typeof request.waitEvent === 'string' ? request.waitEvent : undefined,
77
+ typeof request.timeoutMs === 'number' ? request.timeoutMs : undefined,
78
+ request.observeDocumentResponse === true,
79
+ ) as { result?: unknown; event?: unknown; waitOutcome?: unknown; documentResponse?: unknown };
80
+ answer({ ok: true, ...(dispatched && typeof dispatched === 'object' ? dispatched as Record<string, unknown> : { result: dispatched }) });
81
+ return;
82
+ }
83
+ default: answer({ ok: false, error: `unknown collector host request: ${type}` }); return;
84
+ }
85
+ } catch (error) { answer({ ok: false, error: error instanceof Error ? error.message : String(error) }); }
86
+ });
87
+ installProcessCleanup(() => { host.abandon(); cleanup(); }, client);
88
+ }
@@ -12,6 +12,7 @@ export type CheckName = (typeof CHECK_NAMES)[number];
12
12
  export interface CheckFinding {
13
13
  readonly kind: CheckName;
14
14
  readonly elementId?: string;
15
+ readonly backendNodeId?: number;
15
16
  readonly selector?: string;
16
17
  readonly rect?: Rect;
17
18
  readonly detail: string;
@@ -214,19 +215,166 @@ function readRequired<T>(ref: SnapRef, filename: string): T {
214
215
  }
215
216
 
216
217
  function finding(kind: CheckName, element: GeometryElement | undefined, detail: string, provenance?: string): Omit<CheckFinding, 'caveats'> {
217
- return { kind, elementId: element?.id, selector: element?.selector, rect: element ? rectOf(element.rect) : undefined, detail, provenance };
218
+ return { kind, elementId: element?.id, backendNodeId: element?.backendNodeId ?? undefined, selector: element?.selector, rect: element ? rectOf(element.rect) : undefined, detail, provenance };
218
219
  }
219
220
 
220
- function rgb(value: string | null | undefined): [number, number, number] | undefined {
221
- const m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i.exec(value ?? '');
222
- return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined;
221
+ function isSelfOrDescendant(target: GeometryElement, receiver: GeometryElement): boolean {
222
+ if (target.backendNodeId != null && target.backendNodeId === receiver.backendNodeId) return true;
223
+ return target.domPath !== undefined && receiver.domPath !== undefined && receiver.domPath.startsWith(`${target.domPath}/`);
223
224
  }
225
+
226
+ interface Rgba { readonly red: number; readonly green: number; readonly blue: number; readonly alpha: number }
227
+ interface StyleElement { readonly selector?: string; readonly backendNodeId?: number | null; readonly computed?: Record<string, string | null> }
228
+
229
+ function bounded(value: number): number { return Math.min(1, Math.max(0, value)); }
230
+
231
+ function rgbChannel(token: string): number {
232
+ const value = token.trim();
233
+ return value.endsWith('%') ? Number(value.slice(0, -1)) * 2.55 : Number(value);
234
+ }
235
+
236
+ function fromOklab(lightness: number, a: number, b: number, alpha: number): Rgba | undefined {
237
+ if (![lightness, a, b, alpha].every(Number.isFinite) || alpha < 0 || alpha > 1) return undefined;
238
+ const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3;
239
+ const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3;
240
+ const s = (lightness - 0.0894841775 * a - 1.2914855480 * b) ** 3;
241
+ const linear = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s];
242
+ const channel = (value: number): number => 255 * (value <= 0.0031308 ? 12.92 * bounded(value) : 1.055 * bounded(value) ** (1 / 2.4) - 0.055);
243
+ return { red: channel(linear[0]!), green: channel(linear[1]!), blue: channel(linear[2]!), alpha };
244
+ }
245
+
246
+ function rgba(value: string | null | undefined): Rgba | undefined {
247
+ const input = value?.trim().toLowerCase();
248
+ if (!input) return undefined;
249
+ if (input === 'transparent') return { red: 0, green: 0, blue: 0, alpha: 0 };
250
+ const hex = /^#([\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i.exec(input);
251
+ if (hex) {
252
+ const digits = hex[1]!.length <= 4 ? hex[1]!.split('').map((digit) => digit + digit).join('') : hex[1]!;
253
+ return { red: parseInt(digits.slice(0, 2), 16), green: parseInt(digits.slice(2, 4), 16), blue: parseInt(digits.slice(4, 6), 16), alpha: digits.length === 8 ? parseInt(digits.slice(6, 8), 16) / 255 : 1 };
254
+ }
255
+ const rgb = /^rgba?\(\s*([^,\s]+)(?:\s*,\s*|\s+)([^,\s]+)(?:\s*,\s*|\s+)([^,\s]+)(?:\s*(?:,|\/)\s*([^\s)]+))?\s*\)$/i.exec(input);
256
+ if (rgb) {
257
+ const channels = [rgbChannel(rgb[1]!), rgbChannel(rgb[2]!), rgbChannel(rgb[3]!)];
258
+ const alpha = rgb[4] === undefined ? 1 : parseAlphaToken(rgb[4]);
259
+ return channels.some((channel) => !Number.isFinite(channel) || channel < 0 || channel > 255) || !Number.isFinite(alpha) || alpha < 0 || alpha > 1 ? undefined : { red: channels[0]!, green: channels[1]!, blue: channels[2]!, alpha };
260
+ }
261
+ const oklab = /^oklab\(\s*([\d.]+)(?:\s*,\s*|\s+)([-\d.]+)(?:\s*,\s*|\s+)([-\d.]+)(?:\s*(?:,|\/)\s*([^\s)]+))?\s*\)$/i.exec(input);
262
+ if (oklab) return fromOklab(Number(oklab[1]), Number(oklab[2]), Number(oklab[3]), oklab[4] === undefined ? 1 : parseAlphaToken(oklab[4]));
263
+ const oklch = /^oklch\(\s*([\d.]+)(?:\s*,\s*|\s+)([\d.]+)(?:\s*,\s*|\s+)([-\d.]+)(?:\s*(?:,|\/)\s*([^\s)]+))?\s*\)$/i.exec(input);
264
+ if (oklch) {
265
+ const chroma = Number(oklch[2]);
266
+ const hue = Number(oklch[3]) * Math.PI / 180;
267
+ return fromOklab(Number(oklch[1]), chroma * Math.cos(hue), chroma * Math.sin(hue), oklch[4] === undefined ? 1 : parseAlphaToken(oklch[4]));
268
+ }
269
+ return undefined;
270
+ }
271
+
272
+ function composite(over: Rgba, under: Rgba): Rgba {
273
+ const alpha = over.alpha + under.alpha * (1 - over.alpha);
274
+ if (alpha === 0) return { red: 0, green: 0, blue: 0, alpha: 0 };
275
+ return {
276
+ red: (over.red * over.alpha + under.red * under.alpha * (1 - over.alpha)) / alpha,
277
+ green: (over.green * over.alpha + under.green * under.alpha * (1 - over.alpha)) / alpha,
278
+ blue: (over.blue * over.alpha + under.blue * under.alpha * (1 - over.alpha)) / alpha,
279
+ alpha,
280
+ };
281
+ }
282
+
283
+ function applyOpacity(paint: Rgba, opacity: number): Rgba {
284
+ return { ...paint, alpha: paint.alpha * opacity };
285
+ }
286
+
287
+ function styleFor(element: GeometryElement, stylesByNode: Map<number, StyleElement>, stylesBySelector: Map<string, StyleElement>): StyleElement | undefined {
288
+ return (element.backendNodeId === null || element.backendNodeId === undefined ? undefined : stylesByNode.get(element.backendNodeId)) ?? (element.selector === undefined ? undefined : stylesBySelector.get(element.selector)) ?? (element.tag === undefined ? undefined : stylesBySelector.get(element.tag));
289
+ }
290
+
291
+ function remainingOpacityIsOne(element: GeometryElement, byPath: Map<string, GeometryElement>): boolean {
292
+ let ancestor = element.domPath === undefined ? undefined : byPath.get(parentPath(element.domPath) ?? '');
293
+ while (ancestor) {
294
+ if ((ancestor.visibility?.opacity ?? 1) !== 1) return false;
295
+ ancestor = ancestor.domPath === undefined ? undefined : byPath.get(parentPath(ancestor.domPath) ?? '');
296
+ }
297
+ return true;
298
+ }
299
+
300
+ function effectiveBackground(target: GeometryElement, byPath: Map<string, GeometryElement>, stylesByNode: Map<number, StyleElement>, stylesBySelector: Map<string, StyleElement>): { readonly color: [number, number, number]; readonly source: GeometryElement } | undefined {
301
+ let element: GeometryElement | undefined = target;
302
+ let background: Rgba = { red: 0, green: 0, blue: 0, alpha: 0 };
303
+ let source: GeometryElement | undefined;
304
+ while (element) {
305
+ const style = styleFor(element, stylesByNode, stylesBySelector);
306
+ const paint = rgba(style?.computed?.backgroundColor ?? style?.computed?.['background-color']);
307
+ if (!paint) return undefined;
308
+ const wasOpaque = background.alpha === 1;
309
+ background = applyOpacity(composite(background, paint), element.visibility?.opacity ?? 1);
310
+ if (!wasOpaque && background.alpha === 1) source = element;
311
+ else if (background.alpha < 1) source = undefined;
312
+ if (background.alpha === 1 && source && remainingOpacityIsOne(element, byPath)) return { color: [Math.round(background.red), Math.round(background.green), Math.round(background.blue)], source };
313
+ element = element.domPath === undefined ? undefined : byPath.get(parentPath(element.domPath) ?? '');
314
+ }
315
+ return undefined;
316
+ }
317
+
318
+ function effectiveForeground(foreground: Rgba, target: GeometryElement, byPath: Map<string, GeometryElement>, stylesByNode: Map<number, StyleElement>, stylesBySelector: Map<string, StyleElement>): [number, number, number] | undefined {
319
+ let element: GeometryElement | undefined = target;
320
+ let background: Rgba = { red: 0, green: 0, blue: 0, alpha: 0 };
321
+ let rendered = foreground;
322
+ while (element) {
323
+ const style = styleFor(element, stylesByNode, stylesBySelector);
324
+ const paint = rgba(style?.computed?.backgroundColor ?? style?.computed?.['background-color']);
325
+ if (!paint) return undefined;
326
+ const opacity = element.visibility?.opacity ?? 1;
327
+ background = applyOpacity(composite(background, paint), opacity);
328
+ rendered = applyOpacity(composite(rendered, paint), opacity);
329
+ if (background.alpha === 1 && remainingOpacityIsOne(element, byPath)) return [Math.round(rendered.red), Math.round(rendered.green), Math.round(rendered.blue)];
330
+ element = element.domPath === undefined ? undefined : byPath.get(parentPath(element.domPath) ?? '');
331
+ }
332
+ return undefined;
333
+ }
334
+
224
335
  function contrastRatio(foreground: [number, number, number], background: [number, number, number]): number {
225
336
  const lum = (channels: [number, number, number]) => channels.reduce((sum, channel, index) => { const v = channel / 255; return sum + (v <= .03928 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4) * [0.2126, 0.7152, 0.0722][index]; }, 0);
226
337
  const a = lum(foreground), b = lum(background);
227
338
  return (Math.max(a, b) + .05) / (Math.min(a, b) + .05);
228
339
  }
229
340
 
341
+ export type ElementContrastMeasurement =
342
+ | { readonly available: true; readonly foreground: readonly [number, number, number]; readonly background: readonly [number, number, number]; readonly ratio: number; readonly backgroundSource: string; readonly backgroundSourceElementId: string; readonly provenance: string }
343
+ | { readonly available: false; readonly uncertainty: string };
344
+
345
+ /** Computes one selected element's effective rendered color pair from the snapshot's recorded styles and ancestor paint. */
346
+ export function measureElementContrast(ref: SnapRef, elementId: string): ElementContrastMeasurement {
347
+ const elements = readGeometry<{ elements?: GeometryElement[] }>(ref).elements ?? [];
348
+ const element = elements.find((candidate) => candidate.id === elementId);
349
+ if (!element) return { available: false, uncertainty: 'the selected geometry record is not present in geometry.json' };
350
+ const styles = readRequired<{ elements?: StyleElement[] }>(ref, 'styles.json').elements ?? [];
351
+ const stylesByNode = new Map<number, StyleElement>();
352
+ const stylesBySelector = new Map<string, StyleElement>();
353
+ const byPath = new Map<string, GeometryElement>();
354
+ for (const candidate of elements) if (candidate.domPath) byPath.set(candidate.domPath, candidate);
355
+ for (const style of styles) {
356
+ if (style.backendNodeId != null) stylesByNode.set(style.backendNodeId, style);
357
+ if (style.selector) stylesBySelector.set(style.selector, style);
358
+ }
359
+ const style = styleFor(element, stylesByNode, stylesBySelector);
360
+ const foreground = rgba(style?.computed?.color);
361
+ if (!foreground) return { available: false, uncertainty: 'the selected element has no parseable recorded computed color' };
362
+ const background = effectiveBackground(element, byPath, stylesByNode, stylesBySelector);
363
+ if (!background) return { available: false, uncertainty: 'an opaque effective background could not be resolved from recorded ancestor paint and opacity' };
364
+ const renderedForeground = effectiveForeground(foreground, element, byPath, stylesByNode, stylesBySelector);
365
+ if (!renderedForeground) return { available: false, uncertainty: 'the effective foreground could not be resolved through recorded ancestor paint and opacity' };
366
+ const backgroundSource = label(background.source);
367
+ return {
368
+ available: true,
369
+ foreground: renderedForeground,
370
+ background: background.color,
371
+ ratio: contrastRatio(renderedForeground, background.color),
372
+ backgroundSource,
373
+ backgroundSourceElementId: background.source.id,
374
+ provenance: `computed color; effective background supplied by ${backgroundSource}'s opaque paint after compositing recorded descendant backgrounds`,
375
+ };
376
+ }
377
+
230
378
  /** Read-only analysis over a completed snapshot. Browser driving belongs solely to measure snap. */
231
379
  export function checkSnapshot(ref: SnapRef, requested: readonly CheckName[]): { findings: CheckFinding[]; elementCount: number; settled: boolean; viewport: { width: number; height: number } } {
232
380
  const geometry = readGeometry<{ elements: GeometryElement[] }>(ref);
@@ -270,24 +418,41 @@ export function checkSnapshot(ref: SnapRef, requested: readonly CheckName[]): {
270
418
  if (selected.has('tap-targets')) for (const e of visible) if (/^(button|a|input|select|textarea)$/i.test((e as { tag?: string }).tag ?? '') && (e.rect.width < 44 || e.rect.height < 44)) findings.push(finding('tap-targets', e, `${label(e)} measures ${e.rect.width}×${e.rect.height}px; threshold is 44×44px`));
271
419
 
272
420
  if (selected.has('contrast')) {
273
- const styles = readRequired<{ elements?: Array<{ selector?: string; backendNodeId?: number | null; computed?: Record<string, string | null> }> }>(ref, 'styles.json');
274
- for (const style of styles.elements ?? []) {
275
- const foreground = rgb(style.computed?.color);
276
- const background = rgb(style.computed?.backgroundColor ?? style.computed?.['background-color']);
277
- if (!foreground || !background) continue;
278
- const ratio = contrastRatio(foreground, background);
421
+ const styles = readRequired<{ elements?: StyleElement[] }>(ref, 'styles.json').elements ?? [];
422
+ const stylesByNode = new Map<number, StyleElement>();
423
+ const stylesBySelector = new Map<string, StyleElement>();
424
+ const byPath = new Map<string, GeometryElement>();
425
+ for (const element of elements) if (element.domPath) byPath.set(element.domPath, element);
426
+ for (const style of styles) {
427
+ if (style.backendNodeId != null) stylesByNode.set(style.backendNodeId, style);
428
+ if (style.selector) stylesBySelector.set(style.selector, style);
429
+ }
430
+ for (const style of styles) {
431
+ const foreground = rgba(style.computed?.color);
432
+ const element = elements.find((candidate) => (style.backendNodeId != null && candidate.backendNodeId === style.backendNodeId) || candidate.selector === style.selector);
433
+ if (!foreground || !element) continue;
434
+ const background = effectiveBackground(element, byPath, stylesByNode, stylesBySelector);
435
+ const renderedForeground = effectiveForeground(foreground, element, byPath, stylesByNode, stylesBySelector);
436
+ if (!background || !renderedForeground) continue;
437
+ const ratio = contrastRatio(renderedForeground, background.color);
279
438
  if (ratio < 4.5) {
280
- const e = elements.find((x) => (style.backendNodeId != null && x.backendNodeId === style.backendNodeId) || x.selector === style.selector);
281
- findings.push(finding('contrast', e, `${style.selector ?? label(e ?? {})} foreground ${style.computed?.color} against background ${style.computed?.backgroundColor ?? style.computed?.['background-color']} has contrast ratio ${ratio.toFixed(2)}:1`, 'computed color and background-color'));
439
+ const source = label(background.source);
440
+ findings.push(finding('contrast', element, `contrast ratio ${ratio.toFixed(2)}:1 — ${style.selector ?? label(element)} foreground ${style.computed?.color} against composited background rgb(${background.color.join(', ')})`, `computed color; effective background supplied by ${source}'s opaque paint after compositing recorded descendant backgrounds`));
282
441
  }
283
442
  }
284
443
  }
285
444
  if (selected.has('hit-test')) {
286
- const hit = readHittest<{ elements?: Array<{ selector?: string; backendNodeId?: number | null; selfHitCount?: number; selfHitTotal?: number; points?: Array<{ result?: { topReceiver?: { selector?: string } | null; x?: number; y?: number; stack?: Array<{ selector?: string; pointerEvents?: string; opacity?: number }> } }> }> }>(ref);
287
- for (const sample of hit.elements ?? []) if ((sample.selfHitCount ?? 0) < (sample.selfHitTotal ?? 0)) {
445
+ const hit = readHittest<{ elements?: Array<{ selector?: string; backendNodeId?: number | null; points?: Array<{ result?: { topReceiver?: { selector?: string; backendNodeId?: number | null } | null; x?: number; y?: number; stack?: Array<{ selector?: string; pointerEvents?: string; opacity?: number }> } }> }> }>(ref);
446
+ for (const sample of hit.elements ?? []) {
288
447
  const e = elements.find((x) => (sample.backendNodeId != null && x.backendNodeId === sample.backendNodeId) || (sample.selector && x.selector === sample.selector));
289
- const point = sample.points?.find((p) => p.result?.topReceiver?.selector !== sample.selector)?.result;
290
- findings.push(finding('hit-test', e, `${label(e ?? sample)} resolves ${sample.selfHitCount ?? 0} of ${sample.selfHitTotal ?? 0} sampled points to itself${point ? `; sampled point (${point.x},${point.y}) receiver ${point.topReceiver?.selector ?? 'none'}` : ''}`, point?.stack?.map((x) => `${x.selector ?? x.pointerEvents ?? 'element'}${x.opacity === 0 ? ' opacity 0' : ''}`).join(', ')));
448
+ if (!e) continue;
449
+ const point = sample.points?.find((p) => {
450
+ const receiver = p.result?.topReceiver;
451
+ const receiverElement = receiver && elements.find((x) => receiver.backendNodeId != null && x.backendNodeId === receiver.backendNodeId);
452
+ return receiverElement !== undefined && !isSelfOrDescendant(e, receiverElement);
453
+ })?.result;
454
+ if (!point?.topReceiver) continue;
455
+ findings.push(finding('hit-test', e, `${label(e)} sampled point (${point.x},${point.y}) resolves to non-descendant receiver ${point.topReceiver.selector ?? 'unidentified element'}`, point.stack?.map((x) => `${x.selector ?? x.pointerEvents ?? 'element'}${x.opacity === 0 ? ' opacity 0' : ''}`).join(', ')));
291
456
  }
292
457
  }
293
458
  if (selected.has('truncation')) for (const t of readText<{ elements?: Array<{ selector?: string; backendNodeId?: number | null; truncated?: boolean; scrollWidth?: number; clientWidth?: number }> }>(ref).elements ?? []) if (t.truncated) {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `hittest.json` collector — point-sampled hit-test results (what element
3
- * actually receives a click at a given coordinate): a 9-point lattice
4
- * (center + 4 corners + 4 edge-midpoints) per walked element, plus a
3
+ * actually receives a click at a given coordinate): a 9-point interior lattice
4
+ * (center + 4 inset corners + 4 inset edge-midpoints) per walked element, plus a
5
5
  * coarse whole-viewport grid independent of any element.
6
6
  *
7
7
  * The ENTIRE walk + sampling happens inside ONE `Runtime.evaluate` script
@@ -330,16 +330,22 @@ function buildHittestScript(maxElements: number, latticeStep: number, latticeMax
330
330
  function pointLabelsFor(rect) {
331
331
  var cx = rect.x + rect.width / 2;
332
332
  var cy = rect.y + rect.height / 2;
333
+ // Exact border coordinates belong to the adjacent element or outside
334
+ // the viewport under CSS hit testing. Sample the box interior instead.
335
+ var insetX = Math.min(1, rect.width / 4);
336
+ var insetY = Math.min(1, rect.height / 4);
337
+ var left = rect.x + insetX, right = rect.x + rect.width - insetX;
338
+ var top = rect.y + insetY, bottom = rect.y + rect.height - insetY;
333
339
  return [
334
340
  { label: 'center', x: cx, y: cy },
335
- { label: 'top-left', x: rect.x, y: rect.y },
336
- { label: 'top-right', x: rect.x + rect.width, y: rect.y },
337
- { label: 'bottom-left', x: rect.x, y: rect.y + rect.height },
338
- { label: 'bottom-right', x: rect.x + rect.width, y: rect.y + rect.height },
339
- { label: 'top-mid', x: cx, y: rect.y },
340
- { label: 'bottom-mid', x: cx, y: rect.y + rect.height },
341
- { label: 'left-mid', x: rect.x, y: cy },
342
- { label: 'right-mid', x: rect.x + rect.width, y: cy },
341
+ { label: 'top-left', x: left, y: top },
342
+ { label: 'top-right', x: right, y: top },
343
+ { label: 'bottom-left', x: left, y: bottom },
344
+ { label: 'bottom-right', x: right, y: bottom },
345
+ { label: 'top-mid', x: cx, y: top },
346
+ { label: 'bottom-mid', x: cx, y: bottom },
347
+ { label: 'left-mid', x: left, y: cy },
348
+ { label: 'right-mid', x: right, y: cy },
343
349
  ];
344
350
  }
345
351
 
@@ -121,7 +121,12 @@ const SCROLL_TOPOLOGY_SCRIPT = `/* __captureScrollTopology */
121
121
  }
122
122
  return { items: out, truncated: i < kids.length };
123
123
  }
124
- function visibleChildren(el) {
124
+ function visibleChildrenAt(el, requestedTop) {
125
+ // A projected rect is wrong for sticky/fixed and scroll-reactive content.
126
+ // Measure the actual layout at each offset; the outer finally restores
127
+ // every original offset before this one evaluation returns.
128
+ el.scrollTop = requestedTop;
129
+ var actualTop = el.scrollTop;
125
130
  var band = el.getBoundingClientRect();
126
131
  var out = [];
127
132
  var kids = el.children;
@@ -129,9 +134,9 @@ const SCROLL_TOPOLOGY_SCRIPT = `/* __captureScrollTopology */
129
134
  for (i = 0; i < kids.length && out.length < 30; i++) {
130
135
  var r = kids[i].getBoundingClientRect();
131
136
  var intersects = r.bottom > band.top && r.top < band.bottom && r.right > band.left && r.left < band.right;
132
- if (intersects) out.push({ scrollId: tagId(kids[i]), selector: selectorOf(kids[i]), rect: rectOf(kids[i]) });
137
+ if (intersects) out.push({ scrollId: tagId(kids[i]), selector: selectorOf(kids[i]), rect: { x: r.x, y: r.y, width: r.width, height: r.height } });
133
138
  }
134
- return { items: out, truncated: i < kids.length };
139
+ return { offsetTop: actualTop, items: out, truncated: i < kids.length };
135
140
  }
136
141
 
137
142
  var root = document.scrollingElement || document.documentElement;
@@ -186,10 +191,13 @@ const SCROLL_TOPOLOGY_SCRIPT = `/* __captureScrollTopology */
186
191
  [originalTop, 0, maxTop].forEach(function(v) { if (!(v in seen)) { seen[v] = true; offsetsToSample.push(v); } });
187
192
  for (var s = 0; s < offsetsToSample.length; s++) {
188
193
  var top = offsetsToSample[s];
189
- el.scrollTop = top;
190
- var vc = isRoot ? { items: [], truncated: false } : visibleChildren(el);
191
- samples.push({ offsetTop: top, visibleChildren: vc.items, visibleChildrenTruncated: vc.truncated });
194
+ var vc = isRoot ? { offsetTop: top, items: [], truncated: false } : visibleChildrenAt(el, top);
195
+ samples.push({ offsetTop: vc.offsetTop, visibleChildren: vc.items, visibleChildrenTruncated: vc.truncated });
192
196
  }
197
+ // Sampling above leaves el scrolled to its last sampled offset;
198
+ // restore before measuring sticky/fixed/snap descendants so their
199
+ // rects reflect the container's real (unscrolled) position, not
200
+ // whichever sample offset was visited last.
193
201
  el.scrollTop = originalTop;
194
202
  el.scrollLeft = originalLeft;
195
203
 
@@ -461,7 +469,7 @@ function toContainerOut(c: ScrollContainerRaw, backendById: Map<string, number>)
461
469
  async function resolveMarkerBackendIds(client: CDPClient, markerAttr: string): Promise<Map<string, number>> {
462
470
  const map = new Map<string, number>();
463
471
  try {
464
- const doc = (await client.send('DOM.getDocument', { depth: -1, pierce: false })) as { root?: { nodeId?: number } };
472
+ const doc = (await client.send('DOM.getDocument', { depth: 0, pierce: false })) as { root?: { nodeId?: number } };
465
473
  const rootId = doc.root?.nodeId;
466
474
  if (rootId === undefined) return map;
467
475
  const res = (await client.send('DOM.querySelectorAll', { nodeId: rootId, selector: `[${markerAttr}]` })) as { nodeIds?: number[] };
@@ -38,6 +38,7 @@
38
38
  import { resolveAuthoredSourceLocation, type ResolvedSourceLocation } from '../../source-map.js';
39
39
  import { sanitizeString } from '../redaction.js';
40
40
  import type { CDPClient } from '../../client.js';
41
+ import { isMotionHeldClient } from '../../host/held-client.js';
41
42
 
42
43
  // ============================================================================
43
44
  // Output shape
@@ -597,6 +598,16 @@ export async function captureStyleSheetHeaders(
597
598
  client: CDPClient,
598
599
  ): Promise<{ urls: Map<string, string>; stop: () => void; available: boolean; reason?: string }> {
599
600
  const urls = new Map<string, string>();
601
+ // A recorder-routed command cannot subscribe through its one-request socket;
602
+ // ask the recorder's owning CDP connection to collect the same redelivery.
603
+ if (isMotionHeldClient(client)) {
604
+ try {
605
+ for (const header of await client.collectStyleSheetHeaders()) urls.set(header.styleSheetId, header.sourceURL);
606
+ return { urls, stop: () => {}, available: true };
607
+ } catch {
608
+ return { urls, stop: () => {}, available: false, reason: 'stylesheet-header-redelivery-failed' };
609
+ }
610
+ }
600
611
  if (typeof client.on !== 'function') {
601
612
  return { urls, stop: () => {}, available: false, reason: 'client-lacks-event-support' };
602
613
  }
@@ -123,10 +123,9 @@ export type StylesUnavailableReason = 'styles-evaluate-returned-no-value' | 'sty
123
123
  // In-page script — cssPath + computed style facts, no DOM domain calls
124
124
  // ============================================================================
125
125
 
126
- // `html`/`body` are excluded: `cssPathFromBody` is rooted AT `document.body`, so it produces `''` for
127
- // the body element itself and `body > html` for the (out-of-subtree) html element — neither is a valid
128
- // `body > ...` selector for `resolveNodeIds` to query. Both are structural roots, not measurable subjects.
129
- const EXCLUDED_TAGS = ['html', 'body', 'script', 'style', 'head', 'meta', 'link', 'title', 'template', 'noscript', 'br', 'wbr'];
126
+ // `html` is excluded because `cssPathFromBody` is rooted at `document.body`, so it would produce
127
+ // an out-of-subtree selector. `body` remains: it supplies the canvas background behind transparent descendants.
128
+ const EXCLUDED_TAGS = ['html', 'script', 'style', 'head', 'meta', 'link', 'title', 'template', 'noscript', 'br', 'wbr'];
130
129
 
131
130
  interface StylesFact {
132
131
  cssPath: string;
@@ -149,6 +148,7 @@ const STYLES_SCRIPT = `/* __captureStylesInventory */
149
148
  for (var e = 0; e < EXCLUDED.length; e++) excludedSet[EXCLUDED[e]] = true;
150
149
 
151
150
  function cssPathFromBody(el) {
151
+ if (el === document.body) return 'body';
152
152
  var parts = [];
153
153
  var node = el;
154
154
  var depth = 0;
@@ -116,7 +116,16 @@ export interface ExplainMissingSelector {
116
116
  readonly available: MissingSelectorRecovery;
117
117
  }
118
118
 
119
- export type ExplainSnapshotResult = ExplainSuccess | ExplainMissingSelector;
119
+ export interface ExplainAmbiguousSelector {
120
+ readonly kind: 'ambiguous-selector';
121
+ readonly ref: SnapRef;
122
+ readonly selector: string;
123
+ readonly meta: { readonly settled?: boolean; readonly settleMs?: number };
124
+ readonly matchCount: number;
125
+ readonly candidates: readonly ElementRecord[];
126
+ }
127
+
128
+ export type ExplainSnapshotResult = ExplainSuccess | ExplainMissingSelector | ExplainAmbiguousSelector;
120
129
 
121
130
  type RawSection = { kind: ExplainSection['kind']; facts: ExplainFact[] };
122
131
 
@@ -333,7 +342,7 @@ function stackingClippingSection(target: GeometryElement, geometry: readonly Geo
333
342
  : `paint extents could not be resolved: ${ink.unresolved.join(', ')}`;
334
343
  facts.push(factFor(target, fact`Ink box (${source.provenance}) was not calculated because ${reason}.`));
335
344
  } else {
336
- facts.push(factFor(target, text`Ink box was not calculated because ${source.unavailable}.`));
345
+ facts.push(factFor(target, fact`Ink box was not calculated because ${source.unavailable}.`));
337
346
  }
338
347
 
339
348
  const overflowAncestors = ancestors.filter((element) => {
@@ -567,6 +576,9 @@ export function explainSnapshot(ref: SnapRef, selector: string, detailOpts: Expl
567
576
  if (!matches.length) {
568
577
  return { kind: 'missing-selector', ref, selector, meta, available: recoveryForms(selectable, selector) };
569
578
  }
579
+ if (matches.length > 1) {
580
+ return { kind: 'ambiguous-selector', ref, selector, meta, matchCount: matches.length, candidates: matches.slice(0, RECOVERY_CANDIDATE_LIMIT) };
581
+ }
570
582
 
571
583
  const selectedId = matches[0]!.id;
572
584
  const target = geometry.find((element) => element.id === selectedId)!;