@crouton-kit/capture 1.3.7 → 1.3.9

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 (141) hide show
  1. package/bin/capture +32786 -9989
  2. package/package.json +9 -3
  3. package/src/capture.ts +86 -163
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -116
  6. package/src/cdp/bridge/protocol.ts +187 -1
  7. package/src/cdp/bridge/server.ts +211 -84
  8. package/src/cdp/bridge/spawn.ts +110 -1
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +35 -4
  11. package/src/cdp/commands/cdp.ts +266 -69
  12. package/src/cdp/commands/gate-guard.ts +43 -0
  13. package/src/cdp/commands/lib.ts +339 -213
  14. package/src/cdp/commands/measure/census.ts +87 -0
  15. package/src/cdp/commands/measure/check.ts +109 -0
  16. package/src/cdp/commands/measure/diff.ts +120 -0
  17. package/src/cdp/commands/measure/explain.ts +133 -0
  18. package/src/cdp/commands/measure/index.ts +106 -0
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +52 -0
  21. package/src/cdp/commands/measure/map-layers.ts +59 -0
  22. package/src/cdp/commands/measure/map-scroll.ts +94 -0
  23. package/src/cdp/commands/measure/snap.ts +416 -0
  24. package/src/cdp/commands/measure/sweep.ts +227 -0
  25. package/src/cdp/commands/motion/index.ts +61 -0
  26. package/src/cdp/commands/motion/jank.ts +132 -0
  27. package/src/cdp/commands/motion/mask.ts +92 -0
  28. package/src/cdp/commands/motion/rec.ts +534 -0
  29. package/src/cdp/commands/motion/response.ts +76 -0
  30. package/src/cdp/commands/motion/timeline.ts +152 -0
  31. package/src/cdp/commands/page/click.ts +194 -0
  32. package/src/cdp/commands/page/elements.ts +154 -0
  33. package/src/cdp/commands/page/exec.ts +317 -0
  34. package/src/cdp/commands/page/index.ts +72 -0
  35. package/src/cdp/commands/page/navigate.ts +330 -0
  36. package/src/cdp/commands/page/scroll.ts +107 -0
  37. package/src/cdp/commands/page/shot.ts +208 -0
  38. package/src/cdp/commands/page/type.ts +104 -0
  39. package/src/cdp/commands/tab/index.ts +59 -0
  40. package/src/cdp/commands/tab/list.ts +167 -0
  41. package/src/cdp/commands/tab/network.ts +162 -0
  42. package/src/cdp/commands/tab/open.ts +90 -0
  43. package/src/cdp/commands/tab/reset.ts +160 -0
  44. package/src/cdp/connection.ts +368 -37
  45. package/src/cdp/coordinates.ts +158 -0
  46. package/src/cdp/detect.ts +42 -13
  47. package/src/cdp/dispatch.ts +76 -26
  48. package/src/cdp/domains.ts +75 -0
  49. package/src/cdp/har-recorder.ts +648 -115
  50. package/src/cdp/leaf-grammar.ts +99 -0
  51. package/src/cdp/measure/census.ts +205 -0
  52. package/src/cdp/measure/check.ts +318 -0
  53. package/src/cdp/measure/collectors/animation.ts +385 -0
  54. package/src/cdp/measure/collectors/ax.ts +278 -0
  55. package/src/cdp/measure/collectors/focus.ts +1041 -0
  56. package/src/cdp/measure/collectors/forms.ts +905 -0
  57. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  58. package/src/cdp/measure/collectors/hittest.ts +835 -0
  59. package/src/cdp/measure/collectors/layers.ts +697 -0
  60. package/src/cdp/measure/collectors/media.ts +522 -0
  61. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  62. package/src/cdp/measure/collectors/queries.ts +464 -0
  63. package/src/cdp/measure/collectors/scroll.ts +599 -0
  64. package/src/cdp/measure/collectors/states.ts +992 -0
  65. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  66. package/src/cdp/measure/collectors/styles.ts +355 -0
  67. package/src/cdp/measure/collectors/text.ts +713 -0
  68. package/src/cdp/measure/diff.ts +470 -0
  69. package/src/cdp/measure/explain.ts +498 -0
  70. package/src/cdp/measure/map-ax.ts +633 -0
  71. package/src/cdp/measure/map-focus.ts +188 -0
  72. package/src/cdp/measure/map-layers.ts +177 -0
  73. package/src/cdp/measure/map-scroll.ts +317 -0
  74. package/src/cdp/measure/redaction.ts +51 -0
  75. package/src/cdp/measure/settle.ts +1051 -0
  76. package/src/cdp/measure/snapshot.ts +390 -0
  77. package/src/cdp/measure/sweep.ts +330 -0
  78. package/src/cdp/measure/types.ts +220 -0
  79. package/src/cdp/measure/v2/collectors.ts +19 -0
  80. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  81. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  82. package/src/cdp/measure/v2/target-lock.ts +15 -0
  83. package/src/cdp/measure/v2/target.ts +16 -0
  84. package/src/cdp/motion/jank.ts +433 -0
  85. package/src/cdp/motion/mask.ts +421 -0
  86. package/src/cdp/motion/recorder.ts +764 -0
  87. package/src/cdp/motion/response.ts +356 -0
  88. package/src/cdp/motion/timeline.ts +215 -0
  89. package/src/cdp/record.ts +5 -212
  90. package/src/cdp/recorder-bridge.ts +1894 -0
  91. package/src/cdp/recorder-client.ts +326 -0
  92. package/src/cdp/scope-lock.ts +73 -0
  93. package/src/cdp/screenshot.ts +199 -66
  94. package/src/cdp/source-map.ts +309 -0
  95. package/src/cdp/targets.ts +21 -1
  96. package/src/cdp/timing.ts +87 -0
  97. package/src/cdp/types.ts +87 -6
  98. package/src/cdp/viewport.ts +21 -0
  99. package/src/cdp.ts +12 -58
  100. package/src/command-help.ts +29 -0
  101. package/src/command-resolution.ts +22 -0
  102. package/src/command-walker.ts +35 -0
  103. package/src/contracts/a11y.ts +33 -0
  104. package/src/contracts/cursor.ts +35 -0
  105. package/src/contracts/index.ts +15 -0
  106. package/src/contracts/motion.ts +19 -0
  107. package/src/contracts/neutral.ts +56 -0
  108. package/src/contracts/primitives.ts +205 -0
  109. package/src/contracts/release.ts +17 -0
  110. package/src/contracts/results.ts +208 -0
  111. package/src/contracts/routes.ts +140 -0
  112. package/src/contracts/snapshot.ts +92 -0
  113. package/src/cursor/index.ts +13 -0
  114. package/src/cursor/service.ts +310 -0
  115. package/src/errors.ts +71 -0
  116. package/src/har-manager.ts +992 -41
  117. package/src/interact.ts +410 -98
  118. package/src/output/artifact-lifecycle.ts +371 -0
  119. package/src/output/artifact.ts +526 -0
  120. package/src/output/diff.ts +111 -0
  121. package/src/output/exact-raw.ts +114 -0
  122. package/src/output/render.ts +987 -0
  123. package/src/output/selector.ts +185 -0
  124. package/src/output/structured.ts +211 -0
  125. package/src/registry.ts +208 -0
  126. package/src/session/artifacts.ts +481 -0
  127. package/src/session/commands.ts +1237 -368
  128. package/src/session/coordinator.ts +251 -0
  129. package/src/session/har-filter.ts +57 -0
  130. package/src/session/log-tailer.ts +653 -0
  131. package/src/session-context.ts +302 -34
  132. package/src/vault/bundle.ts +32 -17
  133. package/src/cdp/commands/exec.ts +0 -105
  134. package/src/cdp/commands/har.ts +0 -128
  135. package/src/cdp/commands/tabs.ts +0 -183
  136. package/src/cdp/commands/traffic.ts +0 -121
  137. package/src/cdp/commands/ui.ts +0 -172
  138. package/src/cdp/execute.ts +0 -113
  139. package/src/cdp/har-output.ts +0 -47
  140. package/src/cdp/usage.ts +0 -77
  141. package/src/tab-session.ts +0 -91
@@ -0,0 +1,1584 @@
1
+ /**
2
+ * `pixels.json` collector — per-element raster crops, written ONLY when
3
+ * `--pixels` was requested (`ctx.pixels`); otherwise this is a no-op (no
4
+ * file written). Owned by U11. A `mutating`-phase collector: it forces the
5
+ * page background transparent (and restores it) for its alpha sample, so
6
+ * `snapshot.ts` runs it serialized, AFTER the baseline `screenshot.png` +
7
+ * `dom.html` are already captured.
8
+ *
9
+ * Drives its own quad-derived element geometry by reading `DOM.getContentQuads`
10
+ * directly (see `readContentQuads`) rather than reading `geometry.json`, which
11
+ * may not exist when this collector runs. Clipping always derives from real CDP
12
+ * content quads, never `getBoundingClientRect()`.
13
+ *
14
+ * Rather than one `Page.captureScreenshot` CDP round-trip per element, this
15
+ * takes exactly two full-page screenshots — one with the page's normal
16
+ * background (source of the crop pixels + color/hash facts) and one with
17
+ * `Emulation.setDefaultBackgroundColorOverride` forced fully transparent
18
+ * (source of the alpha/visible-pixel facts, since only truly-unpainted
19
+ * pixels come back transparent) — then crops both raster buffers in memory
20
+ * per element. The transparent override is always restored: on the normal
21
+ * success path after the transparent capture, and on the failure path in a
22
+ * `catch` that restores the override and still emits `pixels.json` carrying
23
+ * the `backgroundOverrideRestored` fact (with `captureFailed: true`) BEFORE
24
+ * re-throwing, so a screenshot failure never propagates with the override
25
+ * left set and the restoration outcome silently lost. The baseline
26
+ * `screenshot.png` is captured BEFORE this collector runs, so that artifact
27
+ * is already isolated from this collector's background override.
28
+ *
29
+ * Enumeration scope: this collector walks ONLY the top document's light DOM
30
+ * (`DOM.getDocument({pierce:false})` + `querySelectorAll('*')`). Iframe and
31
+ * shadow-DOM subtrees are not enumerated, so their elements are absent from
32
+ * `elements` — an absence of coverage, not a measured absence of pixels.
33
+ * This is emitted as the explicit `scope` fact on `pixels.json` so a reader
34
+ * treats those omissions as out-of-scope rather than as negative findings.
35
+ *
36
+ * Off-quad masking: an element's crop rect is the axis-aligned union of its
37
+ * content quads, but the pixels between/around rotated or disjoint quads do
38
+ * NOT belong to the element. Every derived fact (hash/color/alpha/visible)
39
+ * is computed over ONLY the pixels whose center falls inside a content quad,
40
+ * and off-mask pixels are written transparent in the crop PNG, so a rotated
41
+ * or multi-fragment element's facts are not contaminated by the background
42
+ * or neighbors sitting inside its bounding box.
43
+ *
44
+ * Ancestor-clip masking: an element's own content quads are its full layout
45
+ * box regardless of visual clipping — a child inside an `overflow:hidden` /
46
+ * `clip`/`clip-path` ancestor still reports its whole unclipped quad via
47
+ * `DOM.getContentQuads`. Before any metric or crop pixel is derived, each
48
+ * element's crop rect is intersected with its effective ANCESTOR CLIP BOUNDING
49
+ * RECT, and its per-pixel mask is further constrained by any exact ancestor
50
+ * CLIP SHAPES — both computed page-side, per element, by
51
+ * {@link computeAncestorClip} walking the live ancestor chain. Every
52
+ * clipping ancestor (`overflow-x`/`overflow-y` clipping, or a `clip-path`)
53
+ * contributes to a running bounding-rect intersection; in addition:
54
+ * - `clip-path: inset(...)` resolves to an exact rect (folded into the
55
+ * bounding rect directly — no separate shape needed).
56
+ * - `clip-path: circle(...)` / `ellipse(...)` / `polygon(...)` resolve to
57
+ * an exact shape (reference box, %/px/keyword radii,
58
+ * `closest-side`/`farthest-side`, polygon vertices — nonzero/evenodd
59
+ * fill rule) that every candidate pixel is tested against in
60
+ * {@link pointInClipShape}, in addition to the bounding-rect
61
+ * intersection (which uses the shape's own bbox as a fast pre-filter).
62
+ * - Every length/percentage token fed into the shape math above — `inset()`
63
+ * edges, `circle()`/`ellipse()` radii and center positions, `polygon()`
64
+ * vertices — is resolved through a single `calc()`-aware evaluator
65
+ * (`resolveLength`/`evalCalcExpr`). Computed style can preserve CSS
66
+ * four-value edge-offset positions (e.g. `right 10px bottom 10px`) or
67
+ * serialize them as two `calc()` values; both exact forms are parsed.
68
+ * - `clip-path: path(...)` / `url(...)` (any other clip-path form this
69
+ * collector doesn't recognize), OR a recognized `inset()`/`circle()`/
70
+ * `ellipse()`/`polygon()` whose arguments contain a token the evaluator
71
+ * genuinely cannot resolve (e.g. `var()`, viewport units, or any other
72
+ * `calc()` operand outside the plain px/%/number grammar), CANNOT be
73
+ * resolved to exact per-pixel geometry. Rather than silently treating
74
+ * the ancestor's full bounding box as the clip, or silently defaulting
75
+ * an unresolvable token to a guessed value (contaminating every derived
76
+ * fact with wrong or unclipped pixels — the bug this masking scheme
77
+ * replaces), every such case is handled explicitly and honestly through
78
+ * the SAME single fallback: the ancestor's own bounding rect is still
79
+ * used as a conservative bound, but `approximate: true` is threaded
80
+ * through to the element's `ancestorClipApproximate` fact, and
81
+ * `ancestorClipped` is forced `true` regardless of whether the bounding
82
+ * rect actually shrank — so a reader is told the mask for that element
83
+ * is a best-effort bound, never mistaken for an exact one. There is no
84
+ * second/lenient parser: a token either resolves exactly through this
85
+ * one evaluator, or the whole ancestor clip is marked approximate.
86
+ *
87
+ * This all runs BEFORE `clampRectToPixels` and `buildQuadMask`, so pixels
88
+ * outside the visible clip never enter the crop, the mask, or any derived
89
+ * fact — they are excluded, not merely masked transparent after the fact.
90
+ * `ancestorClipped` states whether any ancestor clip constrains this
91
+ * element at all (bounding-rect shrink, an exact shape, OR an approximate
92
+ * clip) — note a circle/ellipse/polygon inscribed entirely within its own
93
+ * ancestor's bounding box leaves the bounding rect UNCHANGED even though it
94
+ * removes real pixels, so `ancestorClipped` must NOT be inferred from rect
95
+ * shrinkage alone.
96
+ */
97
+
98
+ import { PNG } from 'pngjs';
99
+
100
+ import type { CDPClient } from '../../client.js';
101
+ import { axisAlignedRectFromQuad, type Rect, type Quad } from '../../coordinates.js';
102
+ import { sanitizeString, sanitizeFilenameSlug } from '../redaction.js';
103
+ import type { Collector } from '../types.js';
104
+
105
+ /**
106
+ * Defensive cap on how many elements this collector will crop for a single
107
+ * snapshot. Never silent (I-5): `pixels.json` always carries `elementsTotal`
108
+ * (the full enumerated count, before slicing — already known for free) and
109
+ * `elementsTruncated` (`true` when the cap actually dropped elements).
110
+ * Separately, `elementsSkipped` counts enumerated-but-uncroppable elements
111
+ * dropped by the 4 per-element `continue` sites below (no layout box, a
112
+ * fully ancestor-clipped rect, an off-viewport pixel rect, or a fully
113
+ * off-mask element) — a distinct concern from the enumeration cap. And
114
+ * separately again, `elementsReadFailed` counts a DIFFERENT thing from both:
115
+ * a per-element `DOM.getContentQuads` read that genuinely THREW (a real CDP
116
+ * protocol failure), which is never folded into `elementsSkipped` — a real
117
+ * no-layout-box element resolves that same call cleanly with an honest empty
118
+ * array (see {@link readContentQuads}), so a throw can only mean the read
119
+ * itself failed, and coercing it into the same silent skip a genuinely
120
+ * uncroppable element gets would hide a real failure as "nothing to crop
121
+ * here" (I-5).
122
+ */
123
+ const MAX_ELEMENTS = 2000;
124
+
125
+ /**
126
+ * Factual enumeration-scope descriptor emitted on `pixels.json`. States, in
127
+ * measurement-only terms, exactly what this collector's element walk did and
128
+ * did not cover: the top document's light DOM only, no iframe content and no
129
+ * shadow-DOM subtrees (`DOM.getDocument({pierce:false})`). Absent iframe /
130
+ * shadow-DOM elements are therefore out-of-scope, not measured-absent.
131
+ */
132
+ const PIXELS_SCOPE = {
133
+ enumeration: 'top-document-light-dom',
134
+ pierce: false,
135
+ includesIframeContent: false,
136
+ includesShadowDom: false,
137
+ } as const;
138
+
139
+ /** Fixed, factual reason the page-side viewport CSS-size read could not be used as the pixel-scale basis — present only when {@link ViewportScaleFact.available} is `false`. */
140
+ type ViewportScaleUnavailableReason = 'viewport-read-unavailable' | 'capture-failed';
141
+
142
+ /**
143
+ * The scale basis (`scaleX`/`scaleY` = screenshot px / CSS px) used to turn
144
+ * every element's CSS-pixel rect into the crop/mask pixel geometry that
145
+ * every color/hash/alpha fact in `elements` is derived from. Normally
146
+ * `scaleX`/`scaleY` come from a genuine `window.innerWidth`/`innerHeight`
147
+ * page-side read (`available: true`). When that read resolves without a
148
+ * usable value (missing/zero/non-numeric — see `readViewportCssSize`), the
149
+ * screenshot's own pixel dimensions are used as a same-size stand-in so the
150
+ * collector can still produce crops — but that stand-in is only a correct
151
+ * scale basis when `devicePixelRatio` happens to be 1. Silently presenting
152
+ * it as a resolved CSS size would fabricate an exact-looking scale for
153
+ * every downstream measurement (I-4) with no signal that it never happened
154
+ * (I-5); `available: false` + `unavailableReason` is that signal, always
155
+ * present alongside the (now explicitly approximate) `scaleX`/`scaleY` a
156
+ * reader would otherwise trust as exact.
157
+ */
158
+ interface ViewportScaleFact {
159
+ readonly available: boolean;
160
+ readonly innerWidth: number;
161
+ readonly innerHeight: number;
162
+ readonly scaleX: number;
163
+ readonly scaleY: number;
164
+ /** Present only when `available` is `false`. */
165
+ readonly unavailableReason?: ViewportScaleUnavailableReason;
166
+ }
167
+
168
+ /** Perceptual-hash grid size (8x8 -> 64-bit average hash). */
169
+ const HASH_GRID = 8;
170
+ /** Color-quantization bucket width (0-255) used to find the dominant color. */
171
+ const DOMINANT_BUCKET = 16;
172
+
173
+ interface RGBColor {
174
+ readonly r: number;
175
+ readonly g: number;
176
+ readonly b: number;
177
+ }
178
+
179
+ /** A raw, already-cropped RGBA pixel buffer (row-major, 4 bytes/pixel). */
180
+ interface RawImage {
181
+ readonly width: number;
182
+ readonly height: number;
183
+ readonly data: Buffer;
184
+ }
185
+
186
+ interface PixelRect {
187
+ readonly x: number;
188
+ readonly y: number;
189
+ readonly width: number;
190
+ readonly height: number;
191
+ }
192
+
193
+ /**
194
+ * A per-pixel inclusion mask over a crop, in the crop's own row-major pixel
195
+ * order (`data[row * width + col]`): `1` where the pixel center lies inside
196
+ * one of the element's content quads, `0` where it does not. `count` is the
197
+ * number of on-mask pixels — the denominator for every masked metric.
198
+ */
199
+ interface Mask {
200
+ readonly data: Uint8Array;
201
+ readonly count: number;
202
+ }
203
+
204
+ interface PixelElementRecord {
205
+ readonly id: string;
206
+ /** `null` (never an omitted key) when this element's identity did not resolve — see {@link identityUnresolved}. Mirrors `hittest.ts`'s `HitTestElementSample.backendNodeId` shape (I-3). */
207
+ readonly backendNodeId: number | null;
208
+ /** `true` when {@link backendNodeId} is `null` because `DOM.describeNode` failed/omitted it for this element. Absent (not `false`) when identity resolved. */
209
+ readonly identityUnresolved?: boolean;
210
+ readonly selector?: string;
211
+ /** Effective crop rect: the axis-aligned union of the content quads, intersected with the element's ancestor clip rect (if any) — in top-viewport CSS-pixel space (same space as `geometry.json` rects). Equal to the unclipped quad union when no ancestor clips this element. */
212
+ readonly rect: Rect;
213
+ /** `true` when any ancestor `overflow`/`clip-path` clip constrains this element at all — a bounding-rect shrink, an exact circle/ellipse/polygon shape (which can clip real pixels without changing the bounding rect), or an approximate (`path()`/`url()`) clip. See the module doc for why this is not just "did the rect shrink". */
214
+ readonly ancestorClipped: boolean;
215
+ /** `true` when an ancestor's `clip-path` uses a shape this collector cannot resolve to exact per-pixel geometry (`path()`, `url()`, or any other unrecognized form) — the emitted rect/mask are only a conservative bounding-box approximation for that ancestor, not an exact clip. Always `false` when `ancestorClipped` is `false`. */
216
+ readonly ancestorClipApproximate: boolean;
217
+ /**
218
+ * `true` when the per-element ancestor-clip walk (`DOM.resolveNode` +
219
+ * `Runtime.callFunctionOn`) genuinely failed to run for this element —
220
+ * see {@link AncestorClipInfo.unavailable}. When set, `ancestorClipped`/
221
+ * `ancestorClipApproximate` above read `false` ONLY because the walk
222
+ * never produced a result, not because ancestors were checked and found
223
+ * not to clip — a reader must not treat this element as provably
224
+ * unclipped. Absent (not `false`) when the walk ran.
225
+ */
226
+ readonly ancestorClipUnavailable?: true;
227
+ /** Present only when {@link ancestorClipUnavailable} is `true`. */
228
+ readonly ancestorClipUnavailableReason?: AncestorClipUnavailableReason;
229
+ /** Id-relative crop path: `{snapId}/crops/<file>.png` — same grammar as `check`'s `{snapId}/findings/<file>.png`. */
230
+ readonly crop: string;
231
+ /** Fraction (0-1) of the crop's bounding-box pixels that actually fall inside a content quad (the rest are masked out of every fact below and written transparent). */
232
+ readonly maskedPixelFraction: number;
233
+ /** 64-bit average-hash (aHash) of the on-mask crop pixels, as 16 hex chars — a stable perceptual hash. */
234
+ readonly hash: string;
235
+ readonly avgColor: RGBColor;
236
+ readonly medianColor: RGBColor;
237
+ readonly dominantColor: RGBColor;
238
+ /** Mean alpha (0-1) over the on-mask pixels, sampled with the page background forced transparent. */
239
+ readonly alphaFraction: number;
240
+ /** Fraction (0-1) of on-mask pixels with any paint at all (alpha > 0), same transparent sample. */
241
+ readonly visiblePixelFraction: number;
242
+ }
243
+
244
+ export const collectPixels: Collector = async (ctx) => {
245
+ if (!ctx.pixels) return;
246
+
247
+ const { client } = ctx;
248
+
249
+ const normalPng = await captureFullPagePng(client);
250
+
251
+ await client.send('Emulation.setDefaultBackgroundColorOverride', { color: { r: 0, g: 0, b: 0, a: 0 } });
252
+ let backgroundOverrideRestored = false;
253
+ const restoreBackgroundOverride = async (): Promise<void> => {
254
+ try {
255
+ await client.send('Emulation.setDefaultBackgroundColorOverride', {});
256
+ backgroundOverrideRestored = true;
257
+ } catch {
258
+ backgroundOverrideRestored = false;
259
+ }
260
+ };
261
+
262
+ let transparentPng: PNG;
263
+ try {
264
+ transparentPng = await captureFullPagePng(client);
265
+ } catch (err) {
266
+ // The transparent screenshot (or its decode) failed. Restore the
267
+ // override regardless, then emit `pixels.json` carrying the restoration
268
+ // fact BEFORE re-throwing — otherwise the failure would propagate with
269
+ // no artifact and the restoration outcome would be silently lost.
270
+ // `captureFailed` records that no per-element crops were produced.
271
+ await restoreBackgroundOverride();
272
+ ctx.write.json('pixels.json', {
273
+ scope: PIXELS_SCOPE,
274
+ elements: [],
275
+ backgroundOverrideRestored,
276
+ captureFailed: true,
277
+ elementsTotal: 0,
278
+ elementsTruncated: false,
279
+ elementsSkipped: 0,
280
+ elementsReadFailed: 0,
281
+ // The capture failed before the viewport-scale read was even
282
+ // attempted — there is no scale basis to report, honest or otherwise.
283
+ viewportScale: { available: false, innerWidth: 0, innerHeight: 0, scaleX: 1, scaleY: 1, unavailableReason: 'capture-failed' },
284
+ });
285
+ throw err;
286
+ }
287
+ await restoreBackgroundOverride();
288
+
289
+ const viewportRead = await readViewportCssSize(client, normalPng.width, normalPng.height);
290
+ const { innerWidth, innerHeight } = viewportRead;
291
+ const scaleX = innerWidth > 0 ? normalPng.width / innerWidth : 1;
292
+ const scaleY = innerHeight > 0 ? normalPng.height / innerHeight : 1;
293
+ // Surfaces the scale basis honestly (I-4/I-5): when the page-side read
294
+ // returned no usable value, `scaleX`/`scaleY` above are only a same-size
295
+ // (devicePixelRatio-1) approximation — `available: false` tells a reader
296
+ // every crop-geometry/color fact below rests on that approximation rather
297
+ // than a genuine measured CSS viewport size.
298
+ const viewportScale: ViewportScaleFact = {
299
+ available: viewportRead.available,
300
+ innerWidth,
301
+ innerHeight,
302
+ scaleX: round3(scaleX),
303
+ scaleY: round3(scaleY),
304
+ ...(viewportRead.available ? {} : { unavailableReason: 'viewport-read-unavailable' as const }),
305
+ };
306
+
307
+ const allNodeIds = await enumerateElementNodeIds(client);
308
+ const nodeIds = allNodeIds.slice(0, MAX_ELEMENTS);
309
+ const elementsTotal = allNodeIds.length;
310
+ const elementsTruncated = elementsTotal > nodeIds.length;
311
+ const described = await Promise.all(nodeIds.map((nodeId) => describeElementForCrop(client, nodeId)));
312
+
313
+ const elements: PixelElementRecord[] = [];
314
+ let index = 0;
315
+ let elementsSkipped = 0;
316
+ let elementsReadFailed = 0;
317
+
318
+ for (const outcome of described) {
319
+ if (outcome.kind === 'read-failed') {
320
+ elementsReadFailed += 1;
321
+ continue;
322
+ }
323
+ if (outcome.kind === 'uncroppable') {
324
+ elementsSkipped += 1;
325
+ continue;
326
+ }
327
+ const entry = outcome.element;
328
+ const clipRect = entry.clipInfo.rect;
329
+ const effectiveRect = clipRect ? intersectRect(entry.rect, clipRect) : entry.rect;
330
+ if (effectiveRect.width <= 0 || effectiveRect.height <= 0) {
331
+ elementsSkipped += 1;
332
+ continue;
333
+ }
334
+ const rectShrunk =
335
+ clipRect !== null &&
336
+ (effectiveRect.x !== entry.rect.x ||
337
+ effectiveRect.y !== entry.rect.y ||
338
+ effectiveRect.width !== entry.rect.width ||
339
+ effectiveRect.height !== entry.rect.height);
340
+ // A circle/ellipse/polygon shape (or an approximate path()/url() clip)
341
+ // can remove real pixels without ever shrinking the bounding rect (e.g.
342
+ // a circle inscribed entirely within its own ancestor's box) — so
343
+ // `ancestorClipped` must also fire on those, not just on rect shrinkage.
344
+ const ancestorClipped = rectShrunk || entry.clipInfo.shapes.length > 0 || entry.clipInfo.approximate;
345
+
346
+ const pixelRect = clampRectToPixels(effectiveRect, scaleX, scaleY, normalPng.width, normalPng.height);
347
+ if (!pixelRect) {
348
+ elementsSkipped += 1;
349
+ continue;
350
+ }
351
+
352
+ const mask = buildQuadMask(pixelRect, entry.quads, scaleX, scaleY, entry.clipInfo.shapes);
353
+ if (mask.count === 0) {
354
+ elementsSkipped += 1;
355
+ continue;
356
+ }
357
+
358
+ const normalCrop = cropPixels(normalPng, pixelRect);
359
+ const transparentCrop = cropPixels(transparentPng, pixelRect);
360
+
361
+ const cropFilename = `crops/${cropFileBase(index, entry)}.png`;
362
+ ctx.write.binary(cropFilename, encodePng(applyMaskTransparent(normalCrop, mask)));
363
+
364
+ elements.push({
365
+ id: `px-${index}`,
366
+ backendNodeId: entry.backendNodeId,
367
+ ...(entry.identityUnresolved ? { identityUnresolved: true as const } : {}),
368
+ ...(entry.selector !== undefined ? { selector: entry.selector } : {}),
369
+ rect: effectiveRect,
370
+ ancestorClipped,
371
+ ancestorClipApproximate: entry.clipInfo.approximate,
372
+ ...(entry.clipInfo.unavailable
373
+ ? { ancestorClipUnavailable: true as const, ancestorClipUnavailableReason: entry.clipInfo.unavailableReason }
374
+ : {}),
375
+ crop: `${ctx.snapId}/${cropFilename}`,
376
+ maskedPixelFraction: round3(mask.count / (pixelRect.width * pixelRect.height)),
377
+ hash: averageHash(normalCrop, mask),
378
+ avgColor: averageColor(normalCrop, mask),
379
+ medianColor: medianColor(normalCrop, mask),
380
+ dominantColor: dominantColor(normalCrop, mask),
381
+ alphaFraction: alphaFraction(transparentCrop, mask),
382
+ visiblePixelFraction: visiblePixelFraction(transparentCrop, mask),
383
+ });
384
+ index += 1;
385
+ }
386
+
387
+ ctx.write.json('pixels.json', {
388
+ scope: PIXELS_SCOPE,
389
+ elements,
390
+ backgroundOverrideRestored,
391
+ captureFailed: false,
392
+ elementsTotal,
393
+ elementsTruncated,
394
+ elementsSkipped,
395
+ elementsReadFailed,
396
+ viewportScale,
397
+ });
398
+ };
399
+
400
+ // ============================================================================
401
+ // CDP-facing helpers
402
+ // ============================================================================
403
+
404
+ async function captureFullPagePng(client: CDPClient): Promise<PNG> {
405
+ const shot = (await client.send('Page.captureScreenshot', { format: 'png' })) as { data: string };
406
+ return PNG.sync.read(Buffer.from(shot.data, 'base64'));
407
+ }
408
+
409
+ async function readViewportCssSize(
410
+ client: CDPClient,
411
+ fallbackWidth: number,
412
+ fallbackHeight: number,
413
+ ): Promise<{ innerWidth: number; innerHeight: number; available: boolean }> {
414
+ const response = (await client.send('Runtime.evaluate', {
415
+ expression: '({w: window.innerWidth, h: window.innerHeight})',
416
+ returnByValue: true,
417
+ })) as { result?: { value?: { w?: number; h?: number } } };
418
+ const w = response.result?.value?.w;
419
+ const h = response.result?.value?.h;
420
+ if (typeof w === 'number' && w > 0 && typeof h === 'number' && h > 0) {
421
+ return { innerWidth: w, innerHeight: h, available: true };
422
+ }
423
+ // The page-side read resolved but returned no usable value (missing,
424
+ // zero, or non-numeric). The screenshot's own pixel dimensions are
425
+ // returned as a same-size stand-in so the caller can still produce crops,
426
+ // but `available: false` tells the caller this is NOT a measured CSS
427
+ // size — it must not be presented as an exact scale basis (I-4/I-5).
428
+ return { innerWidth: fallbackWidth, innerHeight: fallbackHeight, available: false };
429
+ }
430
+
431
+ async function enumerateElementNodeIds(client: CDPClient): Promise<number[]> {
432
+ const doc = (await client.send('DOM.getDocument', { depth: -1, pierce: false })) as { root: { nodeId: number } };
433
+ const result = (await client.send('DOM.querySelectorAll', {
434
+ nodeId: doc.root.nodeId,
435
+ selector: '*',
436
+ })) as { nodeIds: number[] };
437
+ return result.nodeIds;
438
+ }
439
+
440
+ interface DescribedElement {
441
+ /** Axis-aligned union of the content quads, in top-viewport CSS-pixel space (unclipped by ancestors). */
442
+ readonly rect: Rect;
443
+ /** The original content quads (preserved, not collapsed to `rect`), used to mask off-quad pixels. */
444
+ readonly quads: readonly Quad[];
445
+ readonly selector?: string;
446
+ readonly tag?: string;
447
+ /** `null` (never an omitted key) when `DOM.describeNode` failed to resolve this element's identity — see {@link identityUnresolved}. */
448
+ readonly backendNodeId: number | null;
449
+ /** `true` when {@link backendNodeId} is `null` because `DOM.describeNode` threw or omitted `backendNodeId` from its response. Absent (not `false`) when identity resolved. */
450
+ readonly identityUnresolved?: boolean;
451
+ /** Ancestor-clip info (viewport CSS-pixel space): bounding-rect intersection, exact shapes, and the honest approximate flag. See {@link computeAncestorClip}. */
452
+ readonly clipInfo: AncestorClipInfo;
453
+ }
454
+
455
+ /** A circle resolved from `clip-path: circle(...)`, in absolute viewport CSS-pixel space. */
456
+ interface ClipShapeCircle {
457
+ readonly type: 'circle';
458
+ readonly cx: number;
459
+ readonly cy: number;
460
+ readonly r: number;
461
+ }
462
+
463
+ /** An ellipse resolved from `clip-path: ellipse(...)`, in absolute viewport CSS-pixel space. */
464
+ interface ClipShapeEllipse {
465
+ readonly type: 'ellipse';
466
+ readonly cx: number;
467
+ readonly cy: number;
468
+ readonly rx: number;
469
+ readonly ry: number;
470
+ }
471
+
472
+ /** A polygon resolved from `clip-path: polygon(...)`: a flat `[x1,y1,x2,y2,...]` vertex list in absolute viewport CSS-pixel space, plus its fill rule. */
473
+ interface ClipShapePolygon {
474
+ readonly type: 'polygon';
475
+ readonly points: readonly number[];
476
+ readonly fillRule: 'nonzero' | 'evenodd';
477
+ }
478
+
479
+ type ClipShape = ClipShapeCircle | ClipShapeEllipse | ClipShapePolygon;
480
+
481
+ /** Fixed, factual reason the per-element ancestor-clip walk (`DOM.resolveNode` + `Runtime.callFunctionOn`) genuinely failed to run — present only when {@link AncestorClipInfo.unavailable} is `true`. */
482
+ type AncestorClipUnavailableReason = 'resolve-node-no-object-id' | 'call-function-no-value' | 'resolve-or-call-threw';
483
+
484
+ /**
485
+ * Everything {@link computeAncestorClip}'s page-side ancestor walk resolves
486
+ * for one element:
487
+ * - `rect`: the running intersection of every clipping ancestor's own
488
+ * bounding rect (`overflow` clips, exact `inset()` rects, and — as a
489
+ * conservative pre-filter, never a substitute for the shape test below —
490
+ * each circle/ellipse/polygon's own bbox and each approximate clip's
491
+ * ancestor box). `null` when no ancestor clips this element at all.
492
+ * - `shapes`: exact circle/ellipse/polygon constraints a candidate pixel
493
+ * must ALSO satisfy (in addition to `rect` and the element's own quads).
494
+ * Multiple ancestors each contribute their own shape; a pixel must fall
495
+ * inside every one (nested clips intersect).
496
+ * - `approximate`: `true` if any ancestor's `clip-path` could not be
497
+ * resolved to exact geometry (`path()`, `url()`, or any other
498
+ * unrecognized clip-path form) — that ancestor's contribution to `rect`
499
+ * is only a conservative bound, never exact.
500
+ * - `unavailable`/`unavailableReason`: `true` when {@link computeAncestorClip}
501
+ * could NOT complete the walk at all (`DOM.resolveNode` resolved without
502
+ * an `objectId`, `Runtime.callFunctionOn` resolved without `result.value`,
503
+ * or either call threw) — distinct from a genuine "no ancestor clips this
504
+ * element" result, which reaches this SAME `{rect:null,shapes:[],
505
+ * approximate:false}` shape through a walk that actually ran and found
506
+ * nothing. Absent (not `false`) when the walk ran (successfully or not).
507
+ */
508
+ interface AncestorClipInfo {
509
+ readonly rect: Rect | null;
510
+ readonly shapes: readonly ClipShape[];
511
+ readonly approximate: boolean;
512
+ readonly unavailable?: true;
513
+ /** Present only when {@link unavailable} is `true`. */
514
+ readonly unavailableReason?: AncestorClipUnavailableReason;
515
+ }
516
+
517
+ /**
518
+ * Fixed, factual reason {@link describeElementForCrop} could not produce a
519
+ * croppable element — the discriminant every caller must branch on instead
520
+ * of collapsing both cases into the same `null`/skip:
521
+ * - `'described'`: the element has a real layout box and identity/clip
522
+ * resolution ran (possibly with their own honest per-field failure
523
+ * markers — see {@link DescribedElement}).
524
+ * - `'uncroppable'`: the per-element `DOM.getContentQuads` read
525
+ * genuinely SUCCEEDED and reported nothing to crop — a real
526
+ * no-layout-box node (`display:none`, `<head>`/`<script>`/`<style>`, a
527
+ * detached node) resolves that call cleanly with an honest empty quad
528
+ * array (see {@link readContentQuads}), or the quads it did return
529
+ * union to a zero-area rect. This is a genuine observation, not a
530
+ * failure — no marker is warranted.
531
+ * - `'read-failed'`: the `DOM.getContentQuads` read itself THREW — a real
532
+ * CDP protocol failure, never the shape a genuinely boxless element
533
+ * produces. Coercing this into the same silent skip as `'uncroppable'`
534
+ * would hide a real read failure as "nothing to crop here" (I-5); the
535
+ * caller counts this into the distinct `elementsReadFailed` fact.
536
+ */
537
+ type DescribeOutcome =
538
+ | { readonly kind: 'described'; readonly element: DescribedElement }
539
+ | { readonly kind: 'uncroppable' }
540
+ | { readonly kind: 'read-failed' };
541
+
542
+ /** Same 8-number validation as `../../coordinates.js`'s private `asQuad` — reimplemented locally because this file needs `DOM.getContentQuads`'s raw quads read independently of `DOM.getBoxModel` (see {@link readContentQuads}'s doc comment for why, and note `../../coordinates.js`'s combined `getContentQuadBox` is deliberately NOT used here). */
543
+ function quadFromPoints(points: number[]): Quad {
544
+ if (points.length !== 8) {
545
+ throw new Error(`Expected an 8-number quad (x1,y1,x2,y2,x3,y3,x4,y4), got ${points.length} numbers`);
546
+ }
547
+ return points as Quad;
548
+ }
549
+
550
+ /**
551
+ * Reads ONLY `DOM.getContentQuads` for one node — deliberately NOT the
552
+ * combined `getContentQuadBox` from `../../coordinates.js` (whose
553
+ * `DOM.getBoxModel` half this collector never uses anyway: only the quads
554
+ * feed `rect`/masking here). Empirically confirmed against real Chrome
555
+ * (the identical finding `geometry.ts`'s `readContentQuads` documents): a
556
+ * genuinely boxless node (`display:none`, `<head>`/`<script>`/`<style>`, a
557
+ * detached node) makes `DOM.getContentQuads` resolve normally with a real,
558
+ * honest EMPTY array — never throw — while `DOM.getBoxModel` throws
559
+ * "Could not compute box model" for that SAME node. Had this collector kept
560
+ * reading both together (the pre-fix behavior), every genuinely boxless
561
+ * element would throw via the `getBoxModel` half and be indistinguishable
562
+ * from an actual quads-read failure. Calling `DOM.getContentQuads` alone
563
+ * means a throw here can only mean the quad read itself genuinely failed
564
+ * (invalid/detached node reference, a protocol error) — never that the
565
+ * element has no layout box, which always surfaces as a clean empty array
566
+ * instead. Callers must let a throw here propagate as a genuine per-element
567
+ * read failure (`DescribeOutcome.kind === 'read-failed'`, I-4/I-5), not
568
+ * degrade it to the same shape a real empty result produces.
569
+ */
570
+ async function readContentQuads(client: CDPClient, nodeId: number): Promise<Quad[]> {
571
+ const res = (await client.send('DOM.getContentQuads', { nodeId })) as { quads: number[][] };
572
+ return res.quads.map(quadFromPoints);
573
+ }
574
+
575
+ /**
576
+ * Fetches one node's content quads, a best-effort selector, and identity
577
+ * (`backendNodeId`). Returns a {@link DescribeOutcome} distinguishing three
578
+ * cases (see its own doc comment): a real described element, a genuinely
579
+ * uncroppable one (no layout box / zero-area union — an honest observation,
580
+ * silently skipped by the caller), or a genuine `DOM.getContentQuads` read
581
+ * failure (marked, never silently skipped). Identity resolution never
582
+ * drops the element on its own failure: a `DOM.describeNode`
583
+ * failure/omission just leaves `backendNodeId: null` + `identityUnresolved:
584
+ * true` on the returned record instead of silently omitting the field
585
+ * (I-3).
586
+ */
587
+ async function describeElementForCrop(client: CDPClient, nodeId: number): Promise<DescribeOutcome> {
588
+ let quads: Quad[];
589
+ try {
590
+ quads = await readContentQuads(client, nodeId);
591
+ } catch {
592
+ return { kind: 'read-failed' };
593
+ }
594
+ if (quads.length === 0) return { kind: 'uncroppable' };
595
+
596
+ const rect = unionRect(quads.map(axisAlignedRectFromQuad));
597
+ if (rect.width <= 0 || rect.height <= 0) return { kind: 'uncroppable' };
598
+
599
+ let selector: string | undefined;
600
+ let tag: string | undefined;
601
+ let backendNodeId: number | null = null;
602
+ let identityUnresolved = false;
603
+ try {
604
+ const described = (await client.send('DOM.describeNode', { nodeId })) as {
605
+ node?: { nodeName?: string; backendNodeId?: number; attributes?: string[] };
606
+ };
607
+ if (typeof described.node?.backendNodeId === 'number') {
608
+ backendNodeId = described.node.backendNodeId;
609
+ } else {
610
+ // `DOM.describeNode` resolved but the response omitted `backendNodeId` —
611
+ // identity did not resolve even though nothing threw. Never let this
612
+ // fall through as a silently-omitted field (I-3): mark it explicitly.
613
+ identityUnresolved = true;
614
+ }
615
+ tag = described.node?.nodeName?.toLowerCase();
616
+ selector = buildSelector(described.node?.nodeName, described.node?.attributes);
617
+ } catch {
618
+ // `DOM.describeNode` failure means identity could not be resolved for
619
+ // this element. The rect/crop are still valid without a selector, but
620
+ // `backendNodeId` stays `null` (never omitted) and `identityUnresolved`
621
+ // is set so a downstream join never mistakes this for a resolved record
622
+ // (I-3, mirroring hittest.ts's `identityUnresolved` shape).
623
+ identityUnresolved = true;
624
+ }
625
+
626
+ const clipInfo = await computeAncestorClip(client, nodeId);
627
+
628
+ return { kind: 'described', element: { rect, quads, selector, tag, backendNodeId, identityUnresolved, clipInfo } };
629
+ }
630
+
631
+ /**
632
+ * Page-side JS run per element via `Runtime.callFunctionOn` (`this` bound to
633
+ * the target node): walks the live ancestor chain (starting at the parent —
634
+ * an element's own `overflow`/`clip-path` clips its CHILDREN, never itself)
635
+ * and, for every ancestor that clips (`overflow-x`/`overflow-y` matching
636
+ * hidden/auto/scroll/clip, or a `clip-path` other than `none`), intersects a
637
+ * running bounding-rect clip with that ancestor's own `getBoundingClientRect()`
638
+ * AND, for `clip-path`, resolves the shape itself where possible:
639
+ * - `inset(...)` resolves to an exact rect (folded straight into the
640
+ * bounding-rect intersection).
641
+ * - `circle(...)` / `ellipse(...)` / `polygon(...)` resolve to an exact
642
+ * shape (pushed onto `shapes`, in absolute viewport-px terms) — percentage
643
+ * radii, `closest-side`/`farthest-side` keywords, the CSS `<position>`
644
+ * syntax for `at <position>`, polygon vertex lists (with `nonzero`/
645
+ * `evenodd` fill rule), and the `<geometry-box>` keyword (border-box
646
+ * default; padding-box/content-box resolved via computed border/padding;
647
+ * margin-box via computed margin; fill-box/stroke-box/view-box — SVG
648
+ * reference boxes with no meaningful DOM analog here — fall back to
649
+ * border-box, a documented simplification, not the `approximate` escape
650
+ * hatch below since it's still an exact shape test on a nearby box). The
651
+ * shape's own bbox also feeds the running bounding-rect intersection, as
652
+ * a fast pre-filter only — real exclusion happens per-pixel later via
653
+ * {@link pointInClipShape}.
654
+ * - Anything else (`path()`, `url()`, or an unrecognized clip-path form)
655
+ * cannot be resolved to exact geometry at all. This is handled
656
+ * explicitly rather than silently treated as unclipped: `approximate` is
657
+ * set `true`, and the ancestor's own bounding rect is folded into the
658
+ * running bounding-rect intersection as a conservative (non-exact, but
659
+ * never under-clipping in the common "shape sits within its box" case)
660
+ * bound — the caller uses `approximate` to flag this honestly rather
661
+ * than presenting it as an exact mask.
662
+ * Returns `{ rect, shapes, approximate }` — `rect` is `null` when no ancestor
663
+ * clips at all, else `{x,y,width,height}` in the same viewport CSS-pixel
664
+ * space as `DOM.getContentQuads`; `shapes` accumulates every resolved
665
+ * circle/ellipse/polygon across all ancestors (a pixel must satisfy all of
666
+ * them — nested clips intersect).
667
+ */
668
+ const ANCESTOR_CLIP_FUNCTION = `function () {
669
+ // Splits a CSS value list on whitespace, but ONLY at paren-depth 0 — so a
670
+ // \`calc(100% - 10px)\` argument (which contains internal spaces) survives
671
+ // as one token instead of being torn apart by a naive \\s+ split. Every
672
+ // multi-token value this file parses (a <position>'s two axes, an
673
+ // ellipse's two radii, a polygon vertex's x/y pair) goes through this.
674
+ function splitTopLevel(str) {
675
+ var tokens = [];
676
+ var current = '';
677
+ var depth = 0;
678
+ for (var i = 0; i < str.length; i += 1) {
679
+ var ch = str[i];
680
+ if (ch === '(') depth += 1;
681
+ if (ch === ')') depth -= 1;
682
+ if (/\\s/.test(ch) && depth === 0) {
683
+ if (current) tokens.push(current);
684
+ current = '';
685
+ } else {
686
+ current += ch;
687
+ }
688
+ }
689
+ if (current) tokens.push(current);
690
+ return tokens;
691
+ }
692
+
693
+ // Resolves a single <length-percentage> token — \`10px\`, \`50%\`, a bare
694
+ // number, or a \`calc(...)\` expression of those — against \`refPx\` (the
695
+ // percentage basis). Returns \`null\` when the token is not one of these
696
+ // forms (e.g. \`var(...)\`, an unsupported calc() operand) — the SOLE
697
+ // signal every caller uses to mark its shape/rect unresolvable rather than
698
+ // silently guessing a value. This is the ONE parsing path for every
699
+ // length/percentage this file resolves (inset() edges, circle()/ellipse()
700
+ // radii and positions, polygon() vertices) — there is no second, more
701
+ // lenient parser.
702
+ function resolveLength(token, refPx) {
703
+ token = (token || '').trim();
704
+ if (!token) return null;
705
+ if (/^calc\\(/.test(token) && token.charAt(token.length - 1) === ')') {
706
+ return evalCalcExpr(token.slice(5, -1), refPx);
707
+ }
708
+ var pctMatch = /^(-?(?:\\d+(?:\\.\\d*)?|\\.\\d+))%$/.exec(token);
709
+ if (pctMatch) {
710
+ var pct = parseFloat(pctMatch[1]);
711
+ return Number.isFinite(pct) ? (pct / 100) * refPx : null;
712
+ }
713
+ var pxMatch = /^(-?(?:\\d+(?:\\.\\d*)?|\\.\\d+))px$/.exec(token);
714
+ if (pxMatch) {
715
+ var px = parseFloat(pxMatch[1]);
716
+ return Number.isFinite(px) ? px : null;
717
+ }
718
+ var numMatch = /^(-?(?:\\d+(?:\\.\\d*)?|\\.\\d+))$/.exec(token);
719
+ if (numMatch) {
720
+ var num = parseFloat(numMatch[1]);
721
+ return Number.isFinite(num) ? num : null;
722
+ }
723
+ return null;
724
+ }
725
+
726
+ // Evaluates the inside of a \`calc(...)\` as a sum of +/- terms — the only
727
+ // shape computed style ever emits for an edge-offset <position> (e.g.
728
+ // \`right 10px\` computes to \`calc(100% - 10px)\`). Per the CSS calc()
729
+ // grammar, a binary +/- operator MUST have whitespace on both sides (so
730
+ // \`-10px\` alone is a signed term, never mistaken for an operator); a
731
+ // nested \`calc(...)\` term is resolved recursively via \`resolveLength\`.
732
+ // Returns \`null\` — propagated by every caller — the moment any term or
733
+ // operator falls outside this grammar (multiplication, division, \`var()\`,
734
+ // viewport units, ...): there is no fallback guess, only an honest
735
+ // "cannot resolve this token" signal.
736
+ function evalCalcExpr(inner, refPx) {
737
+ var tokens = [];
738
+ var current = '';
739
+ var depth = 0;
740
+ for (var i = 0; i < inner.length; i += 1) {
741
+ var ch = inner[i];
742
+ if (ch === '(') depth += 1;
743
+ if (ch === ')') depth -= 1;
744
+ var isOperator =
745
+ (ch === '+' || ch === '-') && depth === 0 && i > 0 && inner[i - 1] === ' ' && i + 1 < inner.length && inner[i + 1] === ' ';
746
+ if (isOperator) {
747
+ tokens.push(current.trim());
748
+ tokens.push(ch);
749
+ current = '';
750
+ } else {
751
+ current += ch;
752
+ }
753
+ }
754
+ if (current.trim() !== '') tokens.push(current.trim());
755
+ if (tokens.length === 0 || depth !== 0) return null;
756
+ var result = 0;
757
+ var op = '+';
758
+ var expectsTerm = true;
759
+ for (var t = 0; t < tokens.length; t += 1) {
760
+ var tok = tokens[t];
761
+ if (tok === '+' || tok === '-') {
762
+ if (expectsTerm) return null;
763
+ op = tok;
764
+ expectsTerm = true;
765
+ continue;
766
+ }
767
+ if (!expectsTerm) return null;
768
+ var val = resolveLength(tok, refPx);
769
+ if (val === null || !Number.isFinite(val)) return null;
770
+ result += op === '-' ? -val : val;
771
+ expectsTerm = false;
772
+ }
773
+ return expectsTerm || !Number.isFinite(result) ? null : result;
774
+ }
775
+ // \`argsStr\` is the already-extracted, paren-balanced inset() argument
776
+ // list (see \`extractFunctionArgs\` below) — this does NOT re-match against
777
+ // the raw clip-path string, so a \`calc(...)\` edge value (which itself
778
+ // contains parens) can never truncate the extraction.
779
+ function parseInsetRect(argsStr, box) {
780
+ var tokens = splitTopLevel(argsStr.trim());
781
+ var nums = [];
782
+ for (var i = 0; i < tokens.length; i += 1) {
783
+ if (/^-?[\\d.]/.test(tokens[i]) || /^calc\\(/.test(tokens[i])) nums.push(tokens[i]);
784
+ else break;
785
+ }
786
+ if (nums.length === 0) return null;
787
+ var top = nums[0];
788
+ var right = nums.length > 1 ? nums[1] : nums[0];
789
+ var bottom = nums.length > 2 ? nums[2] : nums[0];
790
+ var left = nums.length > 3 ? nums[3] : right;
791
+ var leftVal = resolveLength(left, box.width);
792
+ var topVal = resolveLength(top, box.height);
793
+ var rightVal = resolveLength(right, box.width);
794
+ var bottomVal = resolveLength(bottom, box.height);
795
+ if (leftVal === null || topVal === null || rightVal === null || bottomVal === null) return null;
796
+ return {
797
+ left: box.left + leftVal,
798
+ top: box.top + topVal,
799
+ right: box.right - rightVal,
800
+ bottom: box.bottom - bottomVal,
801
+ };
802
+ }
803
+ function intersectBox(a, b) {
804
+ if (!a) return b;
805
+ if (!b) return a;
806
+ return {
807
+ left: Math.max(a.left, b.left),
808
+ top: Math.max(a.top, b.top),
809
+ right: Math.min(a.right, b.right),
810
+ bottom: Math.min(a.bottom, b.bottom),
811
+ };
812
+ }
813
+
814
+ // <geometry-box> keyword resolution: defaults to border-box. Only
815
+ // border/padding/content/margin are resolved exactly (the DOM boxes this
816
+ // collector already reasons about elsewhere); fill-box/stroke-box/view-box
817
+ // are SVG-specific reference boxes with no meaningful analog against a
818
+ // plain HTML ancestor chain, so they fall back to border-box too — a
819
+ // documented simplification, distinct from the path()/url() "approximate"
820
+ // escape hatch since the shape math itself is still exact.
821
+ function computeReferenceBox(node, clipPath) {
822
+ var rect = node.getBoundingClientRect();
823
+ var boxMatch = /(border-box|padding-box|content-box|margin-box|fill-box|stroke-box|view-box)/.exec(clipPath);
824
+ var boxKeyword = boxMatch ? boxMatch[1] : 'border-box';
825
+ if (boxKeyword === 'border-box' || boxKeyword === 'fill-box' || boxKeyword === 'stroke-box' || boxKeyword === 'view-box') {
826
+ return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, width: rect.width, height: rect.height };
827
+ }
828
+ var style = getComputedStyle(node);
829
+ var bl = parseFloat(style.borderLeftWidth) || 0;
830
+ var br = parseFloat(style.borderRightWidth) || 0;
831
+ var bt = parseFloat(style.borderTopWidth) || 0;
832
+ var bb = parseFloat(style.borderBottomWidth) || 0;
833
+ if (boxKeyword === 'padding-box') {
834
+ return {
835
+ left: rect.left + bl, top: rect.top + bt, right: rect.right - br, bottom: rect.bottom - bb,
836
+ width: rect.width - bl - br, height: rect.height - bt - bb,
837
+ };
838
+ }
839
+ if (boxKeyword === 'margin-box') {
840
+ var ml = parseFloat(style.marginLeft) || 0;
841
+ var mr = parseFloat(style.marginRight) || 0;
842
+ var mt = parseFloat(style.marginTop) || 0;
843
+ var mb = parseFloat(style.marginBottom) || 0;
844
+ return {
845
+ left: rect.left - ml, top: rect.top - mt, right: rect.right + mr, bottom: rect.bottom + mb,
846
+ width: rect.width + ml + mr, height: rect.height + mt + mb,
847
+ };
848
+ }
849
+ // content-box
850
+ var pl = parseFloat(style.paddingLeft) || 0;
851
+ var pr = parseFloat(style.paddingRight) || 0;
852
+ var pt = parseFloat(style.paddingTop) || 0;
853
+ var pb = parseFloat(style.paddingBottom) || 0;
854
+ return {
855
+ left: rect.left + bl + pl, top: rect.top + bt + pt, right: rect.right - br - pr, bottom: rect.bottom - bb - pb,
856
+ width: rect.width - bl - br - pl - pr, height: rect.height - bt - bb - pt - pb,
857
+ };
858
+ }
859
+
860
+ // CSS <position> subset: one/two-value keywords/lengths/percentages/calc()
861
+ // (including the top-left keyword-order swap), plus four-value horizontal
862
+ // and vertical edge-offset pairs in either axis order. Computed style may
863
+ // preserve \`right 10px bottom 20px\` rather than serializing it as calc().
864
+ // Unsupported, malformed, or ambiguous token sequences return \`null\`
865
+ // (never guessed coordinates), making the ancestor clip approximate.
866
+ function parsePosition(str, w, h) {
867
+ str = (str || '').trim();
868
+ if (!str) return { x: w / 2, y: h / 2 };
869
+ var tokens = splitTopLevel(str);
870
+ function axisValue(token, dim, isX) {
871
+ if (token === 'center') return dim / 2;
872
+ if (isX && token === 'left') return 0;
873
+ if (isX && token === 'right') return dim;
874
+ if (!isX && token === 'top') return 0;
875
+ if (!isX && token === 'bottom') return dim;
876
+ return resolveLength(token, dim);
877
+ }
878
+ if (tokens.length === 1) {
879
+ var t = tokens[0];
880
+ if (t === 'top' || t === 'bottom') {
881
+ var yOnly = axisValue(t, h, false);
882
+ return yOnly === null ? null : { x: w / 2, y: yOnly };
883
+ }
884
+ var xOnly = axisValue(t, w, true);
885
+ return xOnly === null ? null : { x: xOnly, y: h / 2 };
886
+ }
887
+ if (tokens.length === 2) {
888
+ var first = tokens[0];
889
+ var second = tokens[1];
890
+ var x, y;
891
+ if (first === 'top' || first === 'bottom') {
892
+ x = axisValue(second, w, true);
893
+ y = axisValue(first, h, false);
894
+ } else {
895
+ x = axisValue(first, w, true);
896
+ y = axisValue(second, h, false);
897
+ }
898
+ if (x === null || y === null) return null;
899
+ return { x: x, y: y };
900
+ }
901
+ if (tokens.length !== 4) return null;
902
+
903
+ function edgeOffset(edge, offset) {
904
+ var horizontal = edge === 'left' || edge === 'right';
905
+ var vertical = edge === 'top' || edge === 'bottom';
906
+ if (!horizontal && !vertical) return null;
907
+ var dim = horizontal ? w : h;
908
+ var value = resolveLength(offset, dim);
909
+ if (value === null) return null;
910
+ return { axis: horizontal ? 'x' : 'y', value: edge === 'right' || edge === 'bottom' ? dim - value : value };
911
+ }
912
+ var firstPair = edgeOffset(tokens[0], tokens[1]);
913
+ var secondPair = edgeOffset(tokens[2], tokens[3]);
914
+ if (!firstPair || !secondPair || firstPair.axis === secondPair.axis) return null;
915
+ return firstPair.axis === 'x'
916
+ ? { x: firstPair.value, y: secondPair.value }
917
+ : { x: secondPair.value, y: firstPair.value };
918
+ }
919
+
920
+ // Single-axis radius: closest-side/farthest-side (distances to the two
921
+ // opposing edges along this axis) / percentage (of dim) / length /
922
+ // calc(). Returns \`null\` (never a guessed closest-side default) the
923
+ // moment the token is not one of these forms.
924
+ function resolveAxisRadius(token, centerRel, dim) {
925
+ token = (token || '').trim();
926
+ if (!token || token === 'closest-side') return Math.min(centerRel, dim - centerRel);
927
+ if (token === 'farthest-side') return Math.max(centerRel, dim - centerRel);
928
+ return resolveLength(token, dim);
929
+ }
930
+
931
+ // circle()'s single radius uses the CSS Shapes diagonal formula for '%'
932
+ // (sqrt(w^2+h^2)/sqrt(2)), not a per-axis percentage; a calc() radius
933
+ // resolves its own '%' terms against that same diagonal basis. Returns
934
+ // \`null\` (never a guessed closest-side default) when unresolvable.
935
+ function resolveCircleRadius(token, cx, cy, w, h) {
936
+ token = (token || '').trim();
937
+ if (!token || token === 'closest-side') return Math.min(cx, w - cx, cy, h - cy);
938
+ if (token === 'farthest-side') return Math.max(cx, w - cx, cy, h - cy);
939
+ var pctMatch = /^(-?[\\d.]+)%$/.exec(token);
940
+ if (pctMatch) return (parseFloat(pctMatch[1]) / 100) * (Math.sqrt(w * w + h * h) / Math.SQRT2);
941
+ var pxMatch = /^(-?[\\d.]+)px$/.exec(token);
942
+ if (pxMatch) return parseFloat(pxMatch[1]);
943
+ var numMatch = /^(-?[\\d.]+)$/.exec(token);
944
+ if (numMatch) return parseFloat(numMatch[1]);
945
+ if (/^calc\\(/.test(token) && token.charAt(token.length - 1) === ')') {
946
+ return evalCalcExpr(token.slice(5, -1), Math.sqrt(w * w + h * h) / Math.SQRT2);
947
+ }
948
+ return null;
949
+ }
950
+
951
+ function splitShapeArgs(argsStr) {
952
+ var trimmed = argsStr.trim();
953
+ // Radius/axes are entirely optional ("circle(at 40px 40px)" is valid
954
+ // CSS — no leading space before "at" in that case), so check for a
955
+ // leading "at " separately from the more common "<radius> at <pos>" form.
956
+ if (/^at\s+/.test(trimmed)) {
957
+ return { radiusPart: '', posPart: trimmed.replace(/^at\s+/, '').trim() };
958
+ }
959
+ var atIdx = trimmed.indexOf(' at ');
960
+ return {
961
+ radiusPart: (atIdx >= 0 ? trimmed.slice(0, atIdx) : trimmed).trim(),
962
+ posPart: atIdx >= 0 ? trimmed.slice(atIdx + 4).trim() : '',
963
+ };
964
+ }
965
+
966
+ // Returns \`null\` (never a shape built from guessed coordinates) the
967
+ // moment the position or radius contains an unresolvable token.
968
+ function parseCircle(argsStr, box) {
969
+ var parts = splitShapeArgs(argsStr);
970
+ var pos = parsePosition(parts.posPart, box.width, box.height);
971
+ if (!pos) return null;
972
+ var r = resolveCircleRadius(parts.radiusPart, pos.x, pos.y, box.width, box.height);
973
+ if (r === null) return null;
974
+ return { type: 'circle', cx: box.left + pos.x, cy: box.top + pos.y, r: Math.max(0, r) };
975
+ }
976
+
977
+ function parseEllipse(argsStr, box) {
978
+ var parts = splitShapeArgs(argsStr);
979
+ var pos = parsePosition(parts.posPart, box.width, box.height);
980
+ if (!pos) return null;
981
+ var radiusTokens = splitTopLevel(parts.radiusPart);
982
+ var rx = resolveAxisRadius(radiusTokens[0], pos.x, box.width);
983
+ var ry = resolveAxisRadius(radiusTokens[1], pos.y, box.height);
984
+ if (rx === null || ry === null) return null;
985
+ return { type: 'ellipse', cx: box.left + pos.x, cy: box.top + pos.y, rx: Math.max(0, rx), ry: Math.max(0, ry) };
986
+ }
987
+
988
+ function parsePolygon(argsStr, box) {
989
+ var fillRule = 'nonzero';
990
+ var body = argsStr;
991
+ var fillMatch = /^\\s*(nonzero|evenodd)\\s*,/.exec(argsStr);
992
+ if (fillMatch) {
993
+ fillRule = fillMatch[1];
994
+ body = argsStr.slice(fillMatch[0].length);
995
+ }
996
+ var pairs = body.split(',');
997
+ var points = [];
998
+ for (var i = 0; i < pairs.length; i += 1) {
999
+ var tokens = splitTopLevel(pairs[i].trim());
1000
+ if (tokens.length < 2) continue;
1001
+ var xVal = resolveLength(tokens[0], box.width);
1002
+ var yVal = resolveLength(tokens[1], box.height);
1003
+ if (xVal === null || yVal === null) return null;
1004
+ points.push(box.left + xVal, box.top + yVal);
1005
+ }
1006
+ return { type: 'polygon', points: points, fillRule: fillRule };
1007
+ }
1008
+
1009
+ // Extracts the argument list of \`name(...)\` from a clip-path value —
1010
+ // but ONLY when \`name(\` opens a top-level function in the value, never
1011
+ // a bare substring match anywhere in the string. A clip-path value is
1012
+ // (per the CSS grammar this collector supports) either a single
1013
+ // \`<basic-shape>\` function optionally paired with a \`<geometry-box>\`
1014
+ // keyword — so splitting the value on TOP-LEVEL whitespace (via
1015
+ // \`splitTopLevel\`, the same depth-aware splitter used for a shape's own
1016
+ // arguments) yields at most one shape-function token; every other token
1017
+ // is a box keyword. Checking that token's own PREFIX (not an \`indexOf\`
1018
+ // anywhere in the raw string) is what keeps this from matching shape
1019
+ // text that only appears nested inside an unrelated top-level function's
1020
+ // arguments — most importantly \`url(\"#circle(20px at 50px 50px)\")\`, a
1021
+ // valid but UNSUPPORTED \`<clip-source>\` reference whose fragment text
1022
+ // merely resembles a circle() call. Once the matching token is found,
1023
+ // argument extraction still walks paren depth from \`name(\`'s opening
1024
+ // paren to its OWN matching close — never the naive \`[^)]*\` regex this
1025
+ // replaces, which truncates at the first \`)\` it sees and silently hands
1026
+ // a mangled/truncated argument string to the shape parser the moment an
1027
+ // argument itself contains parens (every computed \`calc(...)\`
1028
+ // edge-offset position does). Returns \`null\` when no top-level token
1029
+ // starts with \`name(\` or that token's parens never close.
1030
+ function extractFunctionArgs(str, name) {
1031
+ var prefix = name + '(';
1032
+ var tokens = splitTopLevel(str.trim());
1033
+ var target = null;
1034
+ for (var t = 0; t < tokens.length; t += 1) {
1035
+ if (tokens[t].indexOf(prefix) === 0) {
1036
+ target = tokens[t];
1037
+ break;
1038
+ }
1039
+ }
1040
+ if (target === null) return null;
1041
+ var start = prefix.length;
1042
+ var depth = 1;
1043
+ var i = start;
1044
+ for (; i < target.length; i += 1) {
1045
+ if (target[i] === '(') depth += 1;
1046
+ else if (target[i] === ')') {
1047
+ depth -= 1;
1048
+ if (depth === 0) break;
1049
+ }
1050
+ }
1051
+ if (depth !== 0) return null;
1052
+ return target.slice(start, i);
1053
+ }
1054
+
1055
+ function shapeBboxRect(shape) {
1056
+ if (shape.type === 'circle') {
1057
+ return { left: shape.cx - shape.r, top: shape.cy - shape.r, right: shape.cx + shape.r, bottom: shape.cy + shape.r };
1058
+ }
1059
+ if (shape.type === 'ellipse') {
1060
+ return { left: shape.cx - shape.rx, top: shape.cy - shape.ry, right: shape.cx + shape.rx, bottom: shape.cy + shape.ry };
1061
+ }
1062
+ var xs = [];
1063
+ var ys = [];
1064
+ for (var i = 0; i < shape.points.length; i += 2) {
1065
+ xs.push(shape.points[i]);
1066
+ ys.push(shape.points[i + 1]);
1067
+ }
1068
+ return { left: Math.min.apply(null, xs), top: Math.min.apply(null, ys), right: Math.max.apply(null, xs), bottom: Math.max.apply(null, ys) };
1069
+ }
1070
+
1071
+ var node = this.parentElement;
1072
+ var docEl = this.ownerDocument ? this.ownerDocument.documentElement : null;
1073
+ var clip = null;
1074
+ var shapes = [];
1075
+ var approximate = false;
1076
+ var hops = 0;
1077
+ while (node && node.nodeType === 1 && node !== docEl && hops < 100) {
1078
+ var style = getComputedStyle(node);
1079
+ var overflowClips = /hidden|auto|scroll|clip/.test(style.overflowX) || /hidden|auto|scroll|clip/.test(style.overflowY);
1080
+ var clipPath = style.clipPath;
1081
+ var hasClipPath = !!clipPath && clipPath !== 'none';
1082
+ if (overflowClips) {
1083
+ var ar = node.getBoundingClientRect();
1084
+ clip = intersectBox(clip, { left: ar.left, top: ar.top, right: ar.right, bottom: ar.bottom });
1085
+ }
1086
+ if (hasClipPath) {
1087
+ var refBox = computeReferenceBox(node, clipPath);
1088
+ // Paren-balanced argument extraction (not a naive \`[^)]*\` regex) —
1089
+ // required because a \`calc(...)\` argument (the form computed style
1090
+ // always uses for an edge-offset position) itself contains parens.
1091
+ var insetArgs = extractFunctionArgs(clipPath, 'inset');
1092
+ var circleArgs = extractFunctionArgs(clipPath, 'circle');
1093
+ var ellipseArgs = extractFunctionArgs(clipPath, 'ellipse');
1094
+ var polygonArgs = extractFunctionArgs(clipPath, 'polygon');
1095
+ var refBoxRect = { left: refBox.left, top: refBox.top, right: refBox.right, bottom: refBox.bottom };
1096
+ if (insetArgs !== null) {
1097
+ var insetBox = parseInsetRect(insetArgs, refBox);
1098
+ if (insetBox) {
1099
+ clip = intersectBox(clip, insetBox);
1100
+ } else {
1101
+ // A recognized inset() whose edge tokens this evaluator cannot
1102
+ // resolve (e.g. an operand outside the calc() px/%/number
1103
+ // grammar): do NOT silently fall back to guessed edges — mark
1104
+ // this ancestor's contribution approximate, same as path()/url().
1105
+ approximate = true;
1106
+ clip = intersectBox(clip, refBoxRect);
1107
+ }
1108
+ } else if (circleArgs !== null) {
1109
+ var circleShape = parseCircle(circleArgs, refBox);
1110
+ if (circleShape) {
1111
+ shapes.push(circleShape);
1112
+ clip = intersectBox(clip, shapeBboxRect(circleShape));
1113
+ } else {
1114
+ // A recognized circle() whose radius/position tokens this
1115
+ // evaluator cannot resolve (e.g. a computed calc() position term
1116
+ // outside the supported grammar): do NOT silently compute a
1117
+ // wrong shape from guessed coordinates — mark approximate.
1118
+ approximate = true;
1119
+ clip = intersectBox(clip, refBoxRect);
1120
+ }
1121
+ } else if (ellipseArgs !== null) {
1122
+ var ellipseShape = parseEllipse(ellipseArgs, refBox);
1123
+ if (ellipseShape) {
1124
+ shapes.push(ellipseShape);
1125
+ clip = intersectBox(clip, shapeBboxRect(ellipseShape));
1126
+ } else {
1127
+ approximate = true;
1128
+ clip = intersectBox(clip, refBoxRect);
1129
+ }
1130
+ } else if (polygonArgs !== null) {
1131
+ var polygonShape = parsePolygon(polygonArgs, refBox);
1132
+ if (polygonShape) {
1133
+ shapes.push(polygonShape);
1134
+ clip = intersectBox(clip, shapeBboxRect(polygonShape));
1135
+ } else {
1136
+ approximate = true;
1137
+ clip = intersectBox(clip, refBoxRect);
1138
+ }
1139
+ } else {
1140
+ // path() / url() / any other unrecognized clip-path form: cannot be
1141
+ // resolved to exact geometry. Do NOT silently treat this as
1142
+ // unclipped — fall back to the ancestor's own box as a conservative
1143
+ // bound (same as before) but flag it as approximate so the caller
1144
+ // never mistakes this for an exact mask.
1145
+ approximate = true;
1146
+ clip = intersectBox(clip, refBoxRect);
1147
+ }
1148
+ }
1149
+ node = node.parentElement;
1150
+ hops += 1;
1151
+ }
1152
+ if (!clip) return { rect: null, shapes: shapes, approximate: approximate };
1153
+ var width = Math.max(0, clip.right - clip.left);
1154
+ var height = Math.max(0, clip.bottom - clip.top);
1155
+ return { rect: { x: clip.left, y: clip.top, width: width, height: height }, shapes: shapes, approximate: approximate };
1156
+ }`;
1157
+
1158
+ /**
1159
+ * Resolves `nodeId` to a live `objectId` (`DOM.resolveNode`) and runs
1160
+ * {@link ANCESTOR_CLIP_FUNCTION} on it. Never throws overall, but a
1161
+ * resolve/call failure that genuinely prevents the walk from running
1162
+ * (`DOM.resolveNode` resolving without an `objectId`, `Runtime.callFunctionOn`
1163
+ * resolving without `result.value`, or either call throwing — detached
1164
+ * node, CDP hiccup) is marked honestly via {@link AncestorClipInfo.unavailable}
1165
+ * + `unavailableReason`, NOT silently coerced into the same
1166
+ * `{rect:null,shapes:[],approximate:false}` shape a genuine "no ancestor
1167
+ * clips this element" result produces (I-4/I-5) — that shape is reserved
1168
+ * for a walk that actually ran and found nothing to clip. This mirrors
1169
+ * `backendNodeId`'s `identityUnresolved: true` marker rather than a silent
1170
+ * default: clip info gets the same honesty treatment identity does.
1171
+ * The `DOM.resolveNode`-held `objectId` is released via
1172
+ * `Runtime.releaseObject` in a `finally` (unconditionally, mirroring
1173
+ * `animation.ts`'s held-object cleanup) so a per-element remote handle never
1174
+ * outlives this call.
1175
+ */
1176
+ async function computeAncestorClip(client: CDPClient, nodeId: number): Promise<AncestorClipInfo> {
1177
+ const unavailable = (reason: AncestorClipUnavailableReason): AncestorClipInfo => ({
1178
+ rect: null,
1179
+ shapes: [],
1180
+ approximate: false,
1181
+ unavailable: true,
1182
+ unavailableReason: reason,
1183
+ });
1184
+ let objectId: string | undefined;
1185
+ try {
1186
+ const resolved = (await client.send('DOM.resolveNode', { nodeId })) as { object?: { objectId?: string } };
1187
+ objectId = resolved.object?.objectId;
1188
+ if (!objectId) return unavailable('resolve-node-no-object-id');
1189
+ const called = (await client.send('Runtime.callFunctionOn', {
1190
+ objectId,
1191
+ functionDeclaration: ANCESTOR_CLIP_FUNCTION,
1192
+ returnByValue: true,
1193
+ })) as { result?: { value?: AncestorClipInfo } };
1194
+ if (!called.result?.value) return unavailable('call-function-no-value');
1195
+ return called.result.value;
1196
+ } catch {
1197
+ return unavailable('resolve-or-call-threw');
1198
+ } finally {
1199
+ if (objectId) {
1200
+ try {
1201
+ await client.send('Runtime.releaseObject', { objectId });
1202
+ } catch {
1203
+ // Best-effort release — a failure here doesn't invalidate the clip
1204
+ // info already computed/returned above.
1205
+ }
1206
+ }
1207
+ }
1208
+ }
1209
+
1210
+ // ============================================================================
1211
+ // Pure geometry/selector helpers
1212
+ // ============================================================================
1213
+
1214
+ function unionRect(rects: readonly Rect[]): Rect {
1215
+ // Loop instead of Math.min(...spread) — spreading a large rect list can
1216
+ // overflow the call stack.
1217
+ let minX = Infinity;
1218
+ let minY = Infinity;
1219
+ let maxX = -Infinity;
1220
+ let maxY = -Infinity;
1221
+ for (const r of rects) {
1222
+ if (r.x < minX) minX = r.x;
1223
+ if (r.y < minY) minY = r.y;
1224
+ if (r.x + r.width > maxX) maxX = r.x + r.width;
1225
+ if (r.y + r.height > maxY) maxY = r.y + r.height;
1226
+ }
1227
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
1228
+ }
1229
+
1230
+ /** Axis-aligned rect intersection; zero width/height (never negative) when `a` and `b` don't overlap. */
1231
+ function intersectRect(a: Rect, b: Rect): Rect {
1232
+ const x0 = Math.max(a.x, b.x);
1233
+ const y0 = Math.max(a.y, b.y);
1234
+ const x1 = Math.min(a.x + a.width, b.x + b.width);
1235
+ const y1 = Math.min(a.y + a.height, b.y + b.height);
1236
+ return { x: x0, y: y0, width: Math.max(0, x1 - x0), height: Math.max(0, y1 - y0) };
1237
+ }
1238
+
1239
+ function clampRectToPixels(
1240
+ rect: Rect,
1241
+ scaleX: number,
1242
+ scaleY: number,
1243
+ imgWidth: number,
1244
+ imgHeight: number,
1245
+ ): PixelRect | null {
1246
+ const x0 = Math.max(0, Math.floor(rect.x * scaleX));
1247
+ const y0 = Math.max(0, Math.floor(rect.y * scaleY));
1248
+ const x1 = Math.min(imgWidth, Math.ceil((rect.x + rect.width) * scaleX));
1249
+ const y1 = Math.min(imgHeight, Math.ceil((rect.y + rect.height) * scaleY));
1250
+ if (x1 <= x0 || y1 <= y0) return null;
1251
+ return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
1252
+ }
1253
+
1254
+ /**
1255
+ * Rasterizes a polygon mask over the clamped crop rect: a crop pixel is
1256
+ * on-mask iff its center — mapped back from screenshot-pixel space into CSS
1257
+ * viewport space via the same `scaleX`/`scaleY` used to derive the rect —
1258
+ * lies inside any of the element's content quads AND inside every ancestor
1259
+ * clip shape (`clipShapes`; an empty array is always satisfied — the
1260
+ * pre-existing behavior when no ancestor imposes an exact circle/ellipse/
1261
+ * polygon constraint). The 0.5 pixel-center offset keeps integer-aligned
1262
+ * axis-aligned quads (the common case) from hitting the point-in-polygon
1263
+ * boundary ambiguously.
1264
+ */
1265
+ function buildQuadMask(
1266
+ pixelRect: PixelRect,
1267
+ quads: readonly Quad[],
1268
+ scaleX: number,
1269
+ scaleY: number,
1270
+ clipShapes: readonly ClipShape[],
1271
+ ): Mask {
1272
+ const data = new Uint8Array(pixelRect.width * pixelRect.height);
1273
+ let count = 0;
1274
+ for (let row = 0; row < pixelRect.height; row += 1) {
1275
+ const cssY = (pixelRect.y + row + 0.5) / scaleY;
1276
+ for (let col = 0; col < pixelRect.width; col += 1) {
1277
+ const cssX = (pixelRect.x + col + 0.5) / scaleX;
1278
+ const inQuad = quads.some((quad) => pointInQuad(quad, cssX, cssY));
1279
+ const inClipShapes = clipShapes.every((shape) => pointInClipShape(shape, cssX, cssY));
1280
+ if (inQuad && inClipShapes) {
1281
+ data[row * pixelRect.width + col] = 1;
1282
+ count += 1;
1283
+ }
1284
+ }
1285
+ }
1286
+ return { data, count };
1287
+ }
1288
+
1289
+ /** Even-odd ray-cast point-in-polygon over a quad's four corners. */
1290
+ function pointInQuad(quad: Quad, px: number, py: number): boolean {
1291
+ const xs = [quad[0], quad[2], quad[4], quad[6]];
1292
+ const ys = [quad[1], quad[3], quad[5], quad[7]];
1293
+ let inside = false;
1294
+ for (let i = 0, j = 3; i < 4; j = i, i += 1) {
1295
+ const xi = xs[i];
1296
+ const yi = ys[i];
1297
+ const xj = xs[j];
1298
+ const yj = ys[j];
1299
+ const intersects = yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi;
1300
+ if (intersects) inside = !inside;
1301
+ }
1302
+ return inside;
1303
+ }
1304
+
1305
+ /** Dispatches a point-in-shape test to the exact geometry for the given ancestor clip-path shape. */
1306
+ function pointInClipShape(shape: ClipShape, x: number, y: number): boolean {
1307
+ switch (shape.type) {
1308
+ case 'circle': {
1309
+ const dx = x - shape.cx;
1310
+ const dy = y - shape.cy;
1311
+ return dx * dx + dy * dy <= shape.r * shape.r;
1312
+ }
1313
+ case 'ellipse': {
1314
+ if (shape.rx <= 0 || shape.ry <= 0) return false;
1315
+ const dx = (x - shape.cx) / shape.rx;
1316
+ const dy = (y - shape.cy) / shape.ry;
1317
+ return dx * dx + dy * dy <= 1;
1318
+ }
1319
+ case 'polygon':
1320
+ return pointInPolygon(shape.points, shape.fillRule, x, y);
1321
+ default:
1322
+ return true;
1323
+ }
1324
+ }
1325
+
1326
+ /**
1327
+ * Point-in-polygon over an arbitrary vertex list, honoring the CSS
1328
+ * `clip-path: polygon()` fill rule: `evenodd` (the same even-odd ray-cast
1329
+ * used for content quads) or `nonzero` (the default — a standard nonzero
1330
+ * winding-number test, needed because a self-intersecting or
1331
+ * differently-wound polygon can disagree with even-odd on which regions
1332
+ * are "inside").
1333
+ */
1334
+ function pointInPolygon(points: readonly number[], fillRule: 'nonzero' | 'evenodd', px: number, py: number): boolean {
1335
+ const n = points.length / 2;
1336
+ if (n < 3) return false;
1337
+ if (fillRule === 'evenodd') {
1338
+ let inside = false;
1339
+ for (let i = 0, j = n - 1; i < n; j = i, i += 1) {
1340
+ const xi = points[i * 2];
1341
+ const yi = points[i * 2 + 1];
1342
+ const xj = points[j * 2];
1343
+ const yj = points[j * 2 + 1];
1344
+ const intersects = yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi;
1345
+ if (intersects) inside = !inside;
1346
+ }
1347
+ return inside;
1348
+ }
1349
+ let winding = 0;
1350
+ for (let i = 0, j = n - 1; i < n; j = i, i += 1) {
1351
+ const xi = points[i * 2];
1352
+ const yi = points[i * 2 + 1];
1353
+ const xj = points[j * 2];
1354
+ const yj = points[j * 2 + 1];
1355
+ if (yi <= py) {
1356
+ if (yj > py && isLeftOfEdge(xi, yi, xj, yj, px, py) > 0) winding += 1;
1357
+ } else if (yj <= py && isLeftOfEdge(xi, yi, xj, yj, px, py) < 0) {
1358
+ winding -= 1;
1359
+ }
1360
+ }
1361
+ return winding !== 0;
1362
+ }
1363
+
1364
+ /** Signed area (cross product) test: >0 when `(px,py)` is left of the directed edge `(x0,y0)->(x1,y1)`. */
1365
+ function isLeftOfEdge(x0: number, y0: number, x1: number, y1: number, px: number, py: number): number {
1366
+ return (x1 - x0) * (py - y0) - (px - x0) * (y1 - y0);
1367
+ }
1368
+
1369
+ function flattenAttributes(attributes: readonly string[] | undefined): Map<string, string> {
1370
+ const map = new Map<string, string>();
1371
+ if (!attributes) return map;
1372
+ for (let i = 0; i + 1 < attributes.length; i += 2) {
1373
+ map.set(attributes[i], attributes[i + 1]);
1374
+ }
1375
+ return map;
1376
+ }
1377
+
1378
+ /**
1379
+ * Best-effort `tag#id.class1.class2` selector. Structure is built from the
1380
+ * raw page-controlled attributes, then the whole string is routed through
1381
+ * the shared {@link sanitizeString} length cap — the single authority every
1382
+ * collector uses for page-controlled strings.
1383
+ */
1384
+ function buildSelector(nodeName: string | undefined, attributes: readonly string[] | undefined): string | undefined {
1385
+ if (!nodeName) return undefined;
1386
+ const attrs = flattenAttributes(attributes);
1387
+
1388
+ let selector = nodeName.toLowerCase();
1389
+ const idAttr = attrs.get('id');
1390
+ if (idAttr) selector += `#${idAttr}`;
1391
+ const classAttr = attrs.get('class');
1392
+ if (classAttr) {
1393
+ for (const cls of classAttr.split(/\s+/).filter(Boolean).slice(0, 3)) {
1394
+ selector += `.${cls}`;
1395
+ }
1396
+ }
1397
+ return sanitizeString(selector);
1398
+ }
1399
+
1400
+ /**
1401
+ * Stable, collision-free crop filename base: `index` guarantees uniqueness,
1402
+ * `backendNodeId ?? tag` is a stable identifier under our control, and the
1403
+ * shared {@link sanitizeFilenameSlug} replaces filename-unsafe characters
1404
+ * and caps length. Never derived from raw page attributes.
1405
+ */
1406
+ function cropFileBase(index: number, entry: DescribedElement): string {
1407
+ const stableId = typeof entry.backendNodeId === 'number' ? String(entry.backendNodeId) : (entry.tag ?? 'el');
1408
+ const slug = entry.selector !== undefined ? sanitizeFilenameSlug(entry.selector) : '';
1409
+ return slug ? `${index}-${stableId}-${slug}` : `${index}-${stableId}`;
1410
+ }
1411
+
1412
+ function round3(value: number): number {
1413
+ return Math.round(value * 1000) / 1000;
1414
+ }
1415
+
1416
+ // ============================================================================
1417
+ // Raster helpers — crop, mask, encode, and derive per-crop facts
1418
+ // ============================================================================
1419
+
1420
+ function cropPixels(png: PNG, rect: PixelRect): RawImage {
1421
+ const out = Buffer.alloc(rect.width * rect.height * 4);
1422
+ for (let row = 0; row < rect.height; row += 1) {
1423
+ const srcStart = ((rect.y + row) * png.width + rect.x) * 4;
1424
+ const destStart = row * rect.width * 4;
1425
+ png.data.copy(out, destStart, srcStart, srcStart + rect.width * 4);
1426
+ }
1427
+ return { width: rect.width, height: rect.height, data: out };
1428
+ }
1429
+
1430
+ /** Returns a copy of `img` with every off-mask pixel zeroed to fully-transparent (0,0,0,0). */
1431
+ function applyMaskTransparent(img: RawImage, mask: Mask): RawImage {
1432
+ const out = Buffer.from(img.data);
1433
+ const n = pixelCount(img);
1434
+ for (let i = 0; i < n; i += 1) {
1435
+ if (mask.data[i] === 0) {
1436
+ const o = i * 4;
1437
+ out[o] = 0;
1438
+ out[o + 1] = 0;
1439
+ out[o + 2] = 0;
1440
+ out[o + 3] = 0;
1441
+ }
1442
+ }
1443
+ return { width: img.width, height: img.height, data: out };
1444
+ }
1445
+
1446
+ function encodePng(img: RawImage): Buffer {
1447
+ const png = new PNG({ width: img.width, height: img.height });
1448
+ img.data.copy(png.data);
1449
+ return PNG.sync.write(png);
1450
+ }
1451
+
1452
+ function pixelCount(img: RawImage): number {
1453
+ return img.width * img.height;
1454
+ }
1455
+
1456
+ function averageColor(img: RawImage, mask: Mask): RGBColor {
1457
+ if (mask.count === 0) return { r: 0, g: 0, b: 0 };
1458
+ let r = 0;
1459
+ let g = 0;
1460
+ let b = 0;
1461
+ const n = pixelCount(img);
1462
+ for (let i = 0; i < n; i += 1) {
1463
+ if (mask.data[i] === 0) continue;
1464
+ const o = i * 4;
1465
+ r += img.data[o];
1466
+ g += img.data[o + 1];
1467
+ b += img.data[o + 2];
1468
+ }
1469
+ return { r: Math.round(r / mask.count), g: Math.round(g / mask.count), b: Math.round(b / mask.count) };
1470
+ }
1471
+
1472
+ function medianColor(img: RawImage, mask: Mask): RGBColor {
1473
+ if (mask.count === 0) return { r: 0, g: 0, b: 0 };
1474
+ const rs = new Array<number>(mask.count);
1475
+ const gs = new Array<number>(mask.count);
1476
+ const bs = new Array<number>(mask.count);
1477
+ const n = pixelCount(img);
1478
+ let k = 0;
1479
+ for (let i = 0; i < n; i += 1) {
1480
+ if (mask.data[i] === 0) continue;
1481
+ const o = i * 4;
1482
+ rs[k] = img.data[o];
1483
+ gs[k] = img.data[o + 1];
1484
+ bs[k] = img.data[o + 2];
1485
+ k += 1;
1486
+ }
1487
+ rs.sort((a, b) => a - b);
1488
+ gs.sort((a, b) => a - b);
1489
+ bs.sort((a, b) => a - b);
1490
+ const mid = Math.floor(mask.count / 2);
1491
+ return { r: rs[mid], g: gs[mid], b: bs[mid] };
1492
+ }
1493
+
1494
+ /** Most-frequent color after quantizing to `DOMINANT_BUCKET`-wide buckets, reported as that bucket's own average. */
1495
+ function dominantColor(img: RawImage, mask: Mask): RGBColor {
1496
+ if (mask.count === 0) return { r: 0, g: 0, b: 0 };
1497
+ const buckets = new Map<string, { count: number; r: number; g: number; b: number }>();
1498
+ const n = pixelCount(img);
1499
+ for (let i = 0; i < n; i += 1) {
1500
+ if (mask.data[i] === 0) continue;
1501
+ const o = i * 4;
1502
+ const r = img.data[o];
1503
+ const g = img.data[o + 1];
1504
+ const b = img.data[o + 2];
1505
+ const key = `${Math.floor(r / DOMINANT_BUCKET)},${Math.floor(g / DOMINANT_BUCKET)},${Math.floor(b / DOMINANT_BUCKET)}`;
1506
+ const entry = buckets.get(key) ?? { count: 0, r: 0, g: 0, b: 0 };
1507
+ entry.count += 1;
1508
+ entry.r += r;
1509
+ entry.g += g;
1510
+ entry.b += b;
1511
+ buckets.set(key, entry);
1512
+ }
1513
+ let best: { count: number; r: number; g: number; b: number } | null = null;
1514
+ for (const entry of buckets.values()) {
1515
+ if (!best || entry.count > best.count) best = entry;
1516
+ }
1517
+ if (!best) return { r: 0, g: 0, b: 0 };
1518
+ return { r: Math.round(best.r / best.count), g: Math.round(best.g / best.count), b: Math.round(best.b / best.count) };
1519
+ }
1520
+
1521
+ function alphaFraction(img: RawImage, mask: Mask): number {
1522
+ if (mask.count === 0) return 0;
1523
+ let sum = 0;
1524
+ const n = pixelCount(img);
1525
+ for (let i = 0; i < n; i += 1) {
1526
+ if (mask.data[i] === 0) continue;
1527
+ sum += img.data[i * 4 + 3];
1528
+ }
1529
+ return sum / (mask.count * 255);
1530
+ }
1531
+
1532
+ function visiblePixelFraction(img: RawImage, mask: Mask): number {
1533
+ if (mask.count === 0) return 0;
1534
+ let visible = 0;
1535
+ const n = pixelCount(img);
1536
+ for (let i = 0; i < n; i += 1) {
1537
+ if (mask.data[i] === 0) continue;
1538
+ if (img.data[i * 4 + 3] > 0) visible += 1;
1539
+ }
1540
+ return visible / mask.count;
1541
+ }
1542
+
1543
+ /**
1544
+ * 8x8 average-hash (aHash): downsamples the ON-MASK crop pixels to a
1545
+ * `HASH_GRID`x`HASH_GRID` luminance grid, compares each cell to the grid
1546
+ * mean, and packs the resulting 64 bits into 16 hex chars. Off-mask pixels
1547
+ * are excluded from every cell's luminance so a rotated/disjoint element's
1548
+ * hash reflects only its own content. Deterministic for identical on-mask
1549
+ * pixel content — the "stable hash" this collector reports.
1550
+ */
1551
+ function averageHash(img: RawImage, mask: Mask): string {
1552
+ const cells = new Array<number>(HASH_GRID * HASH_GRID).fill(0);
1553
+ const counts = new Array<number>(HASH_GRID * HASH_GRID).fill(0);
1554
+
1555
+ for (let y = 0; y < img.height; y += 1) {
1556
+ const cellY = Math.min(HASH_GRID - 1, Math.floor((y / img.height) * HASH_GRID));
1557
+ for (let x = 0; x < img.width; x += 1) {
1558
+ if (mask.data[y * img.width + x] === 0) continue;
1559
+ const cellX = Math.min(HASH_GRID - 1, Math.floor((x / img.width) * HASH_GRID));
1560
+ const o = (y * img.width + x) * 4;
1561
+ const lum = 0.299 * img.data[o] + 0.587 * img.data[o + 1] + 0.114 * img.data[o + 2];
1562
+ const idx = cellY * HASH_GRID + cellX;
1563
+ cells[idx] += lum;
1564
+ counts[idx] += 1;
1565
+ }
1566
+ }
1567
+
1568
+ const values = cells.map((sum, i) => (counts[i] > 0 ? sum / counts[i] : 0));
1569
+ const mean = values.reduce((a, b) => a + b, 0) / values.length;
1570
+
1571
+ const bytes: number[] = [];
1572
+ let byte = 0;
1573
+ let bitCount = 0;
1574
+ for (const value of values) {
1575
+ byte = (byte << 1) | (value >= mean ? 1 : 0);
1576
+ bitCount += 1;
1577
+ if (bitCount === 8) {
1578
+ bytes.push(byte);
1579
+ byte = 0;
1580
+ bitCount = 0;
1581
+ }
1582
+ }
1583
+ return bytes.map((b) => b.toString(16).padStart(2, '0')).join('');
1584
+ }