@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.
@@ -19,11 +19,13 @@ import * as fs from 'fs';
19
19
  import * as path from 'path';
20
20
  import { type ParsedArgs } from '../../types.js';
21
21
  import { withConnection } from '../../connection.js';
22
- import { captureScreenshotWithCssViewport, type CapturedRegion } from '../../screenshot.js';
22
+ import { captureScreenshotWithCssViewport, type CapturedRegion, type ScreenshotCrop } from '../../screenshot.js';
23
23
  import { parseViewport, type Viewport } from '../../viewport.js';
24
24
  import { parseColorScheme, withAppliedColorScheme, type ColorScheme } from '../../color-scheme.js';
25
25
  import { nextStepPath } from '../../../session-context.js';
26
26
  import { createOneshotSession } from '../../../session/commands.js';
27
+ import { resolveLiveTarget, type LiveClient, type ResolutionFailure } from '../../../interact.js';
28
+ import { emitResolutionError } from './click.js';
27
29
  import { assertUnderCaptureRoot, writeBinaryPrivate } from '../../../session/artifacts.js';
28
30
  import {
29
31
  emitResult,
@@ -39,13 +41,17 @@ input:
39
41
  --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
40
42
  --full-page transient override to the full scrollable content height for this capture
41
43
  --color-scheme <dark|light> transient prefers-color-scheme override for this capture; cleared afterward
44
+ --crop <x,y,w,h> capture this page-coordinate CSS-pixel rect, intersected with the live visual viewport; x/y may be fractional, w/h must be positive
45
+ --crop-selector <sel> resolve exactly one live target (bare CSS, ax:<name>, axid:<id>, or backend:<id>), scroll it into view, then crop its border box and surroundings
46
+ --pad <px> nonnegative integer CSS pixels added on every side of --crop-selector (default: 0; not accepted with --crop)
47
+ --zoom <factor> positive decimal CSS-to-image scale requested for a CSS crop; the 1600px image cap may produce a smaller reported scale
42
48
  --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
43
49
  --target <tabId> | --url <pattern> | --port <n> tab targeting; defaults to the active session tab
44
50
  --json mirror the result as JSON
45
51
  output:
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
52
+ <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, crop provenance, and any requested transient emulation
47
53
  effects:
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.`;
54
+ 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. --crop-selector scrolls its resolved target into view. CSS crops are intersected with the source visual viewport; an empty intersection fails without writing an image. --crop/--crop-selector cannot be combined with --full-page.`;
49
55
 
50
56
  // ---------------------------------------------------------------------------
51
57
  // Test-injectable dependency seam (the CDP-stub test pattern; the capture
@@ -119,6 +125,15 @@ function writeScreenshot(outPath: string, png: Buffer): void {
119
125
 
120
126
  export type EmulationMode = 'none' | 'viewport' | 'full-page';
121
127
 
128
+ interface CropProvenance {
129
+ readonly source: 'coordinates' | 'selector';
130
+ readonly requested: ScreenshotCrop;
131
+ readonly selector?: string;
132
+ readonly backendNodeId?: number;
133
+ readonly pad?: number;
134
+ readonly zoom: number;
135
+ }
136
+
122
137
  /** Renders a CSS-pixel measurement without inventing precision: an integer stays an integer, a fractional zoomed viewport keeps six decimals. */
123
138
  function round6(value: number): number {
124
139
  return Math.round(value * 1e6) / 1e6;
@@ -132,6 +147,7 @@ export function buildScreenshotResult(f: {
132
147
  emulation: EmulationMode;
133
148
  viewport?: { width: number; height: number };
134
149
  colorScheme?: ColorScheme;
150
+ crop?: CropProvenance;
135
151
  }): RenderableResult {
136
152
  // The denominator is the CSS region the image was actually cut from, so the
137
153
  // ratio stays true under page zoom, device pixel ratio, and the local
@@ -143,6 +159,9 @@ export function buildScreenshotResult(f: {
143
159
  scale && f.cssViewport
144
160
  ? 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)}).`
145
161
  : text`CSS-to-image scale unavailable: the browser did not return the CSS region this capture covers.`,
162
+ f.crop
163
+ ? fact`CSS crop: ${f.crop.source === 'selector' ? `selector ${f.crop.selector} (backend:${f.crop.backendNodeId}), border box plus ${f.crop.pad}px padding` : 'coordinates'} requested ${formatRect(f.crop.requested)} at zoom ${f.crop.zoom}; the captured CSS region above is its intersection with the source visual viewport.`
164
+ : text`CSS crop: none — the captured CSS region above is the source visual viewport.`,
146
165
  f.emulation === 'none'
147
166
  ? text`viewport emulation: none — the browser's actual current viewport was captured.`
148
167
  : f.emulation === 'viewport'
@@ -168,6 +187,10 @@ export function buildScreenshotResult(f: {
168
187
  'css-to-image-y': scale?.y.toFixed(6),
169
188
  emulation: f.emulation,
170
189
  'color-scheme': f.colorScheme,
190
+ 'crop-source': f.crop?.source,
191
+ 'crop-selector': f.crop?.selector,
192
+ 'crop-backend-node-id': f.crop?.backendNodeId,
193
+ 'requested-zoom': f.crop?.zoom,
171
194
  },
172
195
  summary: f.dimensions
173
196
  ? fact`saved ${f.path} — ${f.dimensions.width}x${f.dimensions.height}px, ${f.bytes} bytes.`
@@ -176,6 +199,38 @@ export function buildScreenshotResult(f: {
176
199
  };
177
200
  }
178
201
 
202
+ function formatRect(rect: ScreenshotCrop): string {
203
+ return `x=${round6(rect.x)} y=${round6(rect.y)} w=${round6(rect.width)} h=${round6(rect.height)}`;
204
+ }
205
+
206
+ function parseCrop(value: string): ScreenshotCrop | undefined {
207
+ const match = /^(-?(?:\d+\.?\d*|\.\d+)),(-?(?:\d+\.?\d*|\.\d+)),(\d+(?:\.\d*)?|\.\d+),(\d+(?:\.\d*)?|\.\d+)$/.exec(value);
208
+ if (!match) return undefined;
209
+ const [x, y, width, height] = match.slice(1).map(Number);
210
+ if (![x, y, width, height].every(Number.isFinite) || width <= 0 || height <= 0) return undefined;
211
+ return { x, y, width, height };
212
+ }
213
+
214
+ function parseZoom(value: string): number | undefined {
215
+ if (!/^(?:\d+\.?\d*|\.\d+)$/.test(value)) return undefined;
216
+ const zoom = Number(value);
217
+ return Number.isFinite(zoom) && zoom > 0 ? zoom : undefined;
218
+ }
219
+
220
+ function borderRect(model: unknown): ScreenshotCrop | undefined {
221
+ const border = (model as { border?: unknown } | undefined)?.border;
222
+ if (!Array.isArray(border) || border.length !== 8 || !border.every((point) => typeof point === 'number' && Number.isFinite(point))) return undefined;
223
+ const xs = [border[0], border[2], border[4], border[6]] as number[];
224
+ const ys = [border[1], border[3], border[5], border[7]] as number[];
225
+ const x = Math.min(...xs);
226
+ const y = Math.min(...ys);
227
+ return { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y };
228
+ }
229
+
230
+ function padded(rect: ScreenshotCrop, pad: number): ScreenshotCrop {
231
+ return { x: rect.x - pad, y: rect.y - pad, width: rect.width + 2 * pad, height: rect.height + 2 * pad };
232
+ }
233
+
179
234
  // ---------------------------------------------------------------------------
180
235
  // page shot
181
236
  // ---------------------------------------------------------------------------
@@ -191,7 +246,7 @@ export async function cmdPageShot(parsed: ParsedArgs, _args: string[]): Promise<
191
246
  {
192
247
  tag: 'error',
193
248
  attrs: { command: 'page shot', code: 'invalid_input' },
194
- summary: fact`received: ${parsed.positional.length} positional arguments; expected none — \`capture page shot [--viewport <WxH>] [--full-page] [--color-scheme <dark|light>] [--out <path>]\`.`,
249
+ summary: fact`received: ${parsed.positional.length} positional arguments; expected none — \`capture page shot [--viewport <WxH>] [--full-page] [--color-scheme <dark|light>] [--crop <x,y,w,h> | --crop-selector <sel> [--pad <px>]] [--zoom <factor>] [--out <path>]\`.`,
195
250
  },
196
251
  { json: parsed.json },
197
252
  );
@@ -199,6 +254,44 @@ export async function cmdPageShot(parsed: ParsedArgs, _args: string[]): Promise<
199
254
  return;
200
255
  }
201
256
 
257
+ const crop = parsed.crop === undefined ? undefined : parseCrop(parsed.crop);
258
+ if (parsed.crop !== undefined && !crop) {
259
+ emitResult({ tag: 'error', attrs: { command: 'page shot', code: 'invalid_crop' }, summary: fact`received: --crop ${parsed.crop}; expected x,y,w,h as finite CSS pixels without whitespace, with positive w and h.` }, { json: parsed.json });
260
+ process.exitCode = 1;
261
+ return;
262
+ }
263
+ if (parsed.crop !== undefined && parsed.cropSelector !== undefined) {
264
+ emitResult({ tag: 'error', attrs: { command: 'page shot', code: 'conflicting_crop' }, summary: text`--crop and --crop-selector cannot be combined; choose coordinates or one live target.` }, { json: parsed.json });
265
+ process.exitCode = 1;
266
+ return;
267
+ }
268
+ if (parsed.cropSelector !== undefined && !parsed.cropSelector.trim()) {
269
+ emitResult({ tag: 'error', attrs: { command: 'page shot', code: 'invalid_crop_selector' }, summary: text`received an empty --crop-selector; expected a bare CSS selector, ax:<name>, axid:<id>, or backend:<id>.` }, { json: parsed.json });
270
+ process.exitCode = 1;
271
+ return;
272
+ }
273
+ if (parsed.pad !== undefined && parsed.cropSelector === undefined) {
274
+ emitResult({ tag: 'error', attrs: { command: 'page shot', code: 'pad_requires_crop_selector' }, summary: text`--pad applies only to --crop-selector.` }, { json: parsed.json });
275
+ process.exitCode = 1;
276
+ return;
277
+ }
278
+ const zoom = parsed.zoom === undefined ? 1 : parseZoom(parsed.zoom);
279
+ if (!zoom) {
280
+ emitResult({ tag: 'error', attrs: { command: 'page shot', code: 'invalid_zoom' }, summary: fact`received: --zoom ${parsed.zoom}; expected a positive decimal factor.` }, { json: parsed.json });
281
+ process.exitCode = 1;
282
+ return;
283
+ }
284
+ if (parsed.zoom !== undefined && !crop && parsed.cropSelector === undefined) {
285
+ emitResult({ tag: 'error', attrs: { command: 'page shot', code: 'zoom_requires_crop' }, summary: text`--zoom requires --crop or --crop-selector.` }, { json: parsed.json });
286
+ process.exitCode = 1;
287
+ return;
288
+ }
289
+ if (parsed.fullPage && (crop || parsed.cropSelector !== undefined)) {
290
+ emitResult({ tag: 'error', attrs: { command: 'page shot', code: 'crop_conflicts_with_full_page' }, summary: text`--crop and --crop-selector capture from the visual viewport and cannot be combined with --full-page.` }, { json: parsed.json });
291
+ process.exitCode = 1;
292
+ return;
293
+ }
294
+
202
295
  let viewport: Viewport | undefined;
203
296
  if (parsed.viewport !== undefined) {
204
297
  try {
@@ -241,13 +334,32 @@ export async function cmdPageShot(parsed: ParsedArgs, _args: string[]): Promise<
241
334
  const result = await deps.withConnection(
242
335
  { ...parsed, command: 'shot' },
243
336
  async (client) => withAppliedColorScheme(client, colorScheme, async () => {
244
- const screenshot = await captureScreenshotWithCssViewport(client, viewport, { fullPage: parsed.fullPage });
337
+ let cropProvenance: CropProvenance | undefined;
338
+ let requestedCrop = crop;
339
+ if (parsed.cropSelector !== undefined) {
340
+ const resolved = await resolveLiveTarget(client as unknown as LiveClient, parsed.cropSelector);
341
+ if (!resolved.ok) return { failure: resolved } as const;
342
+ await client.send('DOM.scrollIntoViewIfNeeded', { backendNodeId: resolved.backendNodeId });
343
+ const box = (await client.send('DOM.getBoxModel', { backendNodeId: resolved.backendNodeId })) as { model?: unknown };
344
+ const rect = borderRect(box.model);
345
+ if (!rect) throw new Error(`Resolved target backend:${resolved.backendNodeId} has no measurable border box for --crop-selector.`);
346
+ const pad = parsed.pad ?? 0;
347
+ requestedCrop = padded(rect, pad);
348
+ cropProvenance = { source: 'selector', requested: requestedCrop, selector: parsed.cropSelector, backendNodeId: resolved.backendNodeId, pad, zoom };
349
+ } else if (requestedCrop) {
350
+ cropProvenance = { source: 'coordinates', requested: requestedCrop, zoom };
351
+ }
352
+ const screenshot = await captureScreenshotWithCssViewport(client, viewport, { fullPage: parsed.fullPage, crop: requestedCrop, zoom });
245
353
  const outPath = await resolveOutPath(parsed);
246
354
  writeScreenshot(outPath, screenshot.png);
247
- return { path: outPath, bytes: screenshot.png.length, dimensions: pngDimensions(screenshot.png), cssViewport: screenshot.cssViewport };
355
+ return { path: outPath, bytes: screenshot.png.length, dimensions: pngDimensions(screenshot.png), cssViewport: screenshot.cssViewport, crop: cropProvenance } as const;
248
356
  }),
249
357
  { settle: 0 },
250
358
  );
251
359
 
360
+ if ('failure' in result) {
361
+ emitResolutionError(parsed, 'page shot', result.failure as ResolutionFailure);
362
+ return;
363
+ }
252
364
  emitResult(buildScreenshotResult({ ...result, emulation, viewport, colorScheme }), { json: parsed.json });
253
365
  }
@@ -1,7 +1,8 @@
1
1
  import * as fs from 'fs';
2
2
  import { performance } from 'node:perf_hooks';
3
3
  import { CDPClient } from './client.js';
4
- import { findTabByIdAcrossEndpoints, findTabByUrlAcrossEndpoints } from './targets.js';
4
+ import { detectCdpPortsAsync } from './detect.js';
5
+ import { AmbiguousPageTargetError, findTabByIdAcrossEndpoints, findTabByUrlAcrossEndpoints, findUnambiguousPageTabInPorts } from './targets.js';
5
6
  import { type CDPTarget, type ParsedArgs } from './types.js';
6
7
  import { ConsoleRecorder, printConsoleSummary } from './console-recorder.js';
7
8
  import { HARRecorder } from './har-recorder.js';
@@ -13,7 +14,7 @@ import { getActiveSession, updateActiveSession, type ActiveSessionState } from '
13
14
  import { withSessionScopeLifecycle } from '../session/coordinator.js';
14
15
  import { RecorderHeldClient, isRecorderHeldClient } from './recorder-client.js';
15
16
  import { recDirFor, readRecorderJson } from './motion/recorder.js';
16
- import { captureError, invalidInput } from '../errors.js';
17
+ import { captureError } from '../errors.js';
17
18
 
18
19
  function getPortFromWebSocketDebuggerUrl(url?: string): number | null {
19
20
  if (!url) return null;
@@ -51,10 +52,10 @@ export interface ConnectionSeams {
51
52
  sleep: (ms: number) => Promise<void>;
52
53
  }
53
54
 
54
- function defaultResolveTab(parsed: ParsedArgs): Promise<{ port: number; tab: CDPTarget } | null> {
55
- return parsed.target
56
- ? findTabByIdAcrossEndpoints(parsed.target, parsed.port)
57
- : findTabByUrlAcrossEndpoints(parsed.url!, parsed.port);
55
+ async function defaultResolveTab(parsed: ParsedArgs): Promise<{ port: number; tab: CDPTarget } | null> {
56
+ if (parsed.target) return findTabByIdAcrossEndpoints(parsed.target, parsed.port);
57
+ if (parsed.url) return findTabByUrlAcrossEndpoints(parsed.url, parsed.port);
58
+ return findUnambiguousPageTabInPorts(parsed.port === undefined ? (await detectCdpPortsAsync()).map((endpoint) => endpoint.port) : [parsed.port]);
58
59
  }
59
60
 
60
61
  let seams: ConnectionSeams = {
@@ -210,11 +211,15 @@ export async function connectForCommand(
210
211
  return routed;
211
212
  }
212
213
 
213
- if (!parsed.target && !parsed.url) {
214
- throw invalidInput('Use --target <tabId> or --url <pattern> to target a tab. Run "capture tab list" to see available tabs.', 'missing_target');
214
+ let resolved: { port: number; tab: CDPTarget } | null;
215
+ try {
216
+ resolved = await seams.resolveTab(parsed);
217
+ } catch (error) {
218
+ if (error instanceof AmbiguousPageTargetError) {
219
+ throw captureError('precondition', 'target_unavailable', error.message, error);
220
+ }
221
+ throw error;
215
222
  }
216
-
217
- const resolved = await seams.resolveTab(parsed);
218
223
  const tab = resolved?.tab ?? null;
219
224
 
220
225
  if (!tab) {
@@ -222,7 +227,9 @@ export async function connectForCommand(
222
227
  throw captureError(
223
228
  'precondition',
224
229
  'target_unavailable',
225
- `No tab found for ${parsed.target ? 'target' : 'URL pattern'} "${query}". Run "capture tab list" to see available tabs.`,
230
+ query === undefined
231
+ ? 'No page tab found. Run "capture tab list" to see available tabs.'
232
+ : `No tab found for ${parsed.target ? 'target' : 'URL pattern'} "${query}". Run "capture tab list" to see available tabs.`,
226
233
  );
227
234
  }
228
235
 
@@ -320,7 +327,7 @@ async function finishLocalHar(parsed: ParsedArgs, collectors: Collectors): Promi
320
327
  if (batch.entries.length > 0 || batch.incompleteLifecycles.length > 0) {
321
328
  await seams.appendHar(parsed.har, batch);
322
329
  console.error(
323
- ` [har:${parsed.har}] +${batch.entries.length} entries +${batch.incompleteLifecycles.length} incomplete`,
330
+ ` [har:${parsed.har}] +${batch.entries.length} entries +${batch.incompleteLifecycles.length} incomplete — incomplete lifecycles are retained as their own records (request plus whatever response was observed), separate from log.entries; read them with capture session har`,
324
331
  );
325
332
  }
326
333
  }
@@ -284,6 +284,7 @@ function buildGeometryWalkScript(maxElements: number): string {
284
284
  idx: idx,
285
285
  tag: tag.toLowerCase(),
286
286
  selector: __selectorOf(el),
287
+ attributes: Array.prototype.map.call(el.attributes, function (attr) { return [attr.name, attr.value]; }),
287
288
  domPath: __domPathOf(el),
288
289
  text: directText(el),
289
290
  frame: { frameId: frameId, isTopFrame: isTop, ancestorFrameIds: ancestorFrameIds.slice() },
@@ -292,6 +293,16 @@ function buildGeometryWalkScript(maxElements: number): string {
292
293
  stackingContext: { creates: reasons.length > 0, reasons: reasons },
293
294
  visibility: { visible: visible, opacity: opacity, displayNone: displayNone, visibilityHidden: visibilityHidden, zeroSize: zeroSize },
294
295
  clipping: clippingInfo(el, rect),
296
+ paint: {
297
+ 'box-shadow': style.boxShadow,
298
+ 'outline-width': style.outlineWidth,
299
+ 'outline-style': style.outlineStyle,
300
+ 'outline-offset': style.outlineOffset,
301
+ filter: style.filter,
302
+ transform: style.transform,
303
+ 'clip-path': style.clipPath,
304
+ 'border-radius': style.borderRadius,
305
+ },
295
306
  layout: {
296
307
  boxSizing: style.boxSizing,
297
308
  position: style.position,
@@ -342,14 +353,11 @@ function buildGeometryWalkScript(maxElements: number): string {
342
353
  pushedFact.iframeContentUnavailable = true;
343
354
  pushedFact.iframeContentUnavailableReason = 'content-document-read-threw';
344
355
  }
345
- if (innerDoc && innerDoc.body) {
356
+ if (innerDoc && innerDoc.documentElement) {
346
357
  frameCounter += 1;
347
358
  var childFrameId = 'frame-' + frameCounter;
348
359
  var childAncestors = ancestorFrameIds.concat([frameId]);
349
- var innerKids = innerDoc.body.children;
350
- for (var ii = 0; ii < innerKids.length; ii++) {
351
- walk(innerKids[ii], childFrameId, false, childAncestors, null);
352
- }
360
+ walk(innerDoc.documentElement, childFrameId, false, childAncestors, null);
353
361
  }
354
362
  return;
355
363
  }
@@ -360,10 +368,7 @@ function buildGeometryWalkScript(maxElements: number): string {
360
368
  }
361
369
  }
362
370
 
363
- var bodyKids = document.body ? document.body.children : [];
364
- for (var bi = 0; bi < bodyKids.length; bi++) {
365
- walk(bodyKids[bi], 'frame-0', true, [], null);
366
- }
371
+ if (document.documentElement) walk(document.documentElement, 'frame-0', true, [], null);
367
372
 
368
373
  return { facts: facts, elements: elements, meta: { elementsTruncated: elementsOverflowCount } };
369
374
  })()`;
@@ -569,6 +574,17 @@ interface RawGridFact {
569
574
  };
570
575
  }
571
576
 
577
+ interface RawPaintFact {
578
+ readonly 'box-shadow': string;
579
+ readonly 'outline-width': string;
580
+ readonly 'outline-style': string;
581
+ readonly 'outline-offset': string;
582
+ readonly filter: string;
583
+ readonly transform: string;
584
+ readonly 'clip-path': string;
585
+ readonly 'border-radius': string;
586
+ }
587
+
572
588
  interface RawLayoutFact {
573
589
  readonly boxSizing: string;
574
590
  readonly position: string;
@@ -609,6 +625,7 @@ interface RawGeometryFact {
609
625
  readonly idx: number;
610
626
  readonly tag: string;
611
627
  readonly selector?: string;
628
+ readonly attributes?: readonly (readonly [string, string])[];
612
629
  readonly domPath: string;
613
630
  readonly text?: string;
614
631
  readonly frame: RawFrameFact;
@@ -617,6 +634,8 @@ interface RawGeometryFact {
617
634
  readonly stackingContext: RawStackingContextFact;
618
635
  readonly visibility: RawVisibilityFact;
619
636
  readonly clipping: RawClippingFact | null;
637
+ /** Ink-relevant computed styles retained here rather than styles.json so every retained geometry element is queryable, including shadow/iframe records and records beyond styles.json's 150-element cap. */
638
+ readonly paint: RawPaintFact;
620
639
  readonly layout: RawLayoutFact;
621
640
  /** Present only on an `<iframe>` element's own fact when its `contentDocument` read threw (G6) -- see {@link GeometryElementRecord.iframeContentUnavailable}. */
622
641
  readonly iframeContentUnavailable?: boolean;
@@ -639,6 +658,10 @@ export interface GeometryBoxModel {
639
658
  export interface GeometryElementRecord extends Omit<ElementRecord, 'backendNodeId'> {
640
659
  readonly id: string;
641
660
  readonly tag: string;
661
+ /** The element's own HTML attributes, capped by {@link capAttributes}. */
662
+ readonly attributes: Readonly<Record<string, string>>;
663
+ /** Number of own attributes omitted by the per-element cap, when nonzero. */
664
+ readonly attributesTruncated?: number;
642
665
  readonly domPath: string;
643
666
  readonly frame: RawFrameFact;
644
667
  readonly shadow: RawShadowFact | null;
@@ -649,6 +672,8 @@ export interface GeometryElementRecord extends Omit<ElementRecord, 'backendNodeI
649
672
  readonly stackingContext: RawStackingContextFact;
650
673
  readonly visibility: RawVisibilityFact;
651
674
  readonly clipping: RawClippingFact | null;
675
+ /** Uncapped, per-geometry-record computed styles used for ink extents and clip-source description. Absent in snapshots captured before ink evidence existed. */
676
+ readonly paint?: RawPaintFact;
652
677
  readonly layout: LayoutFact;
653
678
  /** `null` (never an omitted key) when this record's identity did not resolve — see {@link identityUnresolved}. */
654
679
  readonly backendNodeId: number | null;
@@ -723,9 +748,24 @@ function resolvedIdentity(backendNodeId: number | undefined): { backendNodeId: n
723
748
 
724
749
  /** Cap for ordinary computed-CSS-value strings (position/display/min-max sizes/flex+grid provenance). */
725
750
  const CSS_VALUE_MAX = 200;
751
+ /** Cap on how many HTML attributes are retained for one geometry element. */
752
+ const MAX_ATTRIBUTES = 32;
753
+ /** Cap on attribute names; values use the ordinary CSS/page-string cap. */
754
+ const ATTRIBUTE_NAME_MAX = 100;
726
755
  /** Cap on how many grid template-track strings are kept per axis. */
727
756
  const MAX_GRID_TRACKS = 64;
728
757
 
758
+ function capAttributes(attributes: readonly (readonly [string, string])[]): { readonly values: Readonly<Record<string, string>>; readonly truncated: number } {
759
+ const capped = capArray(attributes, MAX_ATTRIBUTES);
760
+ return {
761
+ values: Object.fromEntries(capped.items.map(([name, value]) => [
762
+ sanitizeString(name, { max: ATTRIBUTE_NAME_MAX }),
763
+ sanitizeString(value, { max: CSS_VALUE_MAX }),
764
+ ])),
765
+ truncated: capped.truncated,
766
+ };
767
+ }
768
+
729
769
  function capTrackList(values: readonly string[]): { items: string[]; truncated: number } {
730
770
  const { items, truncated } = capArray(values, MAX_GRID_TRACKS);
731
771
  return { items: items.map((v) => sanitizeString(v, { max: CSS_VALUE_MAX })), truncated };
@@ -976,6 +1016,7 @@ export const collectGeometry: Collector = async (ctx) => {
976
1016
  }
977
1017
 
978
1018
  const geometryUnavailable = geometryUnavailableReason !== undefined;
1019
+ const capturedAttributes = capAttributes(fact.attributes ?? []);
979
1020
  const rect = quads.length > 0 ? unionRect(quads) : { x: 0, y: 0, width: 0, height: 0 };
980
1021
  // Only a REAL (non-throwing) quads read can strengthen zeroSize/visible
981
1022
  // beyond the JS-side facts -- when the quads read itself never proved
@@ -988,6 +1029,8 @@ export const collectGeometry: Collector = async (ctx) => {
988
1029
  id: `el-${fact.idx}`,
989
1030
  tag: sanitizeString(fact.tag, { max: 64 }),
990
1031
  selector: fact.selector !== undefined ? sanitizeString(fact.selector, { max: 300 }) : undefined,
1032
+ attributes: capturedAttributes.values,
1033
+ ...(capturedAttributes.truncated ? { attributesTruncated: capturedAttributes.truncated } : {}),
991
1034
  domPath: sanitizeString(fact.domPath, { max: 500 }),
992
1035
  ...resolvedIdentity(backendNodeId),
993
1036
  text: fact.text !== undefined ? sanitizeString(fact.text, { max: 200 }) : undefined,
@@ -1012,6 +1055,20 @@ export const collectGeometry: Collector = async (ctx) => {
1012
1055
  clippedFraction: fact.clipping.clippedFraction,
1013
1056
  }
1014
1057
  : null,
1058
+ ...(fact.paint
1059
+ ? {
1060
+ paint: {
1061
+ 'box-shadow': sanitizeString(fact.paint['box-shadow']),
1062
+ 'outline-width': sanitizeString(fact.paint['outline-width']),
1063
+ 'outline-style': sanitizeString(fact.paint['outline-style']),
1064
+ 'outline-offset': sanitizeString(fact.paint['outline-offset']),
1065
+ filter: sanitizeString(fact.paint.filter),
1066
+ transform: sanitizeString(fact.paint.transform),
1067
+ 'clip-path': sanitizeString(fact.paint['clip-path']),
1068
+ 'border-radius': sanitizeString(fact.paint['border-radius']),
1069
+ },
1070
+ }
1071
+ : {}),
1015
1072
  layout: capLayoutFact(fact.layout),
1016
1073
  ...(geometryUnavailable ? { geometryUnavailable: true as const, geometryUnavailableReason } : {}),
1017
1074
  ...(fact.iframeContentUnavailable
@@ -272,6 +272,7 @@ export const collectPixels: Collector = async (ctx) => {
272
272
  ctx.write.json('pixels.json', {
273
273
  scope: PIXELS_SCOPE,
274
274
  elements: [],
275
+ cropsByBackend: {},
275
276
  backgroundOverrideRestored,
276
277
  captureFailed: true,
277
278
  elementsTotal: 0,
@@ -311,6 +312,10 @@ export const collectPixels: Collector = async (ctx) => {
311
312
  const described = await Promise.all(nodeIds.map((nodeId) => describeElementForCrop(client, nodeId)));
312
313
 
313
314
  const elements: PixelElementRecord[] = [];
315
+ // Direct lookup from a live/snapshot backend id to its existing crop path.
316
+ // The element rows remain the authoritative complete records; this map
317
+ // removes filename-search arithmetic for the common `backend:<id>` lookup.
318
+ const cropsByBackend: Record<string, string> = {};
314
319
  let index = 0;
315
320
  let elementsSkipped = 0;
316
321
  let elementsReadFailed = 0;
@@ -361,6 +366,7 @@ export const collectPixels: Collector = async (ctx) => {
361
366
  const cropFilename = `crops/${cropFileBase(index, entry)}.png`;
362
367
  ctx.write.binary(cropFilename, encodePng(applyMaskTransparent(normalCrop, mask)));
363
368
 
369
+ const crop = `${ctx.snapId}/${cropFilename}`;
364
370
  elements.push({
365
371
  id: `px-${index}`,
366
372
  backendNodeId: entry.backendNodeId,
@@ -372,7 +378,7 @@ export const collectPixels: Collector = async (ctx) => {
372
378
  ...(entry.clipInfo.unavailable
373
379
  ? { ancestorClipUnavailable: true as const, ancestorClipUnavailableReason: entry.clipInfo.unavailableReason }
374
380
  : {}),
375
- crop: `${ctx.snapId}/${cropFilename}`,
381
+ crop,
376
382
  maskedPixelFraction: round3(mask.count / (pixelRect.width * pixelRect.height)),
377
383
  hash: averageHash(normalCrop, mask),
378
384
  avgColor: averageColor(normalCrop, mask),
@@ -381,12 +387,14 @@ export const collectPixels: Collector = async (ctx) => {
381
387
  alphaFraction: alphaFraction(transparentCrop, mask),
382
388
  visiblePixelFraction: visiblePixelFraction(transparentCrop, mask),
383
389
  });
390
+ if (entry.backendNodeId !== null) cropsByBackend[String(entry.backendNodeId)] = crop;
384
391
  index += 1;
385
392
  }
386
393
 
387
394
  ctx.write.json('pixels.json', {
388
395
  scope: PIXELS_SCOPE,
389
396
  elements,
397
+ cropsByBackend,
390
398
  backgroundOverrideRestored,
391
399
  captureFailed: false,
392
400
  elementsTotal,