@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.
@@ -0,0 +1,116 @@
1
+ import type { InkRect, InkStyles } from './ink.js';
2
+
3
+ /**
4
+ * Selects which recorded rect + computed-style table an ink derivation runs over:
5
+ * the base-state geometry record, or one element's post-force values under a
6
+ * requested `--state`. Both callers of `deriveInkBox` over snapshot artifacts
7
+ * (`measure explain` and `measure map paint`) resolve their input here so the
8
+ * two leaves cannot disagree about what a forced-state ink box is derived from.
9
+ */
10
+ export interface InkSource {
11
+ readonly rect: InkRect;
12
+ readonly styles: InkStyles;
13
+ /** Factual description of where `rect` and `styles` were read from, for the caller to report. */
14
+ readonly provenance: string;
15
+ }
16
+
17
+ export interface InkSourceUnavailable {
18
+ /** Factual reason no rect/style pair was found, phrased for direct inclusion in output. */
19
+ readonly unavailable: string;
20
+ }
21
+
22
+ export type InkSourceResult = InkSource | InkSourceUnavailable;
23
+
24
+ export function isInkSource(result: InkSourceResult): result is InkSource {
25
+ return (result as InkSource).rect !== undefined;
26
+ }
27
+
28
+ export interface InkTargetIdentity {
29
+ readonly selector?: string;
30
+ readonly backendNodeId?: number | null;
31
+ }
32
+
33
+ interface GeometryLike extends InkTargetIdentity {
34
+ readonly boxModel?: Record<string, unknown> | null;
35
+ /** Ink-relevant computed styles recorded beside geometry; absent on snapshots taken before that collection existed. */
36
+ readonly paint?: Record<string, string | null>;
37
+ }
38
+
39
+ /** Converts a CDP box-model quad (8 numbers) to its axis-aligned bounding rect. */
40
+ export function quadRect(value: unknown): InkRect | undefined {
41
+ if (!Array.isArray(value) || value.length !== 8 || !value.every((entry) => Number.isFinite(entry))) return undefined;
42
+ const xs = [value[0], value[2], value[4], value[6]] as number[];
43
+ const ys = [value[1], value[3], value[5], value[7]] as number[];
44
+ const left = Math.min(...xs), top = Math.min(...ys);
45
+ return { x: left, y: top, width: Math.max(...xs) - left, height: Math.max(...ys) - top };
46
+ }
47
+
48
+ export function baseInkSource(element: GeometryLike): InkSourceResult {
49
+ const rect = quadRect((element.boxModel ?? {}).border);
50
+ if (!rect) return { unavailable: `this target's border-box quad was not captured` };
51
+ return { rect, styles: element.paint ?? {}, provenance: 'base-state geometry record' };
52
+ }
53
+
54
+ function rectFrom(value: unknown): InkRect | undefined {
55
+ if (!value || typeof value !== 'object') return undefined;
56
+ const raw = value as Record<string, unknown>;
57
+ const [x, y, width, height] = [raw.x, raw.y, raw.width ?? raw.w, raw.height ?? raw.h];
58
+ if (![x, y, width, height].every((entry) => typeof entry === 'number' && Number.isFinite(entry))) return undefined;
59
+ return { x: x as number, y: y as number, width: width as number, height: height as number };
60
+ }
61
+
62
+ function stylesFrom(value: unknown): InkStyles | undefined {
63
+ if (!value || typeof value !== 'object') return undefined;
64
+ const after = (value as Record<string, unknown>).after;
65
+ if (!after || typeof after !== 'object') return undefined;
66
+ return after as InkStyles;
67
+ }
68
+
69
+ function identityMatches(row: InkTargetIdentity, target: InkTargetIdentity): boolean {
70
+ if (typeof target.backendNodeId === 'number' && typeof row.backendNodeId === 'number') return row.backendNodeId === target.backendNodeId;
71
+ return Boolean(target.selector && row.selector === target.selector);
72
+ }
73
+
74
+ interface StateRow extends InkTargetIdentity {
75
+ readonly relation?: string;
76
+ readonly geometry?: unknown;
77
+ readonly style?: unknown;
78
+ }
79
+
80
+ /**
81
+ * Resolves one element's post-force rect and computed styles from `states.json`.
82
+ * The rect is that element's `getBoundingClientRect` AABB after the force was
83
+ * applied — a border box, which is what `deriveInkBox` grows. Under a forced
84
+ * state the paint change frequently lands on a descendant of the forced target
85
+ * (a hover ring drawn on a child), so `affected.elements` rows are searched
86
+ * alongside the forced target's own record.
87
+ */
88
+ export function forcedStateInkSource(states: unknown, target: InkTargetIdentity, state: string): InkSourceResult {
89
+ const elements = (states && typeof states === 'object' && Array.isArray((states as Record<string, unknown>).elements))
90
+ ? (states as { elements: unknown[] }).elements.filter((entry): entry is Record<string, unknown> => Boolean(entry && typeof entry === 'object'))
91
+ : [];
92
+ const forState = elements.filter((record) => String(record.state ?? '') === state);
93
+ if (!forState.length) {
94
+ const recorded = [...new Set(elements.map((record) => String(record.state ?? '')).filter(Boolean))];
95
+ return { unavailable: `this snapshot recorded no "${state}" state${recorded.length ? `; it recorded ${recorded.join(', ')}` : ''}` };
96
+ }
97
+ const unsupported = forState.filter((record) => record.supported === false);
98
+ for (const record of forState) {
99
+ if (record.supported === false) continue;
100
+ const affected = (record.affected && typeof record.affected === 'object' && Array.isArray((record.affected as Record<string, unknown>).elements))
101
+ ? ((record.affected as { elements: unknown[] }).elements.filter((entry): entry is StateRow => Boolean(entry && typeof entry === 'object')))
102
+ : [];
103
+ const own: StateRow = { selector: record.selector as string | undefined, backendNodeId: record.backendNodeId as number | null | undefined, relation: 'target', geometry: record.geometry, style: record.style };
104
+ for (const row of [own, ...affected]) {
105
+ if (!identityMatches(row, target)) continue;
106
+ const rect = rectFrom((row.geometry as Record<string, unknown> | undefined)?.after);
107
+ const styles = stylesFrom(row.style);
108
+ if (!rect) return { unavailable: `the "${state}" record for this target captured no post-force rect` };
109
+ if (!styles) return { unavailable: `the "${state}" record for this target captured no post-force computed styles` };
110
+ const id = String(record.id ?? state);
111
+ return { rect, styles, provenance: `forced state "${state}" (states.json record ${id}, ${row.relation ?? 'affected'} row, post-force values)` };
112
+ }
113
+ }
114
+ if (unsupported.length === forState.length) return { unavailable: `state "${state}" was recorded as unsupported on its forced target` };
115
+ return { unavailable: `no "${state}" record matched this target; forced-state rows cover the forced target, its light-DOM descendants, and its following-sibling subtrees` };
116
+ }
@@ -0,0 +1,180 @@
1
+ export interface InkRect {
2
+ readonly x: number;
3
+ readonly y: number;
4
+ readonly width: number;
5
+ readonly height: number;
6
+ }
7
+
8
+ export interface InkStyles {
9
+ readonly 'box-shadow'?: string | null;
10
+ readonly 'outline-width'?: string | null;
11
+ readonly 'outline-style'?: string | null;
12
+ readonly 'outline-offset'?: string | null;
13
+ readonly filter?: string | null;
14
+ readonly transform?: string | null;
15
+ }
16
+
17
+ export interface InkEdges {
18
+ readonly top: number;
19
+ readonly right: number;
20
+ readonly bottom: number;
21
+ readonly left: number;
22
+ }
23
+
24
+ export interface InkContributor {
25
+ readonly source: 'box-shadow' | 'outline' | 'filter:drop-shadow';
26
+ readonly index?: number;
27
+ readonly edges: InkEdges;
28
+ /** A blur has no finite specified painted edge; its radius is a nominal AABB convention. */
29
+ readonly nominal?: true;
30
+ }
31
+
32
+ export interface InkResult {
33
+ /** `null` when a needed computed-style value was not captured or a paint-growing filter could not be resolved. */
34
+ readonly inkBox: InkRect | null;
35
+ readonly contributors: readonly InkContributor[];
36
+ readonly missingStyles: readonly string[];
37
+ readonly unresolved: readonly string[];
38
+ /** True when an included blur uses the parser's nominal blur-radius extent. */
39
+ readonly nominal: boolean;
40
+ }
41
+
42
+ const REQUIRED_STYLES = ['box-shadow', 'outline-width', 'outline-style', 'outline-offset', 'filter', 'transform'] as const;
43
+
44
+ function zeroEdges(): InkEdges {
45
+ return { top: 0, right: 0, bottom: 0, left: 0 };
46
+ }
47
+
48
+ function splitTopLevel(value: string, delimiter: ',' | ' '): string[] {
49
+ const parts: string[] = [];
50
+ let depth = 0;
51
+ let start = 0;
52
+ for (let index = 0; index < value.length; index += 1) {
53
+ const char = value[index]!;
54
+ if (char === '(') depth += 1;
55
+ else if (char === ')' && depth > 0) depth -= 1;
56
+ else if (depth === 0 && (delimiter === ',' ? char === ',' : /\s/.test(char))) {
57
+ const part = value.slice(start, index).trim();
58
+ if (part) parts.push(part);
59
+ start = index + 1;
60
+ }
61
+ }
62
+ const tail = value.slice(start).trim();
63
+ if (tail) parts.push(tail);
64
+ return parts;
65
+ }
66
+
67
+ function px(value: string): number | undefined {
68
+ const match = /^(-?(?:\d+|\d*\.\d+))px$/.exec(value) ?? (/^-?(?:\d+|\d*\.\d+)$/.test(value) ? [value, value] : undefined);
69
+ if (!match) return undefined;
70
+ const parsed = Number(match[1]);
71
+ return Number.isFinite(parsed) ? parsed : undefined;
72
+ }
73
+
74
+ function edgesForShadow(offsetX: number, offsetY: number, blur: number, spread: number): InkEdges {
75
+ // CSS computes these values to px. Blur's painted boundary is not a hard CSS edge;
76
+ // its radius is the recorded geometric extent used for this conservative ink AABB.
77
+ const growth = Math.max(0, blur + spread);
78
+ return {
79
+ left: Math.max(0, growth - offsetX),
80
+ right: Math.max(0, growth + offsetX),
81
+ top: Math.max(0, growth - offsetY),
82
+ bottom: Math.max(0, growth + offsetY),
83
+ };
84
+ }
85
+
86
+ function parseShadow(value: string, source: InkContributor['source'], index: number, allowSpread: boolean): InkContributor | undefined {
87
+ const tokens = splitTopLevel(value, ' ').filter((token) => token.toLowerCase() !== 'inset');
88
+ const lengths = tokens.map(px).filter((length): length is number => length !== undefined);
89
+ if (lengths.length < 2 || lengths.length > (allowSpread ? 4 : 3)) return undefined;
90
+ const [offsetX, offsetY, blur = 0, spread = 0] = lengths;
91
+ return { source, index, edges: edgesForShadow(offsetX!, offsetY!, blur, allowSpread ? spread : 0), ...(blur > 0 ? { nominal: true as const } : {}) };
92
+ }
93
+
94
+ function dropShadowArguments(filter: string): string[] {
95
+ const out: string[] = [];
96
+ const lower = filter.toLowerCase();
97
+ let cursor = 0;
98
+ while (cursor < lower.length) {
99
+ const start = lower.indexOf('drop-shadow(', cursor);
100
+ if (start < 0) break;
101
+ let depth = 1;
102
+ let end = start + 'drop-shadow('.length;
103
+ while (end < filter.length && depth > 0) {
104
+ if (filter[end] === '(') depth += 1;
105
+ else if (filter[end] === ')') depth -= 1;
106
+ end += 1;
107
+ }
108
+ if (depth === 0) out.push(filter.slice(start + 'drop-shadow('.length, end - 1));
109
+ cursor = end;
110
+ }
111
+ return out;
112
+ }
113
+
114
+ function containsUnresolvedPaintFilter(filter: string): boolean {
115
+ const remainder = filter.replace(/drop-shadow\((?:[^()]|\([^()]*\))*\)/gi, '').trim();
116
+ return /(?:\bblur\(|\burl\()/i.test(remainder);
117
+ }
118
+
119
+ function combineEdges(contributors: readonly InkContributor[]): InkEdges {
120
+ return contributors.reduce<InkEdges>((extent, contributor) => ({
121
+ top: Math.max(extent.top, contributor.edges.top),
122
+ right: Math.max(extent.right, contributor.edges.right),
123
+ bottom: Math.max(extent.bottom, contributor.edges.bottom),
124
+ left: Math.max(extent.left, contributor.edges.left),
125
+ }), zeroEdges());
126
+ }
127
+
128
+ /** Derives an axis-aligned ink box from a recorded border box and computed-style table without a browser. */
129
+ export function deriveInkBox(rect: InkRect, styles: InkStyles): InkResult {
130
+ const missingStyles = REQUIRED_STYLES.filter((property) => styles[property] === undefined || styles[property] === null);
131
+ if (missingStyles.length) return { inkBox: null, contributors: [], missingStyles, unresolved: [], nominal: false };
132
+ if (styles.transform !== 'none') return { inkBox: null, contributors: [], missingStyles: [], unresolved: ['transform'], nominal: false };
133
+
134
+ const contributors: InkContributor[] = [];
135
+ const unresolved: string[] = [];
136
+ const boxShadow = styles['box-shadow']!.trim();
137
+ if (boxShadow && boxShadow !== 'none') {
138
+ splitTopLevel(boxShadow, ',').forEach((shadow, index) => {
139
+ if (/\binset\b/i.test(shadow)) return;
140
+ const contributor = parseShadow(shadow, 'box-shadow', index + 1, true);
141
+ if (contributor) contributors.push(contributor);
142
+ else unresolved.push(`box-shadow ${index + 1}`);
143
+ });
144
+ }
145
+
146
+ if (styles['outline-style'] !== 'none') {
147
+ const width = px(styles['outline-width']!.trim());
148
+ const offset = px(styles['outline-offset']!.trim());
149
+ if (width === undefined || offset === undefined) unresolved.push('outline');
150
+ else {
151
+ const extent = Math.max(0, width + offset);
152
+ contributors.push({ source: 'outline', edges: { top: extent, right: extent, bottom: extent, left: extent } });
153
+ }
154
+ }
155
+
156
+ const filter = styles.filter!.trim();
157
+ if (filter && filter !== 'none') {
158
+ dropShadowArguments(filter).forEach((shadow, index) => {
159
+ const contributor = parseShadow(shadow, 'filter:drop-shadow', index + 1, false);
160
+ if (contributor) contributors.push(contributor);
161
+ else unresolved.push(`filter drop-shadow ${index + 1}`);
162
+ });
163
+ if (containsUnresolvedPaintFilter(filter)) unresolved.push('filter');
164
+ }
165
+
166
+ if (unresolved.length) return { inkBox: null, contributors, missingStyles: [], unresolved, nominal: contributors.some((contributor) => contributor.nominal) };
167
+ const extent = combineEdges(contributors);
168
+ return {
169
+ inkBox: {
170
+ x: rect.x - extent.left,
171
+ y: rect.y - extent.top,
172
+ width: rect.width + extent.left + extent.right,
173
+ height: rect.height + extent.top + extent.bottom,
174
+ },
175
+ contributors,
176
+ missingStyles: [],
177
+ unresolved: [],
178
+ nominal: contributors.some((contributor) => contributor.nominal),
179
+ };
180
+ }
@@ -253,6 +253,8 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
253
253
  const state = options.state ?? [];
254
254
  const viewport = options.viewport ?? null;
255
255
  const colorScheme = options.colorScheme;
256
+ // This narrow extension is parsed by `measure snap`; the shared SnapshotContext remains backwards-compatible for every other collector.
257
+ const stateDiffPaddingCssPx = (options as CaptureSnapshotOptions & { stateDiffPaddingCssPx?: number }).stateDiffPaddingCssPx ?? 8;
256
258
  // Apply the shared artifact-string cap once at the source. URL contents
257
259
  // otherwise survive unchanged in meta.json, the result, and collector context.
258
260
  const url = options.url ? sanitizeString(options.url) : null;
@@ -377,6 +379,7 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
377
379
  pixels,
378
380
  state,
379
381
  unstableRegions,
382
+ ...({ stateDiffPaddingCssPx } as {}),
380
383
  write: makeWriter(dir, artifacts),
381
384
  };
382
385
 
@@ -16,6 +16,16 @@ export interface CapturedRegion {
16
16
  readonly height: number;
17
17
  }
18
18
 
19
+ /** A CSS-pixel crop request. The capture is intersected with the live visual viewport before it reaches CDP. */
20
+ export interface ScreenshotCrop {
21
+ readonly x: number;
22
+ readonly y: number;
23
+ readonly width: number;
24
+ readonly height: number;
25
+ }
26
+
27
+ type ScreenshotOptions = { fullPage?: boolean; crop?: ScreenshotCrop; zoom?: number };
28
+
19
29
  /** A region is reportable only if every number is finite and its size is positive; anything else would produce a fabricated scale. */
20
30
  function validRegion(region: CapturedRegion): CapturedRegion | undefined {
21
31
  const finite = [region.x, region.y, region.width, region.height].every((n) => typeof n === 'number' && Number.isFinite(n));
@@ -23,6 +33,15 @@ function validRegion(region: CapturedRegion): CapturedRegion | undefined {
23
33
  return region;
24
34
  }
25
35
 
36
+ /** Intersects page-coordinate CSS regions without rounding their geometry. */
37
+ function intersectRegions(a: CapturedRegion, b: CapturedRegion): CapturedRegion | undefined {
38
+ const x = Math.max(a.x, b.x);
39
+ const y = Math.max(a.y, b.y);
40
+ const right = Math.min(a.x + a.width, b.x + b.width);
41
+ const bottom = Math.min(a.y + a.height, b.y + b.height);
42
+ return validRegion({ x, y, width: right - x, height: bottom - y });
43
+ }
44
+
26
45
  /**
27
46
  * Downscales a PNG so its longest side fits within maxDim, using a box
28
47
  * filter. Used when a clipped/scaled CDP capture is unavailable and the raw
@@ -82,7 +101,7 @@ function downscalePngToFit(png: Buffer, maxDim: number): Buffer {
82
101
  export async function captureScreenshot(
83
102
  client: CDPClient,
84
103
  viewport?: { width: number; height: number },
85
- options?: { fullPage?: boolean },
104
+ options?: ScreenshotOptions,
86
105
  ): Promise<Buffer> {
87
106
  const result = await withScopeSerialization(client, 'viewport', 'screenshot capture', () =>
88
107
  viewportScopedCapture(client, viewport, options),
@@ -100,7 +119,7 @@ export async function captureScreenshot(
100
119
  export async function captureScreenshotWithCssViewport(
101
120
  client: CDPClient,
102
121
  viewport?: { width: number; height: number },
103
- options?: { fullPage?: boolean },
122
+ options?: ScreenshotOptions,
104
123
  ): Promise<{ png: Buffer; cssViewport?: CapturedRegion }> {
105
124
  return withScopeSerialization(client, 'viewport', 'screenshot capture', () =>
106
125
  viewportScopedCapture(client, viewport, options, true),
@@ -117,7 +136,7 @@ export async function captureScreenshotWithCssViewport(
117
136
  async function viewportScopedCapture(
118
137
  client: CDPClient,
119
138
  viewport?: { width: number; height: number },
120
- options?: { fullPage?: boolean },
139
+ options?: ScreenshotOptions,
121
140
  includeCssViewport = false,
122
141
  ): Promise<{ png: Buffer; cssViewport?: CapturedRegion }> {
123
142
  const MAX_DIM = 1600; // headroom below Anthropic's 2000px many-image limit
@@ -185,16 +204,29 @@ async function viewportScopedCapture(
185
204
  }, 5000)) as { result: { value: number } };
186
205
  const dpr = dprResult.result.value ?? 1;
187
206
 
188
- const actualMaxSide = Math.max(vw, vh) * dpr;
189
- const scale = actualMaxSide > MAX_DIM ? MAX_DIM / actualMaxSide : 1 / dpr;
207
+ const visualViewport = validRegion({ x: sx, y: sy, width: vw, height: vh });
208
+ const requestedCrop = options?.crop;
209
+ const crop = requestedCrop && visualViewport
210
+ ? intersectRegions(requestedCrop, visualViewport)
211
+ : visualViewport;
212
+ if (!crop) {
213
+ throw new Error('The requested CSS crop does not intersect the live visual viewport.');
214
+ }
215
+ const zoom = options?.zoom ?? 1;
216
+ const actualMaxSide = Math.max(crop.width, crop.height) * dpr * zoom;
217
+ const scale = actualMaxSide > MAX_DIM ? MAX_DIM / (Math.max(crop.width, crop.height) * dpr) : zoom / dpr;
190
218
  screenshotOpts = {
191
219
  ...screenshotOpts,
192
- clip: { x: sx, y: sy, width: vw, height: vh, scale },
220
+ clip: { x: crop.x, y: crop.y, width: crop.width, height: crop.height, scale },
193
221
  };
194
- // The clip is what the image is cut from, rounding included — not the
195
- // fractional viewport it was derived from.
196
- capturedRegion = validRegion({ x: sx, y: sy, width: vw, height: vh });
197
- } catch {
222
+ // The clip is what the image is cut from, including a CSS crop's
223
+ // viewport intersection — not the fractional viewport it was derived from.
224
+ capturedRegion = crop;
225
+ } catch (error) {
226
+ // A crop without CSS viewport metrics cannot be located honestly; unlike
227
+ // an ordinary full-viewport shot, it must not silently become an
228
+ // unrelated uncropped image.
229
+ if (options?.crop) throw error;
198
230
  // Capture without downscaling when the optional metrics probe is unavailable.
199
231
  }
200
232
 
@@ -221,7 +253,7 @@ async function viewportScopedCapture(
221
253
 
222
254
  // If the browser rejects clipped capture entirely, an unclipped viewport
223
255
  // capture is still better than a false successful 0-byte artifact.
224
- if (png.length === 0 && screenshotOpts.clip && !options?.fullPage) {
256
+ if (png.length === 0 && screenshotOpts.clip && !options?.fullPage && !options?.crop) {
225
257
  const retry = (await client.send('Page.captureScreenshot', {
226
258
  format: 'png',
227
259
  captureBeyondViewport: false,
@@ -122,6 +122,30 @@ export async function findTabByIdInPorts(
122
122
  return null;
123
123
  }
124
124
 
125
+ export class AmbiguousPageTargetError extends Error {
126
+ constructor(candidates: readonly { readonly port: number; readonly tab: CDPTarget }[]) {
127
+ super(`More than one page tab is available:\n${candidates.map(({ port, tab }) => ` ${tab.id.slice(0, 8)} port ${port} ${tab.url}`).join('\n')}\nUse --target <tabId> or --url <pattern> to target a tab.`);
128
+ this.name = 'AmbiguousPageTargetError';
129
+ }
130
+ }
131
+
132
+ /** Resolves an omitted target only when all selected endpoints expose one page tab. */
133
+ export async function findUnambiguousPageTabInPorts(
134
+ ports: readonly number[],
135
+ lister: (port: number) => Promise<CDPTarget[]> = listTargets,
136
+ ): Promise<{ port: number; tab: CDPTarget } | null> {
137
+ const candidates: Array<{ port: number; tab: CDPTarget }> = [];
138
+ for (const port of ports) {
139
+ try {
140
+ for (const tab of await lister(port)) if (tab.type === 'page') candidates.push({ port, tab });
141
+ } catch {
142
+ // An unavailable endpoint cannot supply a target.
143
+ }
144
+ }
145
+ if (candidates.length > 1) throw new AmbiguousPageTargetError(candidates);
146
+ return candidates[0] ?? null;
147
+ }
148
+
125
149
  function normalizeUrlForMatch(value: string): { full: string; host: string; path: string } | null {
126
150
  try {
127
151
  const url = new URL(value);
package/src/cdp/types.ts CHANGED
@@ -78,6 +78,21 @@ export interface ParsedArgs {
78
78
  pixels?: boolean;
79
79
  /** `snap --state <state[:selector]>` — repeatable; one entry per occurrence. */
80
80
  state?: string[];
81
+ /** `snap --state-padding <px>` — CSS-px padding around the state target when
82
+ * cropping its rendered/diff artifact. */
83
+ statePadding?: number;
84
+ /** `snap --list-crops` — print every crop path instead of the count and dir. */
85
+ listCrops?: boolean;
86
+ /** `shot --crop <x,y,w,h>` — crop region in CSS px; composite grammar is
87
+ * validated by the owning leaf, which documents it. */
88
+ crop?: string;
89
+ /** `shot --crop-selector <selector>` — crop to this element's live box. */
90
+ cropSelector?: string;
91
+ /** `shot --pad <px>` — CSS-px padding added around a resolved crop rect. */
92
+ pad?: number;
93
+ /** `shot --zoom <factor>` — scale multiplier for the captured crop;
94
+ * fractional grammar is validated by the owning leaf, which documents it. */
95
+ zoom?: string;
81
96
  /** `check --for <list>` — comma-separated check names or categories. */
82
97
  for?: string;
83
98
  /** `diff --before <snap>` */
@@ -5,8 +5,6 @@
5
5
  * until the atomic U15 public cutover.
6
6
  */
7
7
  export * from './primitives.js';
8
- export * from './results.js';
9
- export * from './routes.js';
10
8
  export * from './snapshot.js';
11
9
  export * from './neutral.js';
12
10
  export * from './a11y.js';
@@ -34,6 +34,7 @@ export const RESULT_TAGS = [
34
34
  'focus-map',
35
35
  'scroll-map',
36
36
  'layer-map',
37
+ 'paint-map',
37
38
  'recording',
38
39
  'motion-mask',
39
40
  'timeline',