@crouton-kit/capture 1.3.19 → 1.3.21

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.
@@ -0,0 +1,47 @@
1
+ import type { CDPClient } from './client.js';
2
+
3
+ export type ColorScheme = 'dark' | 'light';
4
+
5
+ type MediaClient = Pick<CDPClient, 'send'>;
6
+
7
+ export function parseColorScheme(value: string | undefined): ColorScheme | undefined {
8
+ if (value === undefined) return undefined;
9
+ if (value === 'dark' || value === 'light') return value;
10
+ throw new Error('--color-scheme must be dark or light');
11
+ }
12
+
13
+ /** Applies one media-feature override only for the supplied capture operation. */
14
+ export async function withAppliedColorScheme<T>(client: MediaClient, colorScheme: ColorScheme | undefined, fn: () => Promise<T>): Promise<T> {
15
+ if (!colorScheme) return fn();
16
+ let ownsMediaOverride = false;
17
+ let primaryFailed = false;
18
+ let primaryError: unknown;
19
+ try {
20
+ // A rejected response does not prove Chrome rejected the request. Claim
21
+ // cleanup responsibility before awaiting it, just as viewport capture does.
22
+ ownsMediaOverride = true;
23
+ await client.send('Emulation.setEmulatedMedia', {
24
+ features: [{ name: 'prefers-color-scheme', value: colorScheme }],
25
+ });
26
+ return await fn();
27
+ } catch (error) {
28
+ primaryFailed = true;
29
+ primaryError = error;
30
+ throw error;
31
+ } finally {
32
+ if (ownsMediaOverride) {
33
+ try {
34
+ await client.send('Emulation.setEmulatedMedia', { features: [] });
35
+ } catch (cleanupError) {
36
+ if (primaryFailed) {
37
+ throw new AggregateError(
38
+ [primaryError, cleanupError],
39
+ 'Color-scheme capture failed and media-emulation cleanup also failed.',
40
+ { cause: primaryError },
41
+ );
42
+ }
43
+ throw cleanupError;
44
+ }
45
+ }
46
+ }
47
+ }
@@ -36,7 +36,7 @@ Every leaf defaults to rendered prose; --json mirrors the same result.
36
36
  Findings exit 0 — a report, not a failure.
37
37
  \`--gate\` (exit 2 on findings/changes) is accepted only by check and diff.
38
38
 
39
- <subcommand name="snap" args="[url|snap] [--freeze-animations] [--settle-timeout <ms>] [--capture-unsettled] [--pixels] [--state <state[:selector]>]... [--viewport <WxH>]..." whenToUse="drive + write the settled snapshot substrate every other leaf reads"/>
39
+ <subcommand name="snap" args="[url|snap] [--freeze-animations] [--settle-timeout <ms>] [--capture-unsettled] [--pixels] [--state <state[:selector]>]... [--viewport <WxH>]... [--color-scheme <dark|light>]" whenToUse="drive + write the settled snapshot substrate every other leaf reads"/>
40
40
  <subcommand name="check" args="[url|snap] [--for <checks>] [--gate]" whenToUse="read threshold/fact measurements from one snapshot"/>
41
41
  <subcommand name="diff" args="--before <snap> --after <snap> [--pixels] [--full] [--gate]" whenToUse="structured before/after delta between two snapshots"/>
42
42
  <subcommand name="census" args="[--snap <id>]... [--url <url>]... [--set-file <path>] --axis <axis>" whenToUse="value distributions across one or more snapshots"/>
@@ -6,6 +6,7 @@ import { type ParsedArgs } from '../../types.js';
6
6
  import { withConnection } from '../../connection.js';
7
7
  import { isRecorderHeldClient } from '../../recorder-client.js';
8
8
  import { parseViewport, type Viewport } from '../../viewport.js';
9
+ import { parseColorScheme, withAppliedColorScheme, type ColorScheme } from '../../color-scheme.js';
9
10
  import { captureSnapshotSubstrate, COLLECTOR_NAMES, SnapshotCaptureTimeout } from '../../measure/snapshot.js';
10
11
  import type { CollectorOutcome } from '../../measure/types.js';
11
12
  import { sanitizeString } from '../../measure/redaction.js';
@@ -36,8 +37,9 @@ input:
36
37
  --pixels also write per-element raster crops
37
38
  --state <state[:selector]> force a pseudo-state or real control state (repeatable)
38
39
  --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
+ --color-scheme <dark|light> temporarily capture under this prefers-color-scheme value; cleared afterward
39
41
  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
40
- effects: drives the target page to capture; writes one snapshot artifact directory under the active (or a private one-shot) session`;
42
+ effects: drives the target page to capture; writes one snapshot artifact directory under the active (or a private one-shot) session. --color-scheme transiently applies Emulation.setEmulatedMedia and clears it after capture.`;
41
43
 
42
44
  interface SnapshotMetaForBase {
43
45
  readonly url?: string | null;
@@ -216,7 +218,7 @@ function settledNote(settled: boolean, captured: boolean, settleMs: number, sett
216
218
  * seam rather than importing the substrate directly, preserving snap as the
217
219
  * sole live-driving primitive.
218
220
  */
219
- export async function captureMeasureSnap(parsed: ParsedArgs, targetRef = parsed.positional[0], viewportValue = parsed.viewport): Promise<MeasureSnapCapture> {
221
+ export async function captureMeasureSnap(parsed: ParsedArgs, targetRef = parsed.positional[0], viewportValue = parsed.viewport, colorSchemeValue = parsed.colorScheme): Promise<MeasureSnapCapture> {
220
222
  if (parsed.positional.length > 1) {
221
223
  throw invalidInput('measure snap accepts at most one positional URL or snapshot reference');
222
224
  }
@@ -231,6 +233,7 @@ export async function captureMeasureSnap(parsed: ParsedArgs, targetRef = parsed.
231
233
  const viewport: ViewportSpec | undefined = viewportValue === undefined
232
234
  ? undefined
233
235
  : { label: viewportValue, ...parseViewport(viewportValue) };
236
+ const colorScheme = parseColorScheme(colorSchemeValue);
234
237
 
235
238
  let base: SnapRef | undefined;
236
239
  let baseUrl: string | null = null;
@@ -256,7 +259,7 @@ export async function captureMeasureSnap(parsed: ParsedArgs, targetRef = parsed.
256
259
  try {
257
260
  let artifacts: readonly string[] = [];
258
261
  await withConnection(connectionArgs, async (client, tab) => {
259
- await withAppliedViewport(client, viewport, async () => {
262
+ await withAppliedColorScheme(client, colorScheme, async () => withAppliedViewport(client, viewport, async () => {
260
263
  const oneshot = active ? undefined : createOneshotSession('measure');
261
264
  const destination = active ? path.join(active.dir, 'measure', 'snaps') : oneshot!.artifactsDir;
262
265
  snapDir = path.join(destination, snapId);
@@ -272,10 +275,11 @@ export async function captureMeasureSnap(parsed: ParsedArgs, targetRef = parsed.
272
275
  pixels: parsed.pixels,
273
276
  state: parsed.state ?? [],
274
277
  viewport: viewport?.label ?? null,
278
+ colorScheme,
275
279
  skipCollectors: parsed.skipCollector ?? [],
276
280
  });
277
281
  artifacts = result.artifacts;
278
- });
282
+ }));
279
283
  }, { settle: 0 });
280
284
 
281
285
  if (!snapDir) throw new Error('measure snap did not allocate an artifact directory');
@@ -290,6 +294,7 @@ function buildSnapshotResult(captured: MeasureSnapCapture): RenderableResult {
290
294
  const meta = readMeta<{
291
295
  url: string | null;
292
296
  viewport: string | null;
297
+ colorScheme?: ColorScheme;
293
298
  settled: boolean;
294
299
  settleMs: number;
295
300
  settleTimeoutMs: number;
@@ -313,6 +318,7 @@ function buildSnapshotResult(captured: MeasureSnapCapture): RenderableResult {
313
318
  attrs: {
314
319
  url: meta.url ?? undefined,
315
320
  viewport: meta.viewport ?? undefined,
321
+ 'color-scheme': meta.colorScheme,
316
322
  elements,
317
323
  settled: meta.settled,
318
324
  'settle-ms': meta.settleMs,
@@ -358,7 +364,7 @@ export function classifySnapError(err: unknown): SnapErrorDetails {
358
364
  if (message.startsWith('--viewport cannot ')) {
359
365
  return { status: 'viewport_unavailable', message };
360
366
  }
361
- if (message.startsWith('measure snap accepts') || message.startsWith('--settle-timeout') || message.startsWith('--viewport')) {
367
+ if (message.startsWith('measure snap accepts') || message.startsWith('--settle-timeout') || message.startsWith('--viewport') || message.startsWith('--color-scheme')) {
362
368
  return { status: 'invalid_input', message };
363
369
  }
364
370
  if (message.includes('No tab found') || message.includes('Use --target') || message.includes('Tab has no WebSocket debugger URL')) {
@@ -408,9 +414,10 @@ export async function cmdMeasureSnap(parsed: ParsedArgs, _args: string[]): Promi
408
414
  if (value === undefined) return undefined;
409
415
  return { label: value, ...parseViewport(value) };
410
416
  });
417
+ const colorScheme = parseColorScheme(parsed.colorScheme);
411
418
  const results = [] as RenderableResult[];
412
419
  for (const viewport of viewports) {
413
- const capture = await captureMeasureSnap(parsed, parsed.positional[0], viewport?.label);
420
+ const capture = await captureMeasureSnap(parsed, parsed.positional[0], viewport?.label, colorScheme);
414
421
  captures.push(capture);
415
422
  results.push(buildSnapshotResult(capture));
416
423
  }
@@ -52,6 +52,33 @@ export function pageInputDeps(): PageInputDeps {
52
52
  return deps;
53
53
  }
54
54
 
55
+ export interface PageInputScreenshot {
56
+ readonly screenshot: string | null;
57
+ readonly screenshotWarning: string | null;
58
+ }
59
+
60
+ /** A dispatched page action stays successful when only its follow-up screenshot times out. */
61
+ export async function capturePageInputScreenshot(
62
+ client: Parameters<typeof autoScreenshot>[0],
63
+ action: string,
64
+ label: string,
65
+ noScreenshot?: boolean,
66
+ ): Promise<PageInputScreenshot> {
67
+ try {
68
+ return {
69
+ screenshot: await deps.autoScreenshot(client, action, label, noScreenshot),
70
+ screenshotWarning: null,
71
+ };
72
+ } catch (error) {
73
+ const message = error instanceof Error ? error.message : String(error);
74
+ if (!/^CDP request timeout \(\d+ms\): Page\.captureScreenshot$/.test(message)) throw error;
75
+ return {
76
+ screenshot: null,
77
+ screenshotWarning: `automatic screenshot timed out after the page action completed; no screenshot was saved (${message})`,
78
+ };
79
+ }
80
+ }
81
+
55
82
  /**
56
83
  * The verb's settle window: `--settle <ms>` (including 0) wins outright;
57
84
  * otherwise the default is keyed off active-session presence (the in-session
@@ -147,7 +174,7 @@ input:
147
174
  --settle <ms> network-settle window applied after the click (default: 1000; 2500 with an active session; 0 disables)
148
175
  --no-screenshot skip the auto-screenshot
149
176
  output:
150
- <clicked backend-node-id=… role=… name=…> — resolved identity, dispatched coordinates, the measured settle (requested/waited), screenshot artifact path; --json mirrors the same fields
177
+ <clicked backend-node-id=… role=… name=…> — resolved identity, dispatched coordinates, the measured settle (requested/waited), and either the screenshot artifact path or a warning when only that follow-up capture timed out; --json mirrors the same fields
151
178
  effects:
152
179
  scrolls the target into view, then dispatches a real mouse press/release at its center; writes one screenshot into the active session's shots/ sequence unless --no-screenshot`;
153
180
 
@@ -177,8 +204,8 @@ export async function cmdPageClick(parsed: ParsedArgs, _args: string[]): Promise
177
204
  const resolved = await resolveLiveTarget(live, target);
178
205
  if (!resolved.ok) return { failure: resolved } as const;
179
206
  const dispatch = await clickResolved(live, resolved);
180
- const screenshot = await deps.autoScreenshot(client, 'click', target, parsed.noScreenshot);
181
- return { dispatch, screenshot } as const;
207
+ const screenshotResult = await capturePageInputScreenshot(client, 'click', target, parsed.noScreenshot);
208
+ return { dispatch, ...screenshotResult } as const;
182
209
  },
183
210
  );
184
211
 
@@ -186,12 +213,13 @@ export async function cmdPageClick(parsed: ParsedArgs, _args: string[]): Promise
186
213
  return emitResolutionError(parsed, 'page click', outcome.failure);
187
214
  }
188
215
 
189
- const { dispatch, screenshot } = outcome;
216
+ const { dispatch, screenshot, screenshotWarning } = outcome;
190
217
  const rows: FactLine[] = [
191
218
  fact`clicked ${dispatch.role ?? 'unknown'} "${dispatch.name ?? ''}" (backend:${dispatch.backendNodeId}) at x=${dispatch.x} y=${dispatch.y}`,
192
219
  fact`settle: requested ${settleFacts.requestedMs}ms, waited ${settleFacts.waitedMs}ms`,
193
220
  ];
194
221
  if (screenshot) rows.push(fact`screenshot: ${screenshot}`);
222
+ if (screenshotWarning) rows.push(fact`screenshot-warning: ${screenshotWarning}`);
195
223
 
196
224
  emitResult(
197
225
  {
@@ -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] [--out <path>]" whenToUse="look at the page right now without acting"/>
42
+ <subcommand name="shot" args="[--viewport <WxH>] [--full-page] [--color-scheme <dark|light>] [--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`;
@@ -13,6 +13,7 @@ import { resolveLiveTarget, scrollResolved, type LiveClient } from '../../../int
13
13
  import { emitResult, fact, lineList, type FactLine } from '../../../output/render.js';
14
14
  import {
15
15
  pageInputDeps,
16
+ capturePageInputScreenshot,
16
17
  effectiveSettle,
17
18
  emitInvalidInput,
18
19
  emitResolutionError,
@@ -26,7 +27,7 @@ input:
26
27
  --settle <ms> network-settle window applied after the scroll (default: 1000; 2500 with an active session; 0 disables)
27
28
  --no-screenshot skip the auto-screenshot
28
29
  output:
29
- <scrolled backend-node-id=… role=… name=…> — resolved identity, destination, the container's resulting scrollTop, the measured settle (requested/waited), screenshot artifact path; --json mirrors the same fields
30
+ <scrolled backend-node-id=… role=… name=…> — resolved identity, destination, the container's resulting scrollTop, the measured settle (requested/waited), and either the screenshot artifact path or a warning when only that follow-up capture timed out; --json mirrors the same fields
30
31
  effects:
31
32
  assigns the container's scrollTop in-page (may trigger lazy-load network); writes one screenshot into the active session's shots/ sequence unless --no-screenshot`;
32
33
 
@@ -74,8 +75,8 @@ export async function cmdPageScroll(parsed: ParsedArgs, _args: string[]): Promis
74
75
  // Same landmark shape `motion rec --do scroll:` records; carried by
75
76
  // the one mutating call when the transport records landmarks.
76
77
  const dispatch = await scrollResolved(live, resolved, to, { mark: `scroll:${target},to=${to}` });
77
- const screenshot = await deps.autoScreenshot(client, 'scroll', target, parsed.noScreenshot);
78
- return { dispatch, screenshot } as const;
78
+ const screenshotResult = await capturePageInputScreenshot(client, 'scroll', target, parsed.noScreenshot);
79
+ return { dispatch, ...screenshotResult } as const;
79
80
  },
80
81
  );
81
82
 
@@ -83,12 +84,13 @@ export async function cmdPageScroll(parsed: ParsedArgs, _args: string[]): Promis
83
84
  return emitResolutionError(parsed, 'page scroll', outcome.failure);
84
85
  }
85
86
 
86
- const { dispatch, screenshot } = outcome;
87
+ const { dispatch, screenshot, screenshotWarning } = outcome;
87
88
  const rows: FactLine[] = [
88
89
  fact`scrolled ${dispatch.role ?? 'unknown'} "${dispatch.name ?? ''}" (backend:${dispatch.backendNodeId}) to ${dispatch.to} — scrollTop now ${dispatch.scrollTop}`,
89
90
  fact`settle: requested ${settleFacts.requestedMs}ms, waited ${settleFacts.waitedMs}ms`,
90
91
  ];
91
92
  if (screenshot) rows.push(fact`screenshot: ${screenshot}`);
93
+ if (screenshotWarning) rows.push(fact`screenshot-warning: ${screenshotWarning}`);
92
94
 
93
95
  emitResult(
94
96
  {
@@ -4,8 +4,9 @@
4
4
  * The navigational look: vision as navigator, never inspector — cheap, no
5
5
  * settling, no collectors. With no flags it captures the browser's ACTUAL
6
6
  * current viewport and performs zero `Emulation.*` calls; emulation exists
7
- * only when explicitly asked (`--viewport <WxH>` / `--full-page`), applied
8
- * as a transient `Emulation.setDeviceMetricsOverride` (+ ~150ms re-layout)
7
+ * only when explicitly asked (`--viewport <WxH>` / `--full-page` /
8
+ * `--color-scheme dark|light`), applied transiently; viewport emulation uses
9
+ * `Emulation.setDeviceMetricsOverride` (+ ~150ms re-layout)
9
10
  * and cleared after — two page-observable resizes, declared as the leaf's
10
11
  * effect in `-h` (I-1/I-2 posture). Explicit viewport emulation uses the
11
12
  * single `WxH` grammar (D10).
@@ -20,6 +21,7 @@ import { type ParsedArgs } from '../../types.js';
20
21
  import { withConnection } from '../../connection.js';
21
22
  import { captureScreenshotWithCssViewport, type CapturedRegion } from '../../screenshot.js';
22
23
  import { parseViewport, type Viewport } from '../../viewport.js';
24
+ import { parseColorScheme, withAppliedColorScheme, type ColorScheme } from '../../color-scheme.js';
23
25
  import { nextStepPath } from '../../../session-context.js';
24
26
  import { createOneshotSession } from '../../../session/commands.js';
25
27
  import { assertUnderCaptureRoot, writeBinaryPrivate } from '../../../session/artifacts.js';
@@ -36,13 +38,14 @@ const USAGE = `capture page shot — capture a PNG of the tab as it is right now
36
38
  input:
37
39
  --viewport <WxH> transient device-metrics override for this capture; grammar: <positive-safe-int>x<positive-safe-int>, exact lowercase x with no whitespace — preset names are not accepted. Absent → no emulation, the browser's actual current viewport is captured
38
40
  --full-page transient override to the full scrollable content height for this capture
41
+ --color-scheme <dark|light> transient prefers-color-scheme override for this capture; cleared afterward
39
42
  --out <path> destination file; default: the active session's shots/ sequence, or a fresh oneshot-*/page/ dir under the capture root when no session is active
40
43
  --target <tabId> | --url <pattern> | --port <n> tab targeting; defaults to the active session tab
41
44
  --json mirror the result as JSON
42
45
  output:
43
- <screenshot path=… width=… height=… css-width=… css-height=… css-x=… css-y=… css-to-image-x=… css-to-image-y=… emulation=none|viewport|full-page> — saved path, PNG pixel dimensions, the CSS-pixel region the image covers (its page-coordinate origin and size) with the CSS-to-image scale derived from it, byte size, and the emulation fact (whether a transient override was applied, or its absence)
46
+ <screenshot path=… width=… height=… css-width=… css-height=… css-x=… css-y=… css-to-image-x=… css-to-image-y=… emulation=none|viewport|full-page color-scheme=dark|light> — saved path, PNG pixel dimensions, the CSS-pixel region the image covers (its page-coordinate origin and size) with the CSS-to-image scale derived from it, byte size, and any requested transient emulation
44
47
  effects:
45
- no flags: none — the capture reads the viewport as-is, with zero Emulation.* calls. With --viewport/--full-page: applies a transient Emulation.setDeviceMetricsOverride (~150ms re-layout wait) and clears it after the capture — two page-observable resizes (resize events fire; media queries flip and flip back)`;
48
+ no flags: none — the capture reads the viewport as-is, with zero Emulation.* calls. --viewport/--full-page applies a transient Emulation.setDeviceMetricsOverride (~150ms re-layout wait) and clears it after the capture — two page-observable resizes. --color-scheme applies a transient Emulation.setEmulatedMedia prefers-color-scheme override and clears it after the capture.`;
46
49
 
47
50
  // ---------------------------------------------------------------------------
48
51
  // Test-injectable dependency seam (the CDP-stub test pattern; the capture
@@ -128,6 +131,7 @@ export function buildScreenshotResult(f: {
128
131
  cssViewport?: CapturedRegion;
129
132
  emulation: EmulationMode;
130
133
  viewport?: { width: number; height: number };
134
+ colorScheme?: ColorScheme;
131
135
  }): RenderableResult {
132
136
  // The denominator is the CSS region the image was actually cut from, so the
133
137
  // ratio stays true under page zoom, device pixel ratio, and the local
@@ -140,12 +144,15 @@ export function buildScreenshotResult(f: {
140
144
  ? fact`CSS-to-image scale: ${scale.x.toFixed(6)} image px/CSS px horizontally and ${scale.y.toFixed(6)} image px/CSS px vertically (captured CSS region ${round6(f.cssViewport.width)}×${round6(f.cssViewport.height)} at page origin ${round6(f.cssViewport.x)},${round6(f.cssViewport.y)}).`
141
145
  : text`CSS-to-image scale unavailable: the browser did not return the CSS region this capture covers.`,
142
146
  f.emulation === 'none'
143
- ? text`emulation: none — the browser's actual current viewport was captured; no Emulation call was made.`
147
+ ? text`viewport emulation: none — the browser's actual current viewport was captured.`
144
148
  : f.emulation === 'viewport'
145
149
  ? fact`emulation: transient ${f.viewport!.width}x${f.viewport!.height} device-metrics override applied for the capture and cleared after — two page-observable resizes.`
146
150
  : f.viewport
147
151
  ? fact`emulation: transient full-page device-metrics override (from ${f.viewport.width}x${f.viewport.height}) applied for the capture and cleared after — two page-observable resizes.`
148
152
  : text`emulation: transient full-page device-metrics override applied for the capture and cleared after — two page-observable resizes.`,
153
+ f.colorScheme
154
+ ? fact`color scheme: transient prefers-color-scheme=${f.colorScheme} override applied for the capture and cleared after.`
155
+ : text`color scheme: browser default — no Emulation.setEmulatedMedia call was made.`,
149
156
  ];
150
157
  return {
151
158
  tag: 'screenshot',
@@ -160,6 +167,7 @@ export function buildScreenshotResult(f: {
160
167
  'css-to-image-x': scale?.x.toFixed(6),
161
168
  'css-to-image-y': scale?.y.toFixed(6),
162
169
  emulation: f.emulation,
170
+ 'color-scheme': f.colorScheme,
163
171
  },
164
172
  summary: f.dimensions
165
173
  ? fact`saved ${f.path} — ${f.dimensions.width}x${f.dimensions.height}px, ${f.bytes} bytes.`
@@ -183,7 +191,7 @@ export async function cmdPageShot(parsed: ParsedArgs, _args: string[]): Promise<
183
191
  {
184
192
  tag: 'error',
185
193
  attrs: { command: 'page shot', code: 'invalid_input' },
186
- summary: fact`received: ${parsed.positional.length} positional arguments; expected none — \`capture page shot [--viewport <WxH>] [--full-page] [--out <path>]\`.`,
194
+ summary: fact`received: ${parsed.positional.length} positional arguments; expected none — \`capture page shot [--viewport <WxH>] [--full-page] [--color-scheme <dark|light>] [--out <path>]\`.`,
187
195
  },
188
196
  { json: parsed.json },
189
197
  );
@@ -209,6 +217,22 @@ export async function cmdPageShot(parsed: ParsedArgs, _args: string[]): Promise<
209
217
  }
210
218
  }
211
219
 
220
+ let colorScheme: ColorScheme | undefined;
221
+ try {
222
+ colorScheme = parseColorScheme(parsed.colorScheme);
223
+ } catch {
224
+ emitResult(
225
+ {
226
+ tag: 'error',
227
+ attrs: { command: 'page shot', code: 'invalid_color_scheme' },
228
+ summary: fact`received: --color-scheme ${parsed.colorScheme}; expected dark or light.`,
229
+ },
230
+ { json: parsed.json },
231
+ );
232
+ process.exitCode = 1;
233
+ return;
234
+ }
235
+
212
236
  const emulation: EmulationMode = parsed.fullPage ? 'full-page' : viewport ? 'viewport' : 'none';
213
237
 
214
238
  // connection.ts derives the recorder-routed action label from
@@ -216,14 +240,14 @@ export async function cmdPageShot(parsed: ParsedArgs, _args: string[]): Promise<
216
240
  // restore the verb so stderr diagnostics identify this leaf.
217
241
  const result = await deps.withConnection(
218
242
  { ...parsed, command: 'shot' },
219
- async (client) => {
243
+ async (client) => withAppliedColorScheme(client, colorScheme, async () => {
220
244
  const screenshot = await captureScreenshotWithCssViewport(client, viewport, { fullPage: parsed.fullPage });
221
245
  const outPath = await resolveOutPath(parsed);
222
246
  writeScreenshot(outPath, screenshot.png);
223
247
  return { path: outPath, bytes: screenshot.png.length, dimensions: pngDimensions(screenshot.png), cssViewport: screenshot.cssViewport };
224
- },
248
+ }),
225
249
  { settle: 0 },
226
250
  );
227
251
 
228
- emitResult(buildScreenshotResult({ ...result, emulation, viewport }), { json: parsed.json });
252
+ emitResult(buildScreenshotResult({ ...result, emulation, viewport, colorScheme }), { json: parsed.json });
229
253
  }
@@ -18,6 +18,7 @@ import {
18
18
  import { emitResult, fact, lineList, type FactLine } from '../../../output/render.js';
19
19
  import {
20
20
  pageInputDeps,
21
+ capturePageInputScreenshot,
21
22
  effectiveSettle,
22
23
  emitInvalidInput,
23
24
  emitResolutionError,
@@ -31,7 +32,7 @@ input:
31
32
  --settle <ms> network-settle window applied after typing (default: 500; 1500 with an active session; 0 disables)
32
33
  --no-screenshot skip the auto-screenshot
33
34
  output:
34
- <typed> — the typed text, the resolved field identity (backend-node-id, role, name) and focus-click coordinates when --into was given, the measured settle (requested/waited), screenshot artifact path; --json mirrors the same fields
35
+ <typed> — the typed text, the resolved field identity (backend-node-id, role, name) and focus-click coordinates when --into was given, the measured settle (requested/waited), and either the screenshot artifact path or a warning when only that follow-up capture timed out; --json mirrors the same fields
35
36
  effects:
36
37
  dispatches a real focus click (when --into) followed by real text insertion; writes one screenshot into the active session's shots/ sequence unless --no-screenshot`;
37
38
 
@@ -69,8 +70,8 @@ export async function cmdPageType(parsed: ParsedArgs, _args: string[]): Promise<
69
70
  await typeText(live, textArg);
70
71
  }
71
72
  // Screenshot label identifies the field, never the typed content.
72
- const screenshot = await deps.autoScreenshot(client, 'type', parsed.into ?? 'focused element', parsed.noScreenshot);
73
- return { dispatch, screenshot } as const;
73
+ const screenshotResult = await capturePageInputScreenshot(client, 'type', parsed.into ?? 'focused element', parsed.noScreenshot);
74
+ return { dispatch, ...screenshotResult } as const;
74
75
  },
75
76
  );
76
77
 
@@ -78,7 +79,7 @@ export async function cmdPageType(parsed: ParsedArgs, _args: string[]): Promise<
78
79
  return emitResolutionError(parsed, 'page type', outcome.failure);
79
80
  }
80
81
 
81
- const { dispatch, screenshot } = outcome;
82
+ const { dispatch, screenshot, screenshotWarning } = outcome;
82
83
  const rows: FactLine[] = [
83
84
  dispatch
84
85
  ? fact`typed "${textArg}" into ${dispatch.role ?? 'unknown'} "${dispatch.name ?? ''}" (backend:${dispatch.backendNodeId}), focus click at x=${dispatch.x} y=${dispatch.y}`
@@ -86,6 +87,7 @@ export async function cmdPageType(parsed: ParsedArgs, _args: string[]): Promise<
86
87
  fact`settle: requested ${settleFacts.requestedMs}ms, waited ${settleFacts.waitedMs}ms`,
87
88
  ];
88
89
  if (screenshot) rows.push(fact`screenshot: ${screenshot}`);
90
+ if (screenshotWarning) rows.push(fact`screenshot-warning: ${screenshotWarning}`);
89
91
 
90
92
  emitResult(
91
93
  {
@@ -35,6 +35,8 @@ export type { Specificity, CSSRange, WinningDeclaration } from './style-provenan
35
35
 
36
36
  const STYLES_MAX_ELEMENTS = 150;
37
37
  const PROVENANCE_CONCURRENCY = 8;
38
+ /** Bounds queued provenance work without abandoning in-flight CDP requests on the shared snap connection. Matches snapshot.ts's per-request bound but remains local to avoid a collector/orchestrator import cycle. */
39
+ const PROVENANCE_BUDGET_MS = 5_000;
38
40
 
39
41
  /** Full computed-style snapshot tracked per element — broad, cheap (no CDP round trip; read via `getComputedStyle` in one `Runtime.evaluate`). */
40
42
  const COMPUTED_PROPERTIES = [
@@ -95,6 +97,10 @@ export interface StylesCoverage {
95
97
  keptElements: number;
96
98
  /** `true` when {@link STYLES_MAX_ELEMENTS} dropped one or more candidate elements from enumeration — an explicit fact (I-5), never a silent cap. */
97
99
  elementsTruncated: boolean;
100
+ /** `true` when the bounded provenance pass stopped starting element reads before every enumerated element was inspected; completed in-flight reads are still included. */
101
+ provenanceTruncated: boolean;
102
+ /** Present only when {@link provenanceTruncated} is true; names the factual bound that stopped additional provenance requests. */
103
+ provenanceTruncationReason?: 'collector-time-budget';
98
104
  }
99
105
 
100
106
  export interface StylesReport {
@@ -205,18 +211,33 @@ function resolvedIdentity(backendNodeId: number | undefined): { backendNodeId: n
205
211
  return backendNodeId === undefined ? { backendNodeId: null, identityUnresolved: true } : { backendNodeId };
206
212
  }
207
213
 
208
- /** Limits expensive per-element CDP provenance reads without changing document order. */
209
- async function mapWithConcurrency<T, R>(values: readonly T[], limit: number, fn: (value: T, index: number) => Promise<R>): Promise<R[]> {
210
- const result = new Array<R>(values.length);
214
+ /**
215
+ * Limits expensive per-element CDP provenance reads without changing document
216
+ * order. Once its budget expires it starts no more reads, but awaits the
217
+ * current worker batch so no CDP work is left running under the next collector.
218
+ */
219
+ async function mapWithConcurrency<T, R>(
220
+ values: readonly T[],
221
+ limit: number,
222
+ budgetMs: number,
223
+ fn: (value: T, index: number) => Promise<R>,
224
+ ): Promise<{ values: Array<R | undefined>; truncated: boolean }> {
225
+ const result = Array<R | undefined>(values.length).fill(undefined);
226
+ const deadline = Date.now() + budgetMs;
211
227
  let next = 0;
228
+ let truncated = false;
212
229
  const worker = async (): Promise<void> => {
213
230
  while (next < values.length) {
231
+ if (Date.now() >= deadline) {
232
+ truncated = true;
233
+ return;
234
+ }
214
235
  const index = next++;
215
236
  result[index] = await fn(values[index]!, index);
216
237
  }
217
238
  };
218
239
  await Promise.all(Array.from({ length: Math.min(limit, values.length) }, worker));
219
- return result;
240
+ return { values: result, truncated };
220
241
  }
221
242
 
222
243
  // ============================================================================
@@ -305,48 +326,49 @@ async function collectStylesInner(
305
326
 
306
327
  const sourceCache = new Map<string, Promise<ResolvedSourceLocation>>();
307
328
 
308
- const elements = await mapWithConcurrency(facts, PROVENANCE_CONCURRENCY, async (fact, index): Promise<StylesElementRecord> => {
309
- const ref = resolved[index];
310
- const computed = normalizeComputed(fact.computed);
311
-
312
- // A `noDeclarationRecord` is reserved for a SUCCESSFUL matched-styles response that genuinely had
313
- // no candidate for a property — an honest "nothing declares this" fact. When provenance itself
314
- // couldn't be inspected (no CDP nodeId, or `CSS.getMatchedStylesForNode` failed), that's a different
315
- // fact — "unknown", not "none" — so `winningDeclarations` is left empty and `provenanceUnavailable`
316
- // is set, rather than conflating the two into misleading no-declaration records.
317
- let winningDeclarations: WinningDeclaration[];
318
- let provenanceUnavailable: boolean | undefined;
319
- if (ref?.nodeId !== undefined) {
320
- try {
321
- const matched = (await client.send('CSS.getMatchedStylesForNode', {
322
- nodeId: ref.nodeId,
323
- })) as CDPMatchedStylesResponse;
324
- winningDeclarations = await buildWinningDeclarations(
325
- client,
326
- matched,
327
- computed,
328
- sourceCache,
329
- styleSheetUrls,
330
- PROVENANCE_PROPERTIES,
331
- );
332
- } catch {
333
- winningDeclarations = [];
334
- provenanceUnavailable = true;
335
- }
336
- } else {
329
+ const provenance = await mapWithConcurrency(facts, PROVENANCE_CONCURRENCY, PROVENANCE_BUDGET_MS, async (fact, index): Promise<StylesElementRecord> => {
330
+ const ref = resolved[index];
331
+ const computed = normalizeComputed(fact.computed);
332
+
333
+ // A `noDeclarationRecord` is reserved for a SUCCESSFUL matched-styles response that genuinely had
334
+ // no candidate for a property — an honest "nothing declares this" fact. When provenance itself
335
+ // couldn't be inspected (no CDP nodeId, or `CSS.getMatchedStylesForNode` failed), that's a different
336
+ // fact — "unknown", not "none" — so `winningDeclarations` is left empty and `provenanceUnavailable`
337
+ // is set, rather than conflating the two into misleading no-declaration records.
338
+ let winningDeclarations: WinningDeclaration[];
339
+ let provenanceUnavailable: boolean | undefined;
340
+ if (ref?.nodeId !== undefined) {
341
+ try {
342
+ const matched = (await client.send('CSS.getMatchedStylesForNode', {
343
+ nodeId: ref.nodeId,
344
+ })) as CDPMatchedStylesResponse;
345
+ winningDeclarations = await buildWinningDeclarations(
346
+ client,
347
+ matched,
348
+ computed,
349
+ sourceCache,
350
+ styleSheetUrls,
351
+ PROVENANCE_PROPERTIES,
352
+ );
353
+ } catch {
337
354
  winningDeclarations = [];
338
355
  provenanceUnavailable = true;
339
356
  }
357
+ } else {
358
+ winningDeclarations = [];
359
+ provenanceUnavailable = true;
360
+ }
340
361
 
341
- return {
342
- id: `s-${index}`,
343
- selector: sanitizeString(fact.cssPath),
344
- ...resolvedIdentity(ref?.backendNodeId),
345
- computed,
346
- winningDeclarations,
347
- provenanceUnavailable,
348
- };
349
- });
362
+ return {
363
+ id: `s-${index}`,
364
+ selector: sanitizeString(fact.cssPath),
365
+ ...resolvedIdentity(ref?.backendNodeId),
366
+ computed,
367
+ winningDeclarations,
368
+ provenanceUnavailable,
369
+ };
370
+ });
371
+ const elements = provenance.values.filter((element): element is StylesElementRecord => element !== undefined);
350
372
 
351
373
  ctx.write.json('styles.json', {
352
374
  elements,
@@ -356,7 +378,9 @@ async function collectStylesInner(
356
378
  shadowRootsNotWalked: inventory.shadowRootsNotWalked,
357
379
  totalCandidateElements: inventory.total,
358
380
  keptElements: elements.length,
359
- elementsTruncated: inventory.total > elements.length,
381
+ elementsTruncated: inventory.total > facts.length,
382
+ provenanceTruncated: provenance.truncated,
383
+ ...(provenance.truncated ? { provenanceTruncationReason: 'collector-time-budget' as const } : {}),
360
384
  },
361
385
  styleSheetHeaders: styleSheetHeaders,
362
386
  identity,