@hueest/xray 0.1.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,1071 @@
1
+ import { a as ROOT_ATTR, c as SPEC_INLINE, l as SPEC_LEAF } from "./plate-BuzRMPx4.js";
2
+ //#region src/classify.ts
3
+ /**
4
+ * Turn id-form rules + an id-tree into the shipped plate: each distinct
5
+ * `(tier, media, declarations)` becomes one class, named by a per-plate
6
+ * sequential id (`[data-xr-root] .xr-<plate>-<n>`), and every node carries the
7
+ * class tokens it needs instead of an id (ADR 0007). The id is a plain counter,
8
+ * not a content hash: the plate prefix already keeps tokens disjoint across plates
9
+ * (the stitch-isolation guarantee), so a per-plate counter is collision-free by
10
+ * construction — and low-entropy ids compress far better than random hashes
11
+ * (~31% smaller brotli on a real plate; see the ADR 0007 amendment). Rules stay
12
+ * unlayered; the existing cascade is reproduced by emission order (losers
13
+ * first), so the classes are emitted in `(layered, spec, order)` order exactly
14
+ * as `renderRules` did.
15
+ *
16
+ * Pure data-in/data-out — runs at plugin load (node) and in tests.
17
+ */
18
+ function classify(rules, tree, plateName) {
19
+ const classBase = `${CLASS_PREFIX}${cssSafe(plateName)}-`;
20
+ const sorted = rules.toSorted((a, b) => Number(b.layered ?? false) - Number(a.layered ?? false) || a.spec - b.spec || a.order - b.order);
21
+ const nameForKey = /* @__PURE__ */ new Map();
22
+ const body = /* @__PURE__ */ new Map();
23
+ const usage = /* @__PURE__ */ new Map();
24
+ const emitOrder = [];
25
+ const nodeClasses = /* @__PURE__ */ new Map();
26
+ for (const rule of sorted) {
27
+ if (rule.decls.length === 0) continue;
28
+ const decls = rule.decls.join("; ");
29
+ const media = rule.media ?? [];
30
+ const container = rule.container ?? [];
31
+ const tier = SPEC_FOLD === "full" ? String(rule.spec) : tierLabel(rule.spec);
32
+ const key = `${rule.layered ? "l" : ""} ${tier} ${media.join(" && ")} ${container.join(" && ")} ${decls}`;
33
+ let name = nameForKey.get(key);
34
+ if (name === void 0) {
35
+ name = classBase + nameForKey.size.toString(36);
36
+ nameForKey.set(key, name);
37
+ body.set(name, {
38
+ media,
39
+ container,
40
+ decls
41
+ });
42
+ usage.set(name, {
43
+ root: false,
44
+ desc: false
45
+ });
46
+ emitOrder.push(name);
47
+ }
48
+ const ids = rule.ids.length === 0 ? [0] : rule.ids;
49
+ for (const id of ids) {
50
+ const u = usage.get(name);
51
+ if (!u) throw new Error(`missing usage entry for ${name}`);
52
+ if (id === 0) u.root = true;
53
+ else u.desc = true;
54
+ let list = nodeClasses.get(id);
55
+ if (!list) {
56
+ list = [];
57
+ nodeClasses.set(id, list);
58
+ }
59
+ if (!list.includes(name)) list.push(name);
60
+ }
61
+ }
62
+ const lines = [];
63
+ for (const name of emitOrder) {
64
+ const item = body.get(name);
65
+ const u = usage.get(name);
66
+ if (!item || !u) throw new Error(`missing class body for ${name}`);
67
+ const { media, container, decls } = item;
68
+ const sels = [];
69
+ if (u.root) sels.push(`[${ROOT_ATTR}].${name}`);
70
+ if (u.desc) sels.push(`[${ROOT_ATTR}] .${name}`);
71
+ let text = `${sels.join(", ")} { ${decls} }`;
72
+ for (const condition of media.toReversed()) text = `@media ${condition} { ${text} }`;
73
+ for (const condition of container.toReversed()) text = `@container ${condition} { ${text} }`;
74
+ lines.push(text);
75
+ }
76
+ return {
77
+ tree: render(tree, nodeClasses),
78
+ css: lines.join("\n")
79
+ };
80
+ }
81
+ /** Strip ids, attach content-class tokens — the shipped tree shape (ADR 0007). */
82
+ function render(node, classes) {
83
+ const out = {};
84
+ if (node.leaf) out.leaf = node.leaf;
85
+ if (node.ref !== void 0) out.ref = node.ref;
86
+ const cls = classes.get(node.id);
87
+ if (cls && cls.length > 0) out.cls = cls.join(" ");
88
+ if (node.kids) out.kids = node.kids.map((kid) => render(kid, classes));
89
+ return out;
90
+ }
91
+ /** Content-class prefix; distinct from the fixed `xr-root/node/leaf` base words. */
92
+ const CLASS_PREFIX = "xr-";
93
+ /** Make a plate name safe to embed in a class token (plate names are already constrained). */
94
+ function cssSafe(name) {
95
+ return name.replace(/[^\w-]/g, "_");
96
+ }
97
+ /**
98
+ * What the class identity keys on, alongside media + declarations:
99
+ * - `'tier'`: the discrete cascade tier — smaller (more bodies dedupe), but a
100
+ * rare same-tier inversion within the continuous `author` band can mis-order
101
+ * one property on one node (ADR 0007). The default.
102
+ * - `'full'`: the exact `spec` — provably no inversion, marginally larger.
103
+ * One-line flip if QA ever surfaces a mis-order.
104
+ */
105
+ const SPEC_FOLD = "full";
106
+ function tierLabel(spec) {
107
+ if (spec <= -2) return "ctx";
108
+ if (spec === -1) return "fb";
109
+ if (spec >= 2e7) return "gate";
110
+ if (spec >= 1e7) return "inl";
111
+ if (spec >= 9e6) return "leaf";
112
+ return "au";
113
+ }
114
+ //#endregion
115
+ //#region src/css.ts
116
+ /**
117
+ * Pure CSS plumbing shared by both extraction strategies: which properties are
118
+ * layout (vs paint), approximate selector specificity for cascade-preserving
119
+ * ordering, and rendering scoped rules back to a single plate CSS string.
120
+ */
121
+ const EXACT_PROPS = new Set([
122
+ "display",
123
+ "position",
124
+ "float",
125
+ "clear",
126
+ "box-sizing",
127
+ "top",
128
+ "right",
129
+ "bottom",
130
+ "left",
131
+ "width",
132
+ "height",
133
+ "block-size",
134
+ "inline-size",
135
+ "aspect-ratio",
136
+ "order",
137
+ "flex",
138
+ "gap",
139
+ "row-gap",
140
+ "column-gap",
141
+ "line-height",
142
+ "font",
143
+ "font-size",
144
+ "font-family",
145
+ "font-weight",
146
+ "font-style",
147
+ "white-space",
148
+ "text-align",
149
+ "text-indent",
150
+ "vertical-align",
151
+ "letter-spacing",
152
+ "word-spacing",
153
+ "word-break",
154
+ "overflow-wrap",
155
+ "text-wrap",
156
+ "transform",
157
+ "transform-origin",
158
+ "translate",
159
+ "scale",
160
+ "rotate",
161
+ "table-layout",
162
+ "border-collapse",
163
+ "border-spacing",
164
+ "caption-side",
165
+ "column-count",
166
+ "column-width",
167
+ "columns",
168
+ "column-span",
169
+ "column-fill",
170
+ "writing-mode",
171
+ "direction",
172
+ "contain",
173
+ "content-visibility",
174
+ "container",
175
+ "container-type",
176
+ "container-name",
177
+ "visibility"
178
+ ]);
179
+ const PATTERN_PROPS = [
180
+ /^(margin|padding)(-(top|right|bottom|left|block|inline))?(-(start|end))?$/,
181
+ /^inset(-(block|inline))?(-(start|end))?$/,
182
+ /^border(-(top|right|bottom|left|block|inline))?(-(start|end))?-(width|style)$/,
183
+ /^border(-(top|bottom)-(left|right))?-radius$/,
184
+ /^border-(start|end)-(start|end)-radius$/,
185
+ /^grid(-|$)/,
186
+ /^flex-/,
187
+ /^(min|max)-(width|height|block-size|inline-size)$/,
188
+ /^(align|justify|place)-(items|content|self)$/,
189
+ /^overflow(-[xy])?$/
190
+ ];
191
+ /** Layout properties are the only declarations a plate carries; paint stays out (ADR 0003). */
192
+ function isLayoutProp(prop) {
193
+ if (prop.startsWith("--")) return false;
194
+ if (EXACT_PROPS.has(prop)) return true;
195
+ return PATTERN_PROPS.some((re) => re.test(prop));
196
+ }
197
+ function resolveVars(value, computed) {
198
+ let out = value;
199
+ for (let i = 0; i < 10 && out.includes("var("); i++) {
200
+ const next = out.replace(/var\(\s*(--[\w-]+)\s*(?:,([^()]*))?\)/g, (_, name, fallback) => computed.getPropertyValue(name).trim() || fallback?.trim() || "");
201
+ if (next === out) break;
202
+ out = next;
203
+ }
204
+ return out;
205
+ }
206
+ /**
207
+ * A grid track list that is only `px` lengths is a frozen computed value —
208
+ * authors write `fr`/`%`/`minmax`/`auto`, not sub-pixel tracks, so this is
209
+ * almost always a var()-driven `grid` shorthand the engine dropped, leaving us
210
+ * the resolved computed track sizes. Frozen px locks the grid to the capture
211
+ * width (off-centre at any other size); we can't recover the author value, so we
212
+ * reconstruct a responsive one. Already-fluid lists (fr/%/minmax/repeat/auto/…)
213
+ * pass through untouched.
214
+ *
215
+ * - Rows follow their content in a skeleton (the bones set the height), so a
216
+ * frozen row list becomes `auto` tracks.
217
+ * - Columns are almost always a centred layout: a wide content track flanked by
218
+ * gutters. Make the gutters flexible and equal (`1fr`) so the grid recentres
219
+ * at any width — this also erases the scrollbar-width asymmetry the capture
220
+ * picked up — and cap the content track(s) at the captured size
221
+ * (`minmax(0, px)`) so they shrink on narrow viewports but never overgrow. A
222
+ * uniform list (no track ≥2× the others — e.g. an even `repeat(n, 1fr)`) has
223
+ * no gutters, so every track becomes `1fr`.
224
+ *
225
+ * A single capture can't tell fixed tracks from flexible ones; the precise fix is
226
+ * to diff track sizes across the capture-all widths (a later refinement).
227
+ */
228
+ const PX_ONLY_TRACKS = /^\s*(?:-?[\d.]+px\s*)+$/;
229
+ function fluidTracks(value, axis) {
230
+ if (!PX_ONLY_TRACKS.test(value)) return value;
231
+ const tracks = value.trim().split(/\s+/).map((t) => Number.parseFloat(t));
232
+ if (axis === "rows") return tracks.map(() => "auto").join(" ");
233
+ const max = Math.max(...tracks);
234
+ const isContent = tracks.map((t) => t >= max * .5);
235
+ if (isContent.every(Boolean)) return tracks.map(() => "1fr").join(" ");
236
+ return tracks.map((t, i) => isContent[i] ? `minmax(0, ${t}px)` : "1fr").join(" ");
237
+ }
238
+ /**
239
+ * Extract the layout-relevant declarations of a style block as `prop: value`
240
+ * strings. Custom-property references are resolved against `computed` (the
241
+ * matched element's computed style) — plates carry no `var()` definitions, so
242
+ * references must be frozen to their capture-time value.
243
+ */
244
+ function filterLayoutDecls(style, computed) {
245
+ const out = [];
246
+ for (let i = 0; i < style.length; i++) {
247
+ const prop = style.item(i);
248
+ if (!isLayoutProp(prop)) continue;
249
+ let value = style.getPropertyValue(prop).trim();
250
+ if (value.includes("var(") && computed) value = resolveVars(value, computed).trim();
251
+ if (!value && computed) value = computed.getPropertyValue(prop).trim();
252
+ if (!value) continue;
253
+ if (prop === "grid-template-columns") value = fluidTracks(value, "columns");
254
+ else if (prop === "grid-template-rows") value = fluidTracks(value, "rows");
255
+ const priority = style.getPropertyPriority(prop);
256
+ out.push(`${prop}: ${value}${priority ? " !important" : ""}`);
257
+ }
258
+ return out;
259
+ }
260
+ /**
261
+ * Approximate selector specificity packed as a single comparable number
262
+ * (`a * 1e6 + b * 1e3 + c`). Good enough to order rewritten rules so the
263
+ * original cascade winner still wins; known approximation: `:is()`/`:not()`
264
+ * sum their arguments instead of taking the max.
265
+ */
266
+ function specificity(selector) {
267
+ let a = 0;
268
+ let b = 0;
269
+ let c = 0;
270
+ let s = selector.replace(/(['"]).*?\1/g, " ");
271
+ s = s.replace(/:where\([^()]*\)/g, " ");
272
+ s = s.replace(/:(nth-[a-z-]+|lang|dir)\([^()]*\)/g, () => {
273
+ b++;
274
+ return " ";
275
+ });
276
+ s = s.replace(/:(not|is|has)\(/g, " (");
277
+ s = s.replace(/\[[^\]]*\]/g, () => {
278
+ b++;
279
+ return " ";
280
+ });
281
+ s = s.replace(/#[a-zA-Z][\w-]*/g, () => {
282
+ a++;
283
+ return " ";
284
+ });
285
+ s = s.replace(/\.[a-zA-Z][\w-]*/g, () => {
286
+ b++;
287
+ return " ";
288
+ });
289
+ s = s.replace(/::[a-z-]+/g, () => {
290
+ c++;
291
+ return " ";
292
+ });
293
+ s = s.replace(/:[a-z-]+/g, () => {
294
+ b++;
295
+ return " ";
296
+ });
297
+ for (const _ of s.matchAll(/[a-zA-Z][\w-]*/g)) c++;
298
+ return a * 1e6 + b * 1e3 + c;
299
+ }
300
+ /** Split a selector list on top-level commas (commas inside `()`/`[]`/strings don't split). */
301
+ function splitSelectorList(selectorText) {
302
+ const out = [];
303
+ let depth = 0;
304
+ let quote = "";
305
+ let cur = "";
306
+ for (const ch of selectorText) {
307
+ if (quote) {
308
+ cur += ch;
309
+ if (ch === quote) quote = "";
310
+ continue;
311
+ }
312
+ if (ch === "\"" || ch === "'") {
313
+ quote = ch;
314
+ cur += ch;
315
+ continue;
316
+ }
317
+ if (ch === "(" || ch === "[") depth++;
318
+ else if (ch === ")" || ch === "]") depth--;
319
+ if (ch === "," && depth === 0) {
320
+ if (cur.trim()) out.push(cur.trim());
321
+ cur = "";
322
+ continue;
323
+ }
324
+ cur += ch;
325
+ }
326
+ if (cur.trim()) out.push(cur.trim());
327
+ return out;
328
+ }
329
+ //#endregion
330
+ //#region src/similarity.ts
331
+ const kindCounts = /* @__PURE__ */ new WeakMap();
332
+ /** Descendant tally by kind — the cheap shape summary similarity is scored on. */
333
+ function countsOf(node) {
334
+ const cached = kindCounts.get(node);
335
+ if (cached) return cached;
336
+ const counts = {
337
+ text: 0,
338
+ media: 0,
339
+ box: 0,
340
+ container: 0
341
+ };
342
+ counts[node.leaf ?? "container"]++;
343
+ for (const kid of node.kids ?? []) {
344
+ const kidCounts = countsOf(kid);
345
+ counts.text += kidCounts.text;
346
+ counts.media += kidCounts.media;
347
+ counts.box += kidCounts.box;
348
+ counts.container += kidCounts.container;
349
+ }
350
+ kindCounts.set(node, counts);
351
+ return counts;
352
+ }
353
+ /**
354
+ * How alike two subtrees are, 0..1 — overlap of their descendant kind
355
+ * tallies. The same element rendered at another viewport (or the next item
356
+ * of the same list) keeps roughly the same census; different sections don't.
357
+ */
358
+ function similarity(a, b) {
359
+ if ((a.leaf ?? "container") !== (b.leaf ?? "container")) return 0;
360
+ if (a.leaf) return 1;
361
+ const ca = countsOf(a);
362
+ const cb = countsOf(b);
363
+ let diff = 0;
364
+ let total = 0;
365
+ for (const kind of [
366
+ "text",
367
+ "media",
368
+ "box",
369
+ "container"
370
+ ]) {
371
+ diff += Math.abs(ca[kind] - cb[kind]);
372
+ total += ca[kind] + cb[kind];
373
+ }
374
+ return total === 0 ? 1 : 1 - diff / total;
375
+ }
376
+ //#endregion
377
+ //#region src/serialize.ts
378
+ function isElementNode(node) {
379
+ return node.nodeType === 1;
380
+ }
381
+ function isTextNode(node) {
382
+ return node.nodeType === 3;
383
+ }
384
+ const SKIP_TAGS = new Set([
385
+ "SCRIPT",
386
+ "STYLE",
387
+ "LINK",
388
+ "META",
389
+ "TEMPLATE",
390
+ "NOSCRIPT",
391
+ "TITLE"
392
+ ]);
393
+ const MEDIA_TAGS = new Set([
394
+ "IMG",
395
+ "SVG",
396
+ "VIDEO",
397
+ "CANVAS",
398
+ "PICTURE",
399
+ "IFRAME",
400
+ "OBJECT",
401
+ "EMBED",
402
+ "INPUT",
403
+ "SELECT",
404
+ "TEXTAREA",
405
+ "PROGRESS",
406
+ "METER",
407
+ "AUDIO"
408
+ ]);
409
+ /** Pseudo-classes that only apply under interaction — a static skeleton never needs them. */
410
+ const DYNAMIC_PSEUDO = /:(hover|active|focus|focus-visible|focus-within|visited|link|target|enabled|disabled|checked)\b/;
411
+ /** Pseudo-element rules are skipped for v1 (no box to map them onto). */
412
+ const PSEUDO_ELEMENT = /::?(before|after|placeholder|selection|marker|backdrop|first-line|first-letter|file-selector-button)\b/;
413
+ function px(n) {
414
+ return `${Math.round(n * 10) / 10}px`;
415
+ }
416
+ function isInlineish(display) {
417
+ return display === "inline" || display.startsWith("inline-");
418
+ }
419
+ function pad(value) {
420
+ return Number.parseFloat(value) || 0;
421
+ }
422
+ /** A computed value already resolved to a `px` length, or null (`normal`, a unitless ratio, ''). */
423
+ function pxValue(value) {
424
+ return /(?:^|\s)-?[\d.]+px$/.test(value.trim()) ? Number.parseFloat(value) : null;
425
+ }
426
+ function parseRgb(value) {
427
+ const match = /rgba?\(([^)]+)\)/.exec(value);
428
+ if (!match?.[1]) return null;
429
+ const parts = match[1].split(/[,/]/).map((p) => Number.parseFloat(p.trim()));
430
+ const [r, g, b] = parts;
431
+ if (r === void 0 || g === void 0 || b === void 0) return null;
432
+ return {
433
+ r,
434
+ g,
435
+ b,
436
+ a: parts.length > 3 ? parts[3] ?? 1 : 1
437
+ };
438
+ }
439
+ const rgbCache = /* @__PURE__ */ new Map();
440
+ /**
441
+ * Resolve any computed CSS colour to sRGB bytes. `rgb()/rgba()` parse directly
442
+ * (the cheap path, and the only one a layout-less test env needs); everything
443
+ * else — `oklch()`, `color()`, `hsl()`, named — is painted onto a 1×1 canvas
444
+ * and read back, since browsers serialise a computed colour in the space it was
445
+ * authored in (Chrome returns `oklch(...)` verbatim), which a regex can't read.
446
+ * Without this an oklch-filled control reads as no fill and never becomes a box.
447
+ * Cached by string: the same token recurs across every node of a themed card.
448
+ */
449
+ function toRgb(value, win) {
450
+ const direct = parseRgb(value);
451
+ if (direct) return direct;
452
+ const cached = rgbCache.get(value);
453
+ if (cached !== void 0) return cached;
454
+ let result = null;
455
+ try {
456
+ const ctx = win.document.createElement("canvas").getContext("2d");
457
+ if (ctx) {
458
+ ctx.fillStyle = value;
459
+ ctx.fillRect(0, 0, 1, 1);
460
+ const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
461
+ if (r !== void 0 && g !== void 0 && b !== void 0) result = {
462
+ r,
463
+ g,
464
+ b,
465
+ a: (a ?? 255) / 255
466
+ };
467
+ }
468
+ } catch {
469
+ result = null;
470
+ }
471
+ rgbCache.set(value, result);
472
+ return result;
473
+ }
474
+ /** A background-image that actually paints (a url or gradient), not `none`/`initial`. */
475
+ function hasPaintedImage(cs) {
476
+ return /url\(|gradient\(/.test(cs.backgroundImage);
477
+ }
478
+ function hasVisibleBackground(cs, win) {
479
+ if (hasPaintedImage(cs)) return true;
480
+ const bg = toRgb(cs.backgroundColor, win);
481
+ return bg !== null && bg.a > 0;
482
+ }
483
+ /** Fill that visibly contrasts with the nearest painted ancestor — meaning ~24 per channel. */
484
+ const FILL_CONTRAST = 72;
485
+ /**
486
+ * Does this element paint a fill that stands out from what's behind it? A
487
+ * background image always counts; a background color counts only when it's
488
+ * mostly opaque and clearly differs from the nearest ancestor's background
489
+ * (so a white button on a white card doesn't, but a blue one does).
490
+ */
491
+ function hasContrastingFill(el, cs, win) {
492
+ if (hasPaintedImage(cs)) return true;
493
+ const bg = toRgb(cs.backgroundColor, win);
494
+ if (!bg || bg.a < .5) return false;
495
+ let ancestor = el.parentElement;
496
+ let behind = null;
497
+ while (ancestor) {
498
+ const candidate = toRgb(win.getComputedStyle(ancestor).backgroundColor, win);
499
+ if (candidate && candidate.a > 0) {
500
+ behind = candidate;
501
+ break;
502
+ }
503
+ ancestor = ancestor.parentElement;
504
+ }
505
+ const base = behind ?? {
506
+ r: 255,
507
+ g: 255,
508
+ b: 255,
509
+ a: 1
510
+ };
511
+ return Math.abs(bg.r - base.r) + Math.abs(bg.g - base.g) + Math.abs(bg.b - base.b) > FILL_CONTRAST;
512
+ }
513
+ /**
514
+ * Capture the rendered subtree(s) under a Skeleton into a Plate. `roots` are
515
+ * the component's top-level elements (the children of the layout-neutral
516
+ * wrapper); the synthetic root node (id 0) stands in for the wrapper itself.
517
+ */
518
+ function capture(roots, options) {
519
+ const view = captureRegime(roots);
520
+ const { tree, css } = classify(view.rules, view.tree, options.name);
521
+ return {
522
+ v: 1,
523
+ name: options.name,
524
+ tree,
525
+ css
526
+ };
527
+ }
528
+ /**
529
+ * Thrown when a subtree has more nodes than the capture limit — almost always
530
+ * a `<Skeleton>` sitting too high in the tree. Raised after the cheap walk and
531
+ * before the O(rules × nodes) CSS extraction, so an oversized capture is
532
+ * refused without freezing the main thread on it.
533
+ */
534
+ var CaptureTooLargeError = class extends Error {
535
+ nodeCount;
536
+ constructor(nodeCount) {
537
+ super(`xray: capture has ${nodeCount} nodes, over the limit`);
538
+ this.name = "CaptureTooLargeError";
539
+ this.nodeCount = nodeCount;
540
+ }
541
+ };
542
+ /**
543
+ * Capture one view's worth of a component: the tree plus id-form rules,
544
+ * with the viewport width it was taken at. The caller (the dev client)
545
+ * derives the view interval and ships it off; `capture` is the
546
+ * single-view convenience on top.
547
+ */
548
+ function captureRegime(roots, options = {}) {
549
+ const doc = roots[0]?.ownerDocument;
550
+ const win = doc?.defaultView;
551
+ if (!doc || !win) throw new Error("xray: capture roots must be attached to a document");
552
+ const { tree, entries } = walkAll(roots, win);
553
+ if (options.maxNodes !== void 0 && entries.length > options.maxNodes) throw new CaptureTooLargeError(entries.length);
554
+ return {
555
+ width: win.innerWidth,
556
+ tree,
557
+ rules: copyRules(entries, doc, win)
558
+ };
559
+ }
560
+ /**
561
+ * Walk a live subtree exactly like `capture` does, but return per-node
562
+ * geometry instead of a plate. Rendering the captured plate and auditing both
563
+ * sides gives an id-aligned fidelity diff (used by the M1 bake-off).
564
+ */
565
+ function audit(roots) {
566
+ const win = roots[0]?.ownerDocument?.defaultView;
567
+ if (!win) throw new Error("xray: audit roots must be attached to a document");
568
+ const { entries } = walkAll(roots, win);
569
+ return entries.map((entry) => {
570
+ const rect = entry.el?.getBoundingClientRect();
571
+ return {
572
+ id: entry.id,
573
+ tag: entry.el?.tagName.toLowerCase() ?? "#text",
574
+ ...entry.leaf ? { leaf: entry.leaf } : {},
575
+ w: Math.round(rect?.width ?? 0),
576
+ h: Math.round(rect?.height ?? 0)
577
+ };
578
+ });
579
+ }
580
+ function walkAll(roots, win) {
581
+ const state = {
582
+ nextId: 1,
583
+ entries: [],
584
+ win
585
+ };
586
+ const kids = [];
587
+ for (const root of roots) {
588
+ const node = walk(root, state);
589
+ if (node) kids.push(node);
590
+ }
591
+ const raw = kids.length > 0 ? {
592
+ id: 0,
593
+ kids
594
+ } : { id: 0 };
595
+ const dropped = /* @__PURE__ */ new Set();
596
+ return {
597
+ tree: pruneRuns(raw, dropped),
598
+ entries: dropped.size > 0 ? state.entries.filter((entry) => !dropped.has(entry.id)) : state.entries
599
+ };
600
+ }
601
+ /** A run longer than this collapses down to the first few items. */
602
+ const RUN_LIMIT = 8;
603
+ const RUN_KEEP = 5;
604
+ const RUN_SIMILARITY = .9;
605
+ /**
606
+ * Long runs of similar siblings are lists — a 200-offering grid, an endless
607
+ * feed. A skeleton conventionally suggests a list with a few rows, and
608
+ * capturing every row balloons the plate file, so runs collapse to their
609
+ * first items. The dropped ids are pruned from the entries so no rules are
610
+ * emitted for them.
611
+ */
612
+ function pruneRuns(node, dropped) {
613
+ const kids = node.kids;
614
+ if (!kids || kids.length === 0) return node;
615
+ const pruned = [];
616
+ let i = 0;
617
+ while (i < kids.length) {
618
+ const head = kids[i];
619
+ if (!head) break;
620
+ let j = i + 1;
621
+ while (j < kids.length) {
622
+ const next = kids[j];
623
+ if (!next || similarity(head, next) < RUN_SIMILARITY) break;
624
+ j++;
625
+ }
626
+ const keep = j - i > RUN_LIMIT ? RUN_KEEP : j - i;
627
+ for (let k = i; k < i + keep; k++) {
628
+ const kid = kids[k];
629
+ if (kid) pruned.push(pruneRuns(kid, dropped));
630
+ }
631
+ for (let k = i + keep; k < j; k++) {
632
+ const kid = kids[k];
633
+ if (kid) collectIds(kid, dropped);
634
+ }
635
+ i = j;
636
+ }
637
+ return {
638
+ ...node,
639
+ kids: pruned
640
+ };
641
+ }
642
+ function collectIds(node, out) {
643
+ out.add(node.id);
644
+ for (const kid of node.kids ?? []) collectIds(kid, out);
645
+ }
646
+ function walk(el, state) {
647
+ const tag = el.tagName.toUpperCase();
648
+ if (SKIP_TAGS.has(tag)) return null;
649
+ const cs = state.win.getComputedStyle(el);
650
+ if (cs.display === "none" || cs.visibility === "hidden" || cs.visibility === "collapse") return null;
651
+ const refName = el.getAttribute("data-xr-boundary") ?? el.getAttribute("data-xr-root");
652
+ if (refName) return {
653
+ id: state.nextId++,
654
+ ref: refName
655
+ };
656
+ const entry = {
657
+ id: state.nextId++,
658
+ el,
659
+ fallback: []
660
+ };
661
+ state.entries.push(entry);
662
+ const node = { id: entry.id };
663
+ const rect = el.getBoundingClientRect();
664
+ if (MEDIA_TAGS.has(tag)) {
665
+ entry.leaf = "media";
666
+ node.leaf = "media";
667
+ if (isInlineish(cs.display)) entry.fallback.push("display: inline-block", "vertical-align: middle");
668
+ let { width, height } = rect;
669
+ const parent = el.parentElement;
670
+ if ((width < 8 || height < 8) && parent && parent.childElementCount === 1) {
671
+ const pcs = state.win.getComputedStyle(parent);
672
+ const prect = parent.getBoundingClientRect();
673
+ const pw = prect.width - pad(pcs.paddingLeft) - pad(pcs.paddingRight);
674
+ const ph = prect.height - pad(pcs.paddingTop) - pad(pcs.paddingBottom);
675
+ if (pw * ph > width * height) {
676
+ width = pw;
677
+ height = ph;
678
+ }
679
+ }
680
+ entry.sizeFallback = [
681
+ "box-sizing: border-box",
682
+ `width: ${px(width)}`,
683
+ `height: ${px(height)}`
684
+ ];
685
+ return node;
686
+ }
687
+ const kids = [];
688
+ const entriesBefore = state.entries.length;
689
+ let textRun = [];
690
+ let hasTextBar = false;
691
+ const flushTextRun = () => {
692
+ if (textRun.length === 0) return;
693
+ const kid = textBar(textRun, state);
694
+ if (kid) {
695
+ kids.push(kid);
696
+ hasTextBar = true;
697
+ }
698
+ textRun = [];
699
+ };
700
+ for (const child of Array.from(el.childNodes)) if (isElementNode(child)) {
701
+ flushTextRun();
702
+ const kid = walk(child, state);
703
+ if (kid) kids.push(kid);
704
+ } else if (isTextNode(child)) textRun.push(child);
705
+ flushTextRun();
706
+ if (kids.length > 0) {
707
+ if (kids.every((kid) => kid.leaf !== void 0 && kid.kids === void 0) && hasContrastingFill(el, cs, state.win)) {
708
+ state.entries.length = entriesBefore;
709
+ entry.leaf = "box";
710
+ node.leaf = "box";
711
+ if (isInlineish(cs.display)) entry.fallback.push("display: inline-block");
712
+ else if (cs.display && cs.display !== "block") entry.fallback.push(`display: ${cs.display}`);
713
+ entry.sizeFallback = [
714
+ "box-sizing: border-box",
715
+ `width: ${px(rect.width)}`,
716
+ `height: ${px(rect.height)}`
717
+ ];
718
+ return node;
719
+ }
720
+ node.kids = kids;
721
+ if (cs.display && cs.display !== "block") entry.fallback.push(`display: ${cs.display}`);
722
+ if (hasTextBar) {
723
+ const lh = pxValue(cs.lineHeight);
724
+ if (lh !== null) entry.fallback.push(`line-height: ${px(lh)}`);
725
+ }
726
+ return node;
727
+ }
728
+ const painted = hasVisibleBackground(cs, state.win);
729
+ if (isInlineish(cs.display)) entry.fallback.push("display: inline-block");
730
+ else if (cs.display && cs.display !== "block") entry.fallback.push(`display: ${cs.display}`);
731
+ if (painted) {
732
+ entry.leaf = "box";
733
+ node.leaf = "box";
734
+ entry.sizeFallback = [
735
+ "box-sizing: border-box",
736
+ `width: ${px(rect.width)}`,
737
+ `height: ${px(rect.height)}`
738
+ ];
739
+ } else entry.fallback.push("box-sizing: border-box", `min-width: ${px(rect.width)}`, `min-height: ${px(rect.height)}`);
740
+ return node;
741
+ }
742
+ /** A run of adjacent text nodes becomes one synthetic inline bar sized to its rendered box. */
743
+ function textBar(run, state) {
744
+ const first = run[0];
745
+ const last = run[run.length - 1];
746
+ const doc = first?.ownerDocument;
747
+ if (!first || !last || !doc) return null;
748
+ if (run.every((node) => (node.textContent ?? "").trim() === "")) return null;
749
+ const range = doc.createRange();
750
+ range.setStartBefore(first);
751
+ range.setEndAfter(last);
752
+ const rect = range.getBoundingClientRect();
753
+ const { width, height } = clampToClipping(rect.width, rect.height, first.parentElement, state.win);
754
+ const entry = {
755
+ id: state.nextId++,
756
+ el: null,
757
+ leaf: "text",
758
+ fallback: ["display: inline-block", "vertical-align: middle"]
759
+ };
760
+ if (width > 0 && height > 0) entry.sizeFallback = [`width: ${px(width)}`, `height: ${px(snapToLines(height, first.parentElement, state.win))}`];
761
+ state.entries.push(entry);
762
+ return {
763
+ id: entry.id,
764
+ leaf: "text"
765
+ };
766
+ }
767
+ /**
768
+ * A `Range` rect is the tight union of the glyph line boxes, so a wrapped run
769
+ * measures lines × glyph-height — short of the lines × line-height the source
770
+ * actually occupied (a 2-line paragraph rendered ~3px short per line). When the
771
+ * parent's line-height resolves to px and the run is clearly multi-line, round
772
+ * to a whole number of line boxes so the bone reserves the real block height.
773
+ * A single line is left untouched — its line box comes from the parent's pinned
774
+ * line-height, and the thin glyph bar reads as text rather than a fat block.
775
+ */
776
+ function snapToLines(height, parent, win) {
777
+ if (!parent) return height;
778
+ const lh = pxValue(win.getComputedStyle(parent).lineHeight);
779
+ if (lh === null || lh <= 0 || height < lh * 1.5) return height;
780
+ return Math.round(height / lh) * lh;
781
+ }
782
+ function clampToClipping(width, height, fromParent, win) {
783
+ for (let host = fromParent; host; host = host.parentElement) {
784
+ const cs = win.getComputedStyle(host);
785
+ const clipsX = cs.overflowX !== "visible";
786
+ const clipsY = cs.overflowY !== "visible";
787
+ if (!clipsX && !clipsY) continue;
788
+ const hrect = host.getBoundingClientRect();
789
+ if (clipsY) {
790
+ const inner = hrect.height - pad(cs.paddingTop) - pad(cs.paddingBottom) - pad(cs.borderTopWidth) - pad(cs.borderBottomWidth);
791
+ if (inner > 0) height = Math.min(height, inner);
792
+ }
793
+ if (clipsX) {
794
+ const inner = hrect.width - pad(cs.paddingLeft) - pad(cs.paddingRight) - pad(cs.borderLeftWidth) - pad(cs.borderRightWidth);
795
+ if (inner > 0) width = Math.min(width, inner);
796
+ }
797
+ }
798
+ return {
799
+ width,
800
+ height
801
+ };
802
+ }
803
+ /** The capture-time measurements everything starts from, at the lowest precedence. */
804
+ function baseRules(entries, win) {
805
+ const rules = [];
806
+ let order = 0;
807
+ const context = entries.find((e) => e.el)?.el?.parentElement;
808
+ if (context) {
809
+ const cs = win.getComputedStyle(context);
810
+ rules.push({
811
+ ids: [],
812
+ decls: [
813
+ `font-family: ${cs.fontFamily}`,
814
+ `font-size: ${cs.fontSize}`,
815
+ `font-weight: ${cs.fontWeight}`,
816
+ `line-height: ${cs.lineHeight}`
817
+ ],
818
+ spec: -2,
819
+ order: order++
820
+ });
821
+ }
822
+ for (const entry of entries) {
823
+ if (entry.fallback.length > 0) rules.push({
824
+ ids: [entry.id],
825
+ decls: entry.fallback,
826
+ spec: -1,
827
+ order: order++
828
+ });
829
+ if (entry.sizeFallback && entry.sizeFallback.length > 0) rules.push({
830
+ ids: [entry.id],
831
+ decls: entry.sizeFallback,
832
+ spec: SPEC_LEAF,
833
+ order: order++
834
+ });
835
+ }
836
+ return rules;
837
+ }
838
+ /** `@container <name>? <query>` — the text to re-wrap a lifted rule with. */
839
+ function containerCondition(rule) {
840
+ const query = rule.containerQuery;
841
+ if (query) {
842
+ const name = rule.containerName?.trim();
843
+ return name ? `${name} ${query}` : query;
844
+ }
845
+ const cond = "conditionText" in rule && typeof rule.conditionText === "string" ? rule.conditionText : "";
846
+ if (cond) return cond;
847
+ const condition = /@container\s+([^{]+)\{/.exec(rule.cssText ?? "")?.[1];
848
+ return condition ? condition.trim() : "";
849
+ }
850
+ function isCssStyleRule(rule) {
851
+ return rule.constructor.name === "CSSStyleRule";
852
+ }
853
+ function isCssMediaRule(rule) {
854
+ return rule.constructor.name === "CSSMediaRule";
855
+ }
856
+ function isCssContainerRule(rule) {
857
+ return rule.constructor.name === "CSSContainerRule";
858
+ }
859
+ function isCssSupportsRule(rule) {
860
+ return rule.constructor.name === "CSSSupportsRule";
861
+ }
862
+ function isCssLayerBlockRule(rule) {
863
+ return rule.constructor.name === "CSSLayerBlockRule" && "cssRules" in rule;
864
+ }
865
+ function isCssImportRule(rule) {
866
+ return rule.constructor.name === "CSSImportRule";
867
+ }
868
+ function importLayerName(rule) {
869
+ if (!("layerName" in rule)) return null;
870
+ return typeof rule.layerName === "string" ? rule.layerName : null;
871
+ }
872
+ function supportsCondition(win, condition) {
873
+ if (!("CSS" in win)) return true;
874
+ const css = win.CSS;
875
+ if (typeof css !== "object" || css === null || !("supports" in css)) return true;
876
+ if (typeof css.supports !== "function") return true;
877
+ try {
878
+ return css.supports(condition) === true;
879
+ } catch {
880
+ return true;
881
+ }
882
+ }
883
+ /** Flatten every reachable author rule, tracking media + container conditions and layer membership. */
884
+ function collectStyleRules(doc, win) {
885
+ const out = [];
886
+ const visit = (list, media, container, layered) => {
887
+ if (!list) return;
888
+ for (let i = 0; i < list.length; i++) {
889
+ const rule = list[i];
890
+ if (!rule) continue;
891
+ if (isCssStyleRule(rule)) {
892
+ out.push({
893
+ rule,
894
+ media,
895
+ container,
896
+ layered
897
+ });
898
+ continue;
899
+ }
900
+ if (isCssMediaRule(rule)) {
901
+ visit(rule.cssRules, [...media, rule.media.mediaText], container, layered);
902
+ continue;
903
+ }
904
+ if (isCssContainerRule(rule)) {
905
+ visit(rule.cssRules, media, [...container, containerCondition(rule)], layered);
906
+ continue;
907
+ }
908
+ if (isCssSupportsRule(rule)) {
909
+ if (supportsCondition(win, rule.conditionText)) visit(rule.cssRules, media, container, layered);
910
+ continue;
911
+ }
912
+ if (isCssLayerBlockRule(rule)) {
913
+ visit(rule.cssRules, media, container, true);
914
+ continue;
915
+ }
916
+ if (isCssImportRule(rule)) try {
917
+ const importMedia = rule.media.mediaText;
918
+ visit(rule.styleSheet?.cssRules, importMedia && importMedia !== "all" ? [...media, importMedia] : media, container, layered || importLayerName(rule) != null);
919
+ } catch {}
920
+ }
921
+ };
922
+ const sheets = [...Array.from(doc.styleSheets), ...doc.adoptedStyleSheets ?? []];
923
+ for (const sheet of sheets) {
924
+ const sheetMedia = sheet.media.mediaText;
925
+ try {
926
+ visit(sheet.cssRules, sheetMedia && sheetMedia !== "all" ? [sheetMedia] : [], [], false);
927
+ } catch {}
928
+ }
929
+ return out;
930
+ }
931
+ function safeMatches(el, selector) {
932
+ try {
933
+ return el.matches(selector);
934
+ } catch {
935
+ return false;
936
+ }
937
+ }
938
+ function pushUnit(map, key, unit) {
939
+ const list = map.get(key);
940
+ if (list) list.push(unit);
941
+ else map.set(key, [unit]);
942
+ }
943
+ /** The rightmost compound of a selector — what an element must itself satisfy to match. */
944
+ function rightmostCompound(selector) {
945
+ let depth = 0;
946
+ let quote = "";
947
+ for (let i = selector.length - 1; i >= 0; i--) {
948
+ const ch = selector.charAt(i);
949
+ if (quote) {
950
+ if (ch === quote) quote = "";
951
+ continue;
952
+ }
953
+ if (ch === "\"" || ch === "'") quote = ch;
954
+ else if (ch === ")" || ch === "]") depth++;
955
+ else if (ch === "(" || ch === "[") depth--;
956
+ else if (depth === 0 && (ch === " " || ch === ">" || ch === "+" || ch === "~")) return selector.slice(i + 1);
957
+ }
958
+ return selector;
959
+ }
960
+ /**
961
+ * Index author rules the way a browser's rule hash does: bucket each selector
962
+ * by a token its matching element must carry (id, a class, or tag), so an
963
+ * element only tests the rules that could possibly apply to it. CSS-module
964
+ * class names are unique, so this turns an O(rules × nodes) sweep into a
965
+ * near-linear one. Selectors keyed on nothing concrete (`*`, attribute-only)
966
+ * fall back to a universal bucket tested against every element.
967
+ */
968
+ function indexRules(doc, win) {
969
+ const buckets = {
970
+ byId: /* @__PURE__ */ new Map(),
971
+ byClass: /* @__PURE__ */ new Map(),
972
+ byTag: /* @__PURE__ */ new Map(),
973
+ universal: []
974
+ };
975
+ let order = 1e3;
976
+ for (const found of collectStyleRules(doc, win)) {
977
+ order++;
978
+ for (const selector of splitSelectorList(found.rule.selectorText)) {
979
+ if (DYNAMIC_PSEUDO.test(selector) || PSEUDO_ELEMENT.test(selector)) continue;
980
+ const unit = {
981
+ rule: found.rule,
982
+ selector,
983
+ media: found.media,
984
+ container: found.container,
985
+ layered: found.layered,
986
+ spec: specificity(selector),
987
+ order,
988
+ ids: [],
989
+ decls: null
990
+ };
991
+ const compound = rightmostCompound(selector).replace(/:{1,2}[\w-]+\([^()]*\)/g, "");
992
+ const id = /#(-?[A-Za-z_][\w-]*)/.exec(compound);
993
+ const cls = /\.(-?[A-Za-z_][\w-]*)/.exec(compound);
994
+ const tag = /^(-?[A-Za-z][\w-]*)/.exec(compound);
995
+ const idName = id?.[1];
996
+ const className = cls?.[1];
997
+ const tagName = tag?.[1];
998
+ if (idName) pushUnit(buckets.byId, idName, unit);
999
+ else if (className) pushUnit(buckets.byClass, className, unit);
1000
+ else if (tagName) pushUnit(buckets.byTag, tagName.toLowerCase(), unit);
1001
+ else buckets.universal.push(unit);
1002
+ }
1003
+ }
1004
+ return buckets;
1005
+ }
1006
+ function hasInlineStyle(el) {
1007
+ return "style" in el && typeof el.style === "object" && el.style !== null && "length" in el.style && "getPropertyValue" in el.style && "getPropertyPriority" in el.style;
1008
+ }
1009
+ /** Lift matching author rules, rewritten to plate-local ids (ADR 0005). */
1010
+ function copyRules(entries, doc, win) {
1011
+ const rules = baseRules(entries, win);
1012
+ const elEntries = entries.filter((e) => e.el !== null);
1013
+ const buckets = indexRules(doc, win);
1014
+ const seen = /* @__PURE__ */ new Set();
1015
+ for (const entry of elEntries) {
1016
+ const el = entry.el;
1017
+ seen.clear();
1018
+ const candidates = [];
1019
+ const collect = (list) => {
1020
+ if (!list) return;
1021
+ for (const unit of list) if (!seen.has(unit)) {
1022
+ seen.add(unit);
1023
+ candidates.push(unit);
1024
+ }
1025
+ };
1026
+ if (el.id) collect(buckets.byId.get(el.id));
1027
+ for (const cls of el.classList) collect(buckets.byClass.get(cls));
1028
+ collect(buckets.byTag.get(el.tagName.toLowerCase()));
1029
+ collect(buckets.universal);
1030
+ for (const unit of candidates) {
1031
+ if (!safeMatches(el, unit.selector)) continue;
1032
+ if (unit.decls === null) unit.decls = filterLayoutDecls(unit.rule.style, win.getComputedStyle(el));
1033
+ if (unit.decls.length > 0) unit.ids.push(entry.id);
1034
+ }
1035
+ }
1036
+ for (const unit of allUnits(buckets)) {
1037
+ if (unit.ids.length === 0 || !unit.decls || unit.decls.length === 0) continue;
1038
+ rules.push({
1039
+ ids: unit.ids,
1040
+ decls: unit.decls,
1041
+ media: unit.media,
1042
+ ...unit.container.length > 0 ? { container: unit.container } : {},
1043
+ layered: unit.layered,
1044
+ spec: unit.spec,
1045
+ order: unit.order
1046
+ });
1047
+ }
1048
+ let order = 1e7;
1049
+ for (const entry of elEntries) {
1050
+ if (!hasInlineStyle(entry.el)) continue;
1051
+ const style = entry.el.style;
1052
+ if (!style || style.length === 0) continue;
1053
+ const decls = filterLayoutDecls(style, win.getComputedStyle(entry.el));
1054
+ if (decls.length === 0) continue;
1055
+ rules.push({
1056
+ ids: [entry.id],
1057
+ decls,
1058
+ spec: SPEC_INLINE,
1059
+ order: order++
1060
+ });
1061
+ }
1062
+ return rules;
1063
+ }
1064
+ function* allUnits(buckets) {
1065
+ for (const list of buckets.byId.values()) yield* list;
1066
+ for (const list of buckets.byClass.values()) yield* list;
1067
+ for (const list of buckets.byTag.values()) yield* list;
1068
+ yield* buckets.universal;
1069
+ }
1070
+ //#endregion
1071
+ export { clampToClipping as a, captureRegime as i, audit as n, classify as o, capture as r, CaptureTooLargeError as t };