@hueest/xray 0.2.0 → 0.3.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.
@@ -1,1393 +0,0 @@
1
- import { a as ROOT_ATTR, l as SPEC_INLINE, u as SPEC_LEAF } from "./plate-DoE1HEXp.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}${encodePlateName(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
- /**
94
- * Encode a plate name into a class-token prefix, injectively. This prefix *is*
95
- * the isolation boundary across stitched plates (ADR 0007), so distinct plate
96
- * names must never produce the same prefix — a collision would let one plate's
97
- * `<style>` block reorder another's classes.
98
- *
99
- * Plate names are constrained by `isValidPlateName`: each path segment is
100
- * `\w+(-\w+)*` (word chars with single interior hyphens, no doubled or edge
101
- * hyphen) and segments are joined by `/`. The slash is the only char that is
102
- * not class-safe, so rendering it as `--` is enough — and because no segment
103
- * holds a literal `--` or an edge hyphen, no `-` ever abuts a `/`, so every
104
- * `--` in the prefix unambiguously came from a `/`, making the map injective.
105
- * Keeping `/` visually as `--` also leaves nested names readable in the
106
- * generated CSS (`product--card`).
107
- */
108
- function encodePlateName(name) {
109
- return name.replace(/\//g, "--");
110
- }
111
- /**
112
- * What the class identity keys on, alongside media + declarations:
113
- * - `'tier'`: the discrete cascade tier — smaller (more bodies dedupe), but a
114
- * rare same-tier inversion within the continuous `author` band can mis-order
115
- * one property on one node (ADR 0007). The default.
116
- * - `'full'`: the exact `spec` — provably no inversion, marginally larger.
117
- * One-line flip if QA ever surfaces a mis-order.
118
- */
119
- const SPEC_FOLD = "full";
120
- function tierLabel(spec) {
121
- if (spec <= -2) return "ctx";
122
- if (spec === -1) return "fb";
123
- if (spec >= 2e7) return "gate";
124
- if (spec >= 1e7) return "inl";
125
- if (spec >= 9e6) return "leaf";
126
- return "au";
127
- }
128
- //#endregion
129
- //#region src/css.ts
130
- /**
131
- * Pure CSS plumbing shared by both extraction strategies: which properties are
132
- * layout (vs paint), approximate selector specificity for cascade-preserving
133
- * ordering, and rendering scoped rules back to a single plate CSS string.
134
- */
135
- const EXACT_PROPS = new Set([
136
- "display",
137
- "position",
138
- "float",
139
- "clear",
140
- "box-sizing",
141
- "top",
142
- "right",
143
- "bottom",
144
- "left",
145
- "width",
146
- "height",
147
- "block-size",
148
- "inline-size",
149
- "aspect-ratio",
150
- "order",
151
- "flex",
152
- "gap",
153
- "row-gap",
154
- "column-gap",
155
- "line-height",
156
- "font",
157
- "font-size",
158
- "font-family",
159
- "font-weight",
160
- "font-style",
161
- "white-space",
162
- "text-align",
163
- "text-indent",
164
- "vertical-align",
165
- "letter-spacing",
166
- "word-spacing",
167
- "word-break",
168
- "overflow-wrap",
169
- "text-wrap",
170
- "transform",
171
- "transform-origin",
172
- "translate",
173
- "scale",
174
- "rotate",
175
- "table-layout",
176
- "border-collapse",
177
- "border-spacing",
178
- "caption-side",
179
- "column-count",
180
- "column-width",
181
- "columns",
182
- "column-span",
183
- "column-fill",
184
- "writing-mode",
185
- "direction",
186
- "contain",
187
- "content-visibility",
188
- "container",
189
- "container-type",
190
- "container-name",
191
- "visibility"
192
- ]);
193
- const PATTERN_PROPS = [
194
- /^(margin|padding)(-(top|right|bottom|left|block|inline))?(-(start|end))?$/,
195
- /^inset(-(block|inline))?(-(start|end))?$/,
196
- /^border(-(top|right|bottom|left|block|inline))?(-(start|end))?-(width|style)$/,
197
- /^border(-(top|bottom)-(left|right))?-radius$/,
198
- /^border-(start|end)-(start|end)-radius$/,
199
- /^grid(-|$)/,
200
- /^flex-/,
201
- /^(min|max)-(width|height|block-size|inline-size)$/,
202
- /^(align|justify|place)-(items|content|self)$/,
203
- /^overflow(-[xy])?$/
204
- ];
205
- /** Layout properties are the only declarations a plate carries; paint stays out (ADR 0003). */
206
- function isLayoutProp(prop) {
207
- if (prop.startsWith("--")) return false;
208
- if (EXACT_PROPS.has(prop)) return true;
209
- return PATTERN_PROPS.some((re) => re.test(prop));
210
- }
211
- function resolveVars(value, computed) {
212
- let out = value;
213
- for (let i = 0; i < 10 && out.includes("var("); i++) {
214
- const next = out.replace(/var\(\s*(--[\w-]+)\s*(?:,([^()]*))?\)/g, (_, name, fallback) => computed.getPropertyValue(name).trim() || fallback?.trim() || "");
215
- if (next === out) break;
216
- out = next;
217
- }
218
- return out;
219
- }
220
- /**
221
- * A grid track list that is only `px` lengths is a frozen computed value —
222
- * authors write `fr`/`%`/`minmax`/`auto`, not sub-pixel tracks, so this is
223
- * almost always a var()-driven `grid` shorthand the engine dropped, leaving us
224
- * the resolved computed track sizes. Frozen px locks the grid to the capture
225
- * width (off-centre at any other size); we can't recover the author value, so we
226
- * reconstruct a responsive one. Already-fluid lists (fr/%/minmax/repeat/auto/…)
227
- * pass through untouched.
228
- *
229
- * - Rows follow their content in a skeleton (the bones set the height), so a
230
- * frozen row list becomes `auto` tracks.
231
- * - Columns are almost always a centred layout: a wide content track flanked by
232
- * gutters. Make the gutters flexible and equal (`1fr`) so the grid recentres
233
- * at any width — this also erases the scrollbar-width asymmetry the capture
234
- * picked up — and cap the content track(s) at the captured size
235
- * (`minmax(0, px)`) so they shrink on narrow viewports but never overgrow. A
236
- * uniform list (no track ≥2× the others — e.g. an even `repeat(n, 1fr)`) has
237
- * no gutters, so every track becomes `1fr`.
238
- *
239
- * A single capture can't tell fixed tracks from flexible ones; the precise fix is
240
- * to diff track sizes across the capture-all widths (a later refinement).
241
- */
242
- const PX_ONLY_TRACKS = /^\s*(?:-?[\d.]+px\s*)+$/;
243
- function fluidTracks(value, axis) {
244
- if (!PX_ONLY_TRACKS.test(value)) return value;
245
- const tracks = value.trim().split(/\s+/).map((t) => Number.parseFloat(t));
246
- if (axis === "rows") return tracks.map(() => "auto").join(" ");
247
- const max = Math.max(...tracks);
248
- const isContent = tracks.map((t) => t >= max * .5);
249
- if (isContent.every(Boolean)) return tracks.map(() => "1fr").join(" ");
250
- return tracks.map((t, i) => isContent[i] ? `minmax(0, ${t}px)` : "1fr").join(" ");
251
- }
252
- /**
253
- * Extract the layout-relevant declarations of a style block as `prop: value`
254
- * strings. Custom-property references are resolved against `computed` (the
255
- * matched element's computed style) — plates carry no `var()` definitions, so
256
- * references must be frozen to their capture-time value.
257
- */
258
- function filterLayoutDecls(style, computed) {
259
- const out = [];
260
- for (let i = 0; i < style.length; i++) {
261
- const prop = style.item(i);
262
- if (!isLayoutProp(prop)) continue;
263
- let value = style.getPropertyValue(prop).trim();
264
- if (value.includes("var(") && computed) value = resolveVars(value, computed).trim();
265
- if (!value && computed) value = computed.getPropertyValue(prop).trim();
266
- if (!value) continue;
267
- if (prop === "grid-template-columns") value = fluidTracks(value, "columns");
268
- else if (prop === "grid-template-rows") value = fluidTracks(value, "rows");
269
- const priority = style.getPropertyPriority(prop);
270
- out.push(`${prop}: ${value}${priority ? " !important" : ""}`);
271
- }
272
- return out;
273
- }
274
- /**
275
- * Approximate selector specificity packed as a single comparable number
276
- * (`a * 1e6 + b * 1e3 + c`). Good enough to order rewritten rules so the
277
- * original cascade winner still wins; known approximation: `:is()`/`:not()`
278
- * sum their arguments instead of taking the max.
279
- */
280
- function specificity(selector) {
281
- let a = 0;
282
- let b = 0;
283
- let c = 0;
284
- let s = selector.replace(/(['"]).*?\1/g, " ");
285
- s = s.replace(/:where\([^()]*\)/g, " ");
286
- s = s.replace(/:(nth-[a-z-]+|lang|dir)\([^()]*\)/g, () => {
287
- b++;
288
- return " ";
289
- });
290
- s = s.replace(/:(not|is|has)\(/g, " (");
291
- s = s.replace(/\[[^\]]*\]/g, () => {
292
- b++;
293
- return " ";
294
- });
295
- s = s.replace(/#[a-zA-Z][\w-]*/g, () => {
296
- a++;
297
- return " ";
298
- });
299
- s = s.replace(/\.[a-zA-Z][\w-]*/g, () => {
300
- b++;
301
- return " ";
302
- });
303
- s = s.replace(/::[a-z-]+/g, () => {
304
- c++;
305
- return " ";
306
- });
307
- s = s.replace(/:[a-z-]+/g, () => {
308
- b++;
309
- return " ";
310
- });
311
- for (const _ of s.matchAll(/[a-zA-Z][\w-]*/g)) c++;
312
- return a * 1e6 + b * 1e3 + c;
313
- }
314
- /** Split a selector list on top-level commas (commas inside `()`/`[]`/strings don't split). */
315
- function splitSelectorList(selectorText) {
316
- const out = [];
317
- let depth = 0;
318
- let quote = "";
319
- let cur = "";
320
- for (const ch of selectorText) {
321
- if (quote) {
322
- cur += ch;
323
- if (ch === quote) quote = "";
324
- continue;
325
- }
326
- if (ch === "\"" || ch === "'") {
327
- quote = ch;
328
- cur += ch;
329
- continue;
330
- }
331
- if (ch === "(" || ch === "[") depth++;
332
- else if (ch === ")" || ch === "]") depth--;
333
- if (ch === "," && depth === 0) {
334
- if (cur.trim()) out.push(cur.trim());
335
- cur = "";
336
- continue;
337
- }
338
- cur += ch;
339
- }
340
- if (cur.trim()) out.push(cur.trim());
341
- return out;
342
- }
343
- //#endregion
344
- //#region src/similarity.ts
345
- const kindCounts = /* @__PURE__ */ new WeakMap();
346
- /** Descendant tally by kind — the cheap shape summary similarity is scored on. */
347
- function countsOf(node) {
348
- const cached = kindCounts.get(node);
349
- if (cached) return cached;
350
- const counts = {
351
- text: 0,
352
- media: 0,
353
- box: 0,
354
- container: 0
355
- };
356
- counts[node.leaf ?? "container"]++;
357
- for (const kid of node.kids ?? []) {
358
- const kidCounts = countsOf(kid);
359
- counts.text += kidCounts.text;
360
- counts.media += kidCounts.media;
361
- counts.box += kidCounts.box;
362
- counts.container += kidCounts.container;
363
- }
364
- kindCounts.set(node, counts);
365
- return counts;
366
- }
367
- /**
368
- * How alike two subtrees are, 0..1 — overlap of their descendant kind
369
- * tallies. The same element rendered at another viewport (or the next item
370
- * of the same list) keeps roughly the same census; different sections don't.
371
- */
372
- function similarity(a, b) {
373
- if ((a.leaf ?? "container") !== (b.leaf ?? "container")) return 0;
374
- if (a.leaf) return 1;
375
- const ca = countsOf(a);
376
- const cb = countsOf(b);
377
- let diff = 0;
378
- let total = 0;
379
- for (const kind of [
380
- "text",
381
- "media",
382
- "box",
383
- "container"
384
- ]) {
385
- diff += Math.abs(ca[kind] - cb[kind]);
386
- total += ca[kind] + cb[kind];
387
- }
388
- return total === 0 ? 1 : 1 - diff / total;
389
- }
390
- //#endregion
391
- //#region src/diagnostics.ts
392
- function createDiagnostics() {
393
- const byCode = /* @__PURE__ */ new Map();
394
- return {
395
- add(code, options) {
396
- const bump = options?.count ?? 1;
397
- const existing = byCode.get(code);
398
- if (existing) {
399
- existing.count = (existing.count ?? 0) + bump;
400
- return;
401
- }
402
- byCode.set(code, {
403
- code,
404
- message: DIAGNOSTIC_MESSAGES[code],
405
- count: bump,
406
- ...options?.detail !== void 0 ? { detail: options.detail } : {}
407
- });
408
- },
409
- list() {
410
- return [...byCode.values()];
411
- }
412
- };
413
- }
414
- /**
415
- * Build a single diagnostic record with its canonical message, for callers
416
- * outside the walk that surface a diagnostic directly (e.g. the client mapping
417
- * `CaptureTooLargeError`, or the server reporting an invalid committed plate).
418
- * Uses the same message table as the collector so the text is identical
419
- * everywhere. `detail` must never carry user CSS or captured text.
420
- */
421
- function makeDiagnostic(code, options) {
422
- return {
423
- code,
424
- message: DIAGNOSTIC_MESSAGES[code],
425
- ...options?.count !== void 0 ? { count: options.count } : {},
426
- ...options?.detail !== void 0 ? { detail: options.detail } : {}
427
- };
428
- }
429
- /**
430
- * The human-readable message per code, shared verbatim by browser and server so
431
- * their text cannot drift. Messages describe the omission and its fidelity
432
- * cost; none echoes user CSS or captured text. `formatDiagnostic` appends the
433
- * aggregate count and any short detail.
434
- */
435
- const DIAGNOSTIC_MESSAGES = {
436
- "unreadable-stylesheet": "could not read a stylesheet (likely cross-origin); rules from it were not captured",
437
- "unreadable-import": "could not read an @import (likely cross-origin); its rules were not captured",
438
- "skipped-dynamic-pseudo": "skipped interaction pseudo-class selectors (:hover, :focus, etc.) — a static skeleton never enters those states",
439
- "skipped-pseudo-element": "skipped pseudo-element selectors (::before, ::after, etc.) — v1 has no box to map them onto",
440
- "unsupported-rule": "skipped CSS rules of a kind the serializer does not lift",
441
- "dropped-tag": "dropped hidden or non-visual elements (display:none, visibility:hidden, script/style/etc.)",
442
- "pruned-run": "collapsed long runs of similar siblings (a list/grid) to their first few items to keep the plate small",
443
- "too-large": "capture exceeded the node limit and was skipped; the <Skeleton> likely sits too high in the tree",
444
- "invalid-plate-file": "a committed plate file could not be read (corrupt JSON, wrong shape, or a version mismatch) and was ignored"
445
- };
446
- /**
447
- * The single shared formatter. Renders one diagnostic to a stable one-line
448
- * string used IDENTICALLY by the browser console and the Vite server output, so
449
- * the two can never disagree. Shape: `<message> (<count>)[: <detail>]`. Count is
450
- * omitted when 1 or absent; detail is appended only when present and is never
451
- * user content.
452
- */
453
- function formatDiagnostic(diagnostic) {
454
- const count = diagnostic.count !== void 0 && diagnostic.count > 1 ? ` (${diagnostic.count})` : "";
455
- const detail = diagnostic.detail !== void 0 ? `: ${diagnostic.detail}` : "";
456
- return `${diagnostic.message}${count}${detail}`;
457
- }
458
- /** The grouped-warning header for a plate's diagnostics, shared by browser and server. */
459
- function diagnosticsHeader(name) {
460
- return `[xray] "${name}" captured with reduced fidelity:`;
461
- }
462
- //#endregion
463
- //#region src/serialize.ts
464
- /**
465
- * Capture-only DOM annotations (ADR 0018), read DURING the walk to shape the
466
- * `PlateNode` tree and NEVER written into the tree, the Plate, or the persisted
467
- * JSON. They are the declarative 90% of capture customization; the programmable
468
- * `captureWalker` is the 10% escape hatch.
469
- *
470
- * - `data-xr-ignore` drops the element AND its subtree (it contributes no bone).
471
- * - `data-xr-bone-kind="text|media|box"` collapses the element's whole subtree
472
- * into ONE Bone leaf of the given kind, sized from the element's own box.
473
- *
474
- * Both are subordinate to the stitch guard (ADR 0006): a nested `<Skeleton>`
475
- * boundary always becomes a stitch first, so neither annotation can make a
476
- * boundary leak into the parent plate. See `walk`.
477
- */
478
- const XR_IGNORE_ATTR = "data-xr-ignore";
479
- const XR_BONE_KIND_ATTR = "data-xr-bone-kind";
480
- /** The `LeafKind` values an author may legally name in `data-xr-bone-kind`. */
481
- const LEAF_KINDS = [
482
- "text",
483
- "media",
484
- "box"
485
- ];
486
- /** Narrow an arbitrary `data-xr-bone-kind` string to a `LeafKind`, or null if unknown. */
487
- function asLeafKind(value) {
488
- return LEAF_KINDS.find((kind) => kind === value) ?? null;
489
- }
490
- /** Reinterpret the internal decision as the opaque result at the ctx/next boundary. Zero runtime cost. */
491
- function wrap(node) {
492
- return node;
493
- }
494
- /** Recover the internal decision from an opaque result inside the engine. Zero runtime cost. */
495
- function unwrap(result) {
496
- return result;
497
- }
498
- /**
499
- * Typed identity wrapper for authoring a capture walker (ADR 0018) — the
500
- * `defineConfig` pattern. It returns its argument unchanged at runtime; its only
501
- * job is to infer and check the `XrayCaptureWalker` shape at the call site so an
502
- * author gets completion and a typo in a hook name is caught.
503
- */
504
- function defineXrayCaptureWalker(walker) {
505
- return walker;
506
- }
507
- /**
508
- * A per-capture memo of `getComputedStyle(el)` keyed by element, scoped to ONE
509
- * synchronous capture (capture performance plan, piece 5). The walk reads each
510
- * entry node's own style through here, and the ancestor-walking helpers
511
- * (contrast, line-height, clipping) memoize the parents they climb. A
512
- * `CSSStyleDeclaration` is a live view, so this WeakMap lives only as long as
513
- * the single capture that built it and is then dropped — it is NEVER cached
514
- * across captures or time (a stored style would silently go stale; the plan
515
- * forbids it). `get` lazily computes and stores on first lookup.
516
- */
517
- var StyleCache = class {
518
- #win;
519
- #map = /* @__PURE__ */ new WeakMap();
520
- constructor(win) {
521
- this.#win = win;
522
- }
523
- get(el) {
524
- const hit = this.#map.get(el);
525
- if (hit) return hit;
526
- const cs = this.#win.getComputedStyle(el);
527
- this.#map.set(el, cs);
528
- return cs;
529
- }
530
- };
531
- function isElementNode(node) {
532
- return node.nodeType === 1;
533
- }
534
- function isTextNode(node) {
535
- return node.nodeType === 3;
536
- }
537
- const SKIP_TAGS = new Set([
538
- "SCRIPT",
539
- "STYLE",
540
- "LINK",
541
- "META",
542
- "TEMPLATE",
543
- "NOSCRIPT",
544
- "TITLE"
545
- ]);
546
- const MEDIA_TAGS = new Set([
547
- "IMG",
548
- "SVG",
549
- "VIDEO",
550
- "CANVAS",
551
- "PICTURE",
552
- "IFRAME",
553
- "OBJECT",
554
- "EMBED",
555
- "INPUT",
556
- "SELECT",
557
- "TEXTAREA",
558
- "PROGRESS",
559
- "METER",
560
- "AUDIO"
561
- ]);
562
- /** Pseudo-classes that only apply under interaction — a static skeleton never needs them. */
563
- const DYNAMIC_PSEUDO = /:(hover|active|focus|focus-visible|focus-within|visited|link|target|enabled|disabled|checked)\b/;
564
- /** Pseudo-element rules are skipped for v1 (no box to map them onto). */
565
- const PSEUDO_ELEMENT = /::?(before|after|placeholder|selection|marker|backdrop|first-line|first-letter|file-selector-button)\b/;
566
- function px(n) {
567
- return `${Math.round(n * 10) / 10}px`;
568
- }
569
- function isInlineish(display) {
570
- return display === "inline" || display.startsWith("inline-");
571
- }
572
- function pad(value) {
573
- return Number.parseFloat(value) || 0;
574
- }
575
- /** A computed value already resolved to a `px` length, or null (`normal`, a unitless ratio, ''). */
576
- function pxValue(value) {
577
- return /(?:^|\s)-?[\d.]+px$/.test(value.trim()) ? Number.parseFloat(value) : null;
578
- }
579
- function parseRgb(value) {
580
- const match = /rgba?\(([^)]+)\)/.exec(value);
581
- if (!match?.[1]) return null;
582
- const parts = match[1].split(/[,/]/).map((p) => Number.parseFloat(p.trim()));
583
- const [r, g, b] = parts;
584
- if (r === void 0 || g === void 0 || b === void 0) return null;
585
- return {
586
- r,
587
- g,
588
- b,
589
- a: parts.length > 3 ? parts[3] ?? 1 : 1
590
- };
591
- }
592
- const rgbCache = /* @__PURE__ */ new Map();
593
- /**
594
- * Resolve any computed CSS colour to sRGB bytes. `rgb()/rgba()` parse directly
595
- * (the cheap path, and the only one a layout-less test env needs); everything
596
- * else — `oklch()`, `color()`, `hsl()`, named — is painted onto a 1×1 canvas
597
- * and read back, since browsers serialise a computed colour in the space it was
598
- * authored in (Chrome returns `oklch(...)` verbatim), which a regex can't read.
599
- * Without this an oklch-filled control reads as no fill and never becomes a box.
600
- * Cached by string: the same token recurs across every node of a themed card.
601
- */
602
- function toRgb(value, win) {
603
- const direct = parseRgb(value);
604
- if (direct) return direct;
605
- const cached = rgbCache.get(value);
606
- if (cached !== void 0) return cached;
607
- let result = null;
608
- try {
609
- const ctx = win.document.createElement("canvas").getContext("2d");
610
- if (ctx) {
611
- ctx.fillStyle = value;
612
- ctx.fillRect(0, 0, 1, 1);
613
- const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
614
- if (r !== void 0 && g !== void 0 && b !== void 0) result = {
615
- r,
616
- g,
617
- b,
618
- a: (a ?? 255) / 255
619
- };
620
- }
621
- } catch {
622
- result = null;
623
- }
624
- rgbCache.set(value, result);
625
- return result;
626
- }
627
- /** A background-image that actually paints (a url or gradient), not `none`/`initial`. */
628
- function hasPaintedImage(cs) {
629
- return /url\(|gradient\(/.test(cs.backgroundImage);
630
- }
631
- function hasVisibleBackground(cs, win) {
632
- if (hasPaintedImage(cs)) return true;
633
- const bg = toRgb(cs.backgroundColor, win);
634
- return bg !== null && bg.a > 0;
635
- }
636
- /** Fill that visibly contrasts with the nearest painted ancestor — meaning ~24 per channel. */
637
- const FILL_CONTRAST = 72;
638
- /**
639
- * Does this element paint a fill that stands out from what's behind it? A
640
- * background image always counts; a background color counts only when it's
641
- * mostly opaque and clearly differs from the nearest ancestor's background
642
- * (so a white button on a white card doesn't, but a blue one does).
643
- */
644
- function hasContrastingFill(el, cs, win, styles) {
645
- if (hasPaintedImage(cs)) return true;
646
- const bg = toRgb(cs.backgroundColor, win);
647
- if (!bg || bg.a < .5) return false;
648
- let ancestor = el.parentElement;
649
- let behind = null;
650
- while (ancestor) {
651
- const candidate = toRgb(styles.get(ancestor).backgroundColor, win);
652
- if (candidate && candidate.a > 0) {
653
- behind = candidate;
654
- break;
655
- }
656
- ancestor = ancestor.parentElement;
657
- }
658
- const base = behind ?? {
659
- r: 255,
660
- g: 255,
661
- b: 255,
662
- a: 1
663
- };
664
- return Math.abs(bg.r - base.r) + Math.abs(bg.g - base.g) + Math.abs(bg.b - base.b) > FILL_CONTRAST;
665
- }
666
- /**
667
- * Capture the rendered subtree(s) under a Skeleton into a Plate. `roots` are
668
- * the component's top-level elements (the children of the layout-neutral
669
- * wrapper); the synthetic root node (id 0) stands in for the wrapper itself.
670
- */
671
- function capture(roots, options) {
672
- const view = captureRegime(roots, {
673
- walker: options.walker,
674
- captureRootIsBoundary: options.captureRootIsBoundary
675
- });
676
- const { tree, css } = classify(view.rules, view.tree, options.name);
677
- return {
678
- v: 1,
679
- name: options.name,
680
- tree,
681
- css
682
- };
683
- }
684
- /**
685
- * Thrown when a subtree has more nodes than the capture limit — almost always
686
- * a `<Skeleton>` sitting too high in the tree. Raised after the cheap walk and
687
- * before the O(rules × nodes) CSS extraction, so an oversized capture is
688
- * refused without freezing the main thread on it.
689
- */
690
- var CaptureTooLargeError = class extends Error {
691
- nodeCount;
692
- /** Folds the oversized-capture warning into the shared diagnostics vocabulary (diagnostics.ts). */
693
- code = "too-large";
694
- constructor(nodeCount) {
695
- super(`xray: capture has ${nodeCount} nodes, over the limit`);
696
- this.name = "CaptureTooLargeError";
697
- this.nodeCount = nodeCount;
698
- }
699
- };
700
- /**
701
- * Capture one view's worth of a component: the tree plus id-form rules,
702
- * with the viewport width it was taken at. The caller (the dev client)
703
- * derives the view interval and ships it off; `capture` is the
704
- * single-view convenience on top.
705
- */
706
- function captureRegime(roots, options = {}) {
707
- const doc = roots[0]?.ownerDocument;
708
- const win = doc?.defaultView;
709
- if (!doc || !win) throw new Error("xray: capture roots must be attached to a document");
710
- const diagnostics = createDiagnostics();
711
- const styles = new StyleCache(win);
712
- const { tree, entries } = walkAll(roots, win, styles, diagnostics, options.walker, options.captureRootIsBoundary ?? false);
713
- if (options.maxNodes !== void 0 && entries.length > options.maxNodes) throw new CaptureTooLargeError(entries.length);
714
- const rules = copyRules(entries, doc, win, styles, diagnostics);
715
- const list = diagnostics.list();
716
- return {
717
- width: win.innerWidth,
718
- tree,
719
- rules,
720
- ...list.length > 0 ? { diagnostics: list } : {}
721
- };
722
- }
723
- function walkAll(roots, win, styles, diagnostics, walker, captureRootIsBoundary = false) {
724
- const state = {
725
- nextId: 1,
726
- entries: [],
727
- win,
728
- styles,
729
- diagnostics,
730
- walker
731
- };
732
- const kids = [];
733
- for (const root of roots) {
734
- const node = walk(root, state, captureRootIsBoundary);
735
- if (node) kids.push(node);
736
- }
737
- const raw = kids.length > 0 ? {
738
- id: 0,
739
- kids
740
- } : { id: 0 };
741
- const dropped = /* @__PURE__ */ new Set();
742
- return {
743
- tree: pruneRuns(raw, dropped, diagnostics),
744
- entries: dropped.size > 0 ? state.entries.filter((entry) => !dropped.has(entry.id)) : state.entries
745
- };
746
- }
747
- /** A run longer than this collapses down to the first few items. */
748
- const RUN_LIMIT = 8;
749
- const RUN_KEEP = 5;
750
- const RUN_SIMILARITY = .9;
751
- /**
752
- * Long runs of similar siblings are lists — a 200-offering grid, an endless
753
- * feed. A skeleton conventionally suggests a list with a few rows, and
754
- * capturing every row balloons the plate file, so runs collapse to their
755
- * first items. The dropped ids are pruned from the entries so no rules are
756
- * emitted for them.
757
- */
758
- function pruneRuns(node, dropped, diagnostics) {
759
- const kids = node.kids;
760
- if (!kids || kids.length === 0) return node;
761
- const pruned = [];
762
- let i = 0;
763
- while (i < kids.length) {
764
- const head = kids[i];
765
- if (!head) break;
766
- let j = i + 1;
767
- while (j < kids.length) {
768
- const next = kids[j];
769
- if (!next || similarity(head, next) < RUN_SIMILARITY) break;
770
- j++;
771
- }
772
- const keep = j - i > RUN_LIMIT ? RUN_KEEP : j - i;
773
- if (j - i > keep) diagnostics.add("pruned-run", { count: j - i - keep });
774
- for (let k = i; k < i + keep; k++) {
775
- const kid = kids[k];
776
- if (kid) pruned.push(pruneRuns(kid, dropped, diagnostics));
777
- }
778
- for (let k = i + keep; k < j; k++) {
779
- const kid = kids[k];
780
- if (kid) collectIds(kid, dropped);
781
- }
782
- i = j;
783
- }
784
- return {
785
- ...node,
786
- kids: pruned
787
- };
788
- }
789
- function collectIds(node, out) {
790
- out.add(node.id);
791
- for (const kid of node.kids ?? []) collectIds(kid, out);
792
- }
793
- function walk(el, state, isCaptureRoot = false) {
794
- const tag = el.tagName.toUpperCase();
795
- if (SKIP_TAGS.has(tag)) {
796
- state.diagnostics.add("dropped-tag");
797
- return null;
798
- }
799
- const cs = state.styles.get(el);
800
- if (cs.display === "none" || cs.visibility === "hidden" || cs.visibility === "collapse") {
801
- state.diagnostics.add("dropped-tag");
802
- return null;
803
- }
804
- if (!isCaptureRoot) {
805
- const refName = el.getAttribute("data-xr-boundary") ?? el.getAttribute("data-xr-root");
806
- if (refName) return {
807
- id: state.nextId++,
808
- ref: refName
809
- };
810
- }
811
- const walker = state.walker;
812
- if (walker?.element) {
813
- const next = () => wrap(annotationWalk(el, cs, state));
814
- const ctx = {
815
- el,
816
- ignore: () => wrap(ignoreElement(state)),
817
- bone: (opts) => wrap(boneElement(el, cs, opts?.kind, state)),
818
- walk: next
819
- };
820
- return unwrap(walker.element(ctx, next));
821
- }
822
- return annotationWalk(el, cs, state);
823
- }
824
- /**
825
- * The built-in annotation walker (ADR 0018), the rung between the custom walker
826
- * and the default classifier. It honors the capture-only `data-xr-*`
827
- * annotations and otherwise falls through to the default classifier. The stitch
828
- * guard already ran in `walk`, so a boundary element never reaches here — an
829
- * annotation can never override a nested `<Skeleton>` (ADR 0006).
830
- */
831
- function annotationWalk(el, cs, state) {
832
- if (el.hasAttribute(XR_IGNORE_ATTR)) return ignoreElement(state);
833
- const kindAttr = el.getAttribute(XR_BONE_KIND_ATTR);
834
- if (kindAttr !== null) {
835
- const kind = asLeafKind(kindAttr.trim());
836
- if (kind !== null) return boneElement(el, cs, kind, state);
837
- state.diagnostics.add("dropped-tag");
838
- }
839
- return defaultClassify(el, cs, state);
840
- }
841
- /**
842
- * `ctx.ignore()` / `data-xr-ignore`: drop the element and its subtree. No Entry,
843
- * no node, no descent — it contributes nothing to the Plate. Aggregated under
844
- * the existing `dropped-tag` diagnostic (a hidden/non-visual drop is the same
845
- * fidelity story; do not spam a new code per ignored element).
846
- */
847
- function ignoreElement(state) {
848
- state.diagnostics.add("dropped-tag");
849
- return null;
850
- }
851
- /**
852
- * `ctx.bone()` / `data-xr-bone-kind`: collapse the element's whole subtree into
853
- * ONE Bone leaf, sized from the element's own measured box (ADR 0018). When
854
- * `kind` is omitted it shares the default classifier's kind-inference heuristic
855
- * (`inferLeafKind`, ONE code path — Q4). The subtree is NOT walked: this is the
856
- * point of the helper. Reuses the default classifier's leaf sizing so the bone
857
- * reserves the real space.
858
- */
859
- function boneElement(el, cs, kind, state) {
860
- const entry = {
861
- id: state.nextId++,
862
- el,
863
- fallback: [],
864
- cs
865
- };
866
- state.entries.push(entry);
867
- const leaf = kind ?? inferLeafKind(el);
868
- const rect = el.getBoundingClientRect();
869
- entry.leaf = leaf;
870
- if (isInlineish(cs.display)) {
871
- entry.fallback.push("display: inline-block");
872
- if (leaf === "media") entry.fallback.push("vertical-align: middle");
873
- } else if (cs.display && cs.display !== "block") entry.fallback.push(`display: ${cs.display}`);
874
- entry.sizeFallback = [
875
- "box-sizing: border-box",
876
- `width: ${px(rect.width)}`,
877
- `height: ${px(rect.height)}`
878
- ];
879
- return {
880
- id: entry.id,
881
- leaf
882
- };
883
- }
884
- /**
885
- * The kind-inference heuristic shared by `ctx.bone()` without an explicit kind
886
- * and the default classifier (ADR 0018, Q4 — ONE code path). A media tag is
887
- * `media`; anything else collapsed to a single bone reads as a `box`. The
888
- * default classifier reaches the same outcomes through its own structural
889
- * branches (MEDIA_TAGS -> media, contrasting fill / painted empty -> box), so
890
- * keeping this one function is the single source of truth for "what kind is an
891
- * element that has no children to descend into".
892
- */
893
- function inferLeafKind(el) {
894
- return MEDIA_TAGS.has(el.tagName.toUpperCase()) ? "media" : "box";
895
- }
896
- /**
897
- * The DEFAULT classifier — the unchanged step-4 body, extracted so the
898
- * precedence ladder's `next()` can call it (ADR 0018). With no walker and no
899
- * annotations this is the only path taken, and its output is byte-identical to
900
- * before the ladder was introduced.
901
- */
902
- function defaultClassify(el, cs, state) {
903
- const tag = el.tagName.toUpperCase();
904
- const entry = {
905
- id: state.nextId++,
906
- el,
907
- fallback: [],
908
- cs
909
- };
910
- state.entries.push(entry);
911
- const node = { id: entry.id };
912
- const rect = el.getBoundingClientRect();
913
- if (MEDIA_TAGS.has(tag)) {
914
- entry.leaf = "media";
915
- node.leaf = "media";
916
- if (isInlineish(cs.display)) entry.fallback.push("display: inline-block", "vertical-align: middle");
917
- let { width, height } = rect;
918
- const parent = el.parentElement;
919
- if ((width < 8 || height < 8) && parent && parent.childElementCount === 1) {
920
- const pcs = state.styles.get(parent);
921
- const prect = parent.getBoundingClientRect();
922
- const pw = prect.width - pad(pcs.paddingLeft) - pad(pcs.paddingRight);
923
- const ph = prect.height - pad(pcs.paddingTop) - pad(pcs.paddingBottom);
924
- if (pw * ph > width * height) {
925
- width = pw;
926
- height = ph;
927
- }
928
- }
929
- entry.sizeFallback = [
930
- "box-sizing: border-box",
931
- `width: ${px(width)}`,
932
- `height: ${px(height)}`
933
- ];
934
- return node;
935
- }
936
- const kids = [];
937
- const entriesBefore = state.entries.length;
938
- let textRun = [];
939
- let hasTextBar = false;
940
- const flushTextRun = () => {
941
- if (textRun.length === 0) return;
942
- const kid = textBar(textRun, state);
943
- if (kid) {
944
- kids.push(kid);
945
- hasTextBar = true;
946
- }
947
- textRun = [];
948
- };
949
- for (const child of Array.from(el.childNodes)) if (isElementNode(child)) {
950
- flushTextRun();
951
- const kid = walk(child, state);
952
- if (kid) kids.push(kid);
953
- } else if (isTextNode(child)) textRun.push(child);
954
- flushTextRun();
955
- if (kids.length > 0) {
956
- if (kids.every((kid) => kid.leaf !== void 0 && kid.kids === void 0) && hasContrastingFill(el, cs, state.win, state.styles)) {
957
- state.entries.length = entriesBefore;
958
- entry.leaf = "box";
959
- node.leaf = "box";
960
- if (isInlineish(cs.display)) entry.fallback.push("display: inline-block");
961
- else if (cs.display && cs.display !== "block") entry.fallback.push(`display: ${cs.display}`);
962
- entry.sizeFallback = [
963
- "box-sizing: border-box",
964
- `width: ${px(rect.width)}`,
965
- `height: ${px(rect.height)}`
966
- ];
967
- return node;
968
- }
969
- node.kids = kids;
970
- if (cs.display && cs.display !== "block") entry.fallback.push(`display: ${cs.display}`);
971
- if (hasTextBar) {
972
- const lh = pxValue(cs.lineHeight);
973
- if (lh !== null) entry.fallback.push(`line-height: ${px(lh)}`);
974
- }
975
- return node;
976
- }
977
- const painted = hasVisibleBackground(cs, state.win);
978
- if (isInlineish(cs.display)) entry.fallback.push("display: inline-block");
979
- else if (cs.display && cs.display !== "block") entry.fallback.push(`display: ${cs.display}`);
980
- if (painted) {
981
- entry.leaf = "box";
982
- node.leaf = "box";
983
- entry.sizeFallback = [
984
- "box-sizing: border-box",
985
- `width: ${px(rect.width)}`,
986
- `height: ${px(rect.height)}`
987
- ];
988
- } else entry.fallback.push("box-sizing: border-box", `min-width: ${px(rect.width)}`, `min-height: ${px(rect.height)}`);
989
- return node;
990
- }
991
- /** A run of adjacent text nodes becomes one synthetic inline bar sized to its rendered box. */
992
- function textBar(run, state) {
993
- const first = run[0];
994
- const last = run[run.length - 1];
995
- const doc = first?.ownerDocument;
996
- if (!first || !last || !doc) return null;
997
- if (run.every((node) => (node.textContent ?? "").trim() === "")) return null;
998
- const range = doc.createRange();
999
- range.setStartBefore(first);
1000
- range.setEndAfter(last);
1001
- const rect = range.getBoundingClientRect();
1002
- const { width, height } = clampToClipping(rect.width, rect.height, first.parentElement, { getComputedStyle: (el) => state.styles.get(el) });
1003
- const entry = {
1004
- id: state.nextId++,
1005
- el: null,
1006
- leaf: "text",
1007
- fallback: ["display: inline-block", "vertical-align: middle"]
1008
- };
1009
- if (width > 0 && height > 0) entry.sizeFallback = [`width: ${px(width)}`, `height: ${px(snapToLines(height, first.parentElement, state.styles))}`];
1010
- state.entries.push(entry);
1011
- return {
1012
- id: entry.id,
1013
- leaf: "text"
1014
- };
1015
- }
1016
- /**
1017
- * A `Range` rect is the tight union of the glyph line boxes, so a wrapped run
1018
- * measures lines × glyph-height — short of the lines × line-height the source
1019
- * actually occupied (a 2-line paragraph rendered ~3px short per line). When the
1020
- * parent's line-height resolves to px and the run is clearly multi-line, round
1021
- * to a whole number of line boxes so the bone reserves the real block height.
1022
- * A single line is left untouched — its line box comes from the parent's pinned
1023
- * line-height, and the thin glyph bar reads as text rather than a fat block.
1024
- */
1025
- function snapToLines(height, parent, styles) {
1026
- if (!parent) return height;
1027
- const lh = pxValue(styles.get(parent).lineHeight);
1028
- if (lh === null || lh <= 0 || height < lh * 1.5) return height;
1029
- return Math.round(height / lh) * lh;
1030
- }
1031
- function clampToClipping(width, height, fromParent, win) {
1032
- for (let host = fromParent; host; host = host.parentElement) {
1033
- const cs = win.getComputedStyle(host);
1034
- const clipsX = cs.overflowX !== "visible";
1035
- const clipsY = cs.overflowY !== "visible";
1036
- if (!clipsX && !clipsY) continue;
1037
- const hrect = host.getBoundingClientRect();
1038
- if (clipsY) {
1039
- const inner = hrect.height - pad(cs.paddingTop) - pad(cs.paddingBottom) - pad(cs.borderTopWidth) - pad(cs.borderBottomWidth);
1040
- if (inner > 0) height = Math.min(height, inner);
1041
- }
1042
- if (clipsX) {
1043
- const inner = hrect.width - pad(cs.paddingLeft) - pad(cs.paddingRight) - pad(cs.borderLeftWidth) - pad(cs.borderRightWidth);
1044
- if (inner > 0) width = Math.min(width, inner);
1045
- }
1046
- }
1047
- return {
1048
- width,
1049
- height
1050
- };
1051
- }
1052
- /** The capture-time measurements everything starts from, at the lowest precedence. */
1053
- function baseRules(entries, styles) {
1054
- const rules = [];
1055
- let order = 0;
1056
- const context = entries.find((e) => e.el)?.el?.parentElement;
1057
- if (context) {
1058
- const cs = styles.get(context);
1059
- rules.push({
1060
- ids: [],
1061
- decls: [
1062
- `font-family: ${cs.fontFamily}`,
1063
- `font-size: ${cs.fontSize}`,
1064
- `font-weight: ${cs.fontWeight}`,
1065
- `line-height: ${cs.lineHeight}`
1066
- ],
1067
- spec: -2,
1068
- order: order++
1069
- });
1070
- }
1071
- for (const entry of entries) {
1072
- if (entry.fallback.length > 0) rules.push({
1073
- ids: [entry.id],
1074
- decls: entry.fallback,
1075
- spec: -1,
1076
- order: order++
1077
- });
1078
- if (entry.sizeFallback && entry.sizeFallback.length > 0) rules.push({
1079
- ids: [entry.id],
1080
- decls: entry.sizeFallback,
1081
- spec: SPEC_LEAF,
1082
- order: order++
1083
- });
1084
- }
1085
- return rules;
1086
- }
1087
- /** `@container <name>? <query>` — the text to re-wrap a lifted rule with. */
1088
- function containerCondition(rule) {
1089
- const query = rule.containerQuery;
1090
- if (query) {
1091
- const name = rule.containerName?.trim();
1092
- return name ? `${name} ${query}` : query;
1093
- }
1094
- const cond = "conditionText" in rule && typeof rule.conditionText === "string" ? rule.conditionText : "";
1095
- if (cond) return cond;
1096
- const condition = /@container\s+([^{]+)\{/.exec(rule.cssText ?? "")?.[1];
1097
- return condition ? condition.trim() : "";
1098
- }
1099
- function isCssStyleRule(rule) {
1100
- return rule.constructor.name === "CSSStyleRule";
1101
- }
1102
- function isCssMediaRule(rule) {
1103
- return rule.constructor.name === "CSSMediaRule";
1104
- }
1105
- function isCssContainerRule(rule) {
1106
- return rule.constructor.name === "CSSContainerRule";
1107
- }
1108
- function isCssSupportsRule(rule) {
1109
- return rule.constructor.name === "CSSSupportsRule";
1110
- }
1111
- function isCssLayerBlockRule(rule) {
1112
- return rule.constructor.name === "CSSLayerBlockRule" && "cssRules" in rule;
1113
- }
1114
- function isCssImportRule(rule) {
1115
- return rule.constructor.name === "CSSImportRule";
1116
- }
1117
- function importLayerName(rule) {
1118
- if (!("layerName" in rule)) return null;
1119
- return typeof rule.layerName === "string" ? rule.layerName : null;
1120
- }
1121
- function supportsCondition(win, condition) {
1122
- if (!("CSS" in win)) return true;
1123
- const css = win.CSS;
1124
- if (typeof css !== "object" || css === null || !("supports" in css)) return true;
1125
- if (typeof css.supports !== "function") return true;
1126
- try {
1127
- return css.supports(condition) === true;
1128
- } catch {
1129
- return true;
1130
- }
1131
- }
1132
- /** Flatten every reachable author rule, tracking media + container conditions and layer membership. */
1133
- function collectStyleRules(doc, win, diagnostics) {
1134
- const out = [];
1135
- const visit = (list, media, container, layered) => {
1136
- if (!list) return;
1137
- for (let i = 0; i < list.length; i++) {
1138
- const rule = list[i];
1139
- if (!rule) continue;
1140
- if (isCssStyleRule(rule)) {
1141
- out.push({
1142
- rule,
1143
- media,
1144
- container,
1145
- layered
1146
- });
1147
- continue;
1148
- }
1149
- if (isCssMediaRule(rule)) {
1150
- visit(rule.cssRules, [...media, rule.media.mediaText], container, layered);
1151
- continue;
1152
- }
1153
- if (isCssContainerRule(rule)) {
1154
- visit(rule.cssRules, media, [...container, containerCondition(rule)], layered);
1155
- continue;
1156
- }
1157
- if (isCssSupportsRule(rule)) {
1158
- if (supportsCondition(win, rule.conditionText)) visit(rule.cssRules, media, container, layered);
1159
- continue;
1160
- }
1161
- if (isCssLayerBlockRule(rule)) {
1162
- visit(rule.cssRules, media, container, true);
1163
- continue;
1164
- }
1165
- if (isCssImportRule(rule)) {
1166
- try {
1167
- const importMedia = rule.media.mediaText;
1168
- visit(rule.styleSheet?.cssRules, importMedia && importMedia !== "all" ? [...media, importMedia] : media, container, layered || importLayerName(rule) != null);
1169
- } catch {
1170
- diagnostics.add("unreadable-import");
1171
- }
1172
- continue;
1173
- }
1174
- diagnostics.add("unsupported-rule");
1175
- }
1176
- };
1177
- const sheets = [...Array.from(doc.styleSheets), ...doc.adoptedStyleSheets ?? []];
1178
- for (const sheet of sheets) {
1179
- const sheetMedia = sheet.media.mediaText;
1180
- try {
1181
- visit(sheet.cssRules, sheetMedia && sheetMedia !== "all" ? [sheetMedia] : [], [], false);
1182
- } catch {
1183
- diagnostics.add("unreadable-stylesheet");
1184
- }
1185
- }
1186
- return out;
1187
- }
1188
- function safeMatches(el, selector) {
1189
- try {
1190
- return el.matches(selector);
1191
- } catch {
1192
- return false;
1193
- }
1194
- }
1195
- function pushSelector(map, key, sel) {
1196
- const list = map.get(key);
1197
- if (list) list.push(sel);
1198
- else map.set(key, [sel]);
1199
- }
1200
- /** The rightmost compound of a selector — what an element must itself satisfy to match. */
1201
- function rightmostCompound(selector) {
1202
- let depth = 0;
1203
- let quote = "";
1204
- for (let i = selector.length - 1; i >= 0; i--) {
1205
- const ch = selector.charAt(i);
1206
- if (quote) {
1207
- if (ch === quote) quote = "";
1208
- continue;
1209
- }
1210
- if (ch === "\"" || ch === "'") quote = ch;
1211
- else if (ch === ")" || ch === "]") depth++;
1212
- else if (ch === "(" || ch === "[") depth--;
1213
- else if (depth === 0 && (ch === " " || ch === ">" || ch === "+" || ch === "~")) return selector.slice(i + 1);
1214
- }
1215
- return selector;
1216
- }
1217
- /**
1218
- * Index author rules the way a browser's rule hash does: bucket each selector
1219
- * by a token its matching element must carry (id, a class, or tag), so an
1220
- * element only tests the rules that could possibly apply to it. CSS-module
1221
- * class names are unique, so this turns an O(rules × nodes) sweep into a
1222
- * near-linear one. Selectors keyed on nothing concrete (`*`, attribute-only)
1223
- * fall back to a universal bucket tested against every element.
1224
- */
1225
- function indexRules(doc, win, diagnostics) {
1226
- const index = {
1227
- byId: /* @__PURE__ */ new Map(),
1228
- byClass: /* @__PURE__ */ new Map(),
1229
- byTag: /* @__PURE__ */ new Map(),
1230
- universal: []
1231
- };
1232
- let order = 1e3;
1233
- for (const found of collectStyleRules(doc, win, diagnostics)) {
1234
- order++;
1235
- for (const selector of splitSelectorList(found.rule.selectorText)) {
1236
- if (PSEUDO_ELEMENT.test(selector)) {
1237
- diagnostics.add("skipped-pseudo-element");
1238
- continue;
1239
- }
1240
- if (DYNAMIC_PSEUDO.test(selector)) {
1241
- diagnostics.add("skipped-dynamic-pseudo");
1242
- continue;
1243
- }
1244
- const sel = {
1245
- rule: found.rule,
1246
- selector,
1247
- media: found.media,
1248
- container: found.container,
1249
- layered: found.layered,
1250
- spec: specificity(selector),
1251
- order
1252
- };
1253
- const compound = rightmostCompound(selector).replace(/:{1,2}[\w-]+\([^()]*\)/g, "");
1254
- const id = /#(-?[A-Za-z_][\w-]*)/.exec(compound);
1255
- const cls = /\.(-?[A-Za-z_][\w-]*)/.exec(compound);
1256
- const tag = /^(-?[A-Za-z][\w-]*)/.exec(compound);
1257
- const idName = id?.[1];
1258
- const className = cls?.[1];
1259
- const tagName = tag?.[1];
1260
- if (idName) pushSelector(index.byId, idName, sel);
1261
- else if (className) pushSelector(index.byClass, className, sel);
1262
- else if (tagName) pushSelector(index.byTag, tagName.toLowerCase(), sel);
1263
- else index.universal.push(sel);
1264
- }
1265
- }
1266
- return index;
1267
- }
1268
- function hasInlineStyle(el) {
1269
- return "style" in el && typeof el.style === "object" && el.style !== null && "length" in el.style && "getPropertyValue" in el.style && "getPropertyPriority" in el.style;
1270
- }
1271
- /** Lift matching author rules, rewritten to plate-local ids (ADR 0005). */
1272
- function copyRules(entries, doc, win, styles, diagnostics) {
1273
- const rules = baseRules(entries, styles);
1274
- const elEntries = entries.filter((e) => e.el !== null);
1275
- const index = indexRules(doc, win, diagnostics);
1276
- const matches = /* @__PURE__ */ new Map();
1277
- const matchOf = (sel) => {
1278
- let m = matches.get(sel);
1279
- if (!m) {
1280
- m = {
1281
- ids: [],
1282
- decls: null
1283
- };
1284
- matches.set(sel, m);
1285
- }
1286
- return m;
1287
- };
1288
- const seen = /* @__PURE__ */ new Set();
1289
- for (const entry of elEntries) {
1290
- const el = entry.el;
1291
- seen.clear();
1292
- const candidates = [];
1293
- const collect = (list) => {
1294
- if (!list) return;
1295
- for (const sel of list) if (!seen.has(sel)) {
1296
- seen.add(sel);
1297
- candidates.push(sel);
1298
- }
1299
- };
1300
- if (el.id) collect(index.byId.get(el.id));
1301
- for (const cls of el.classList) collect(index.byClass.get(cls));
1302
- collect(index.byTag.get(el.tagName.toLowerCase()));
1303
- collect(index.universal);
1304
- for (const sel of candidates) {
1305
- if (!safeMatches(el, sel.selector)) continue;
1306
- const m = matchOf(sel);
1307
- if (m.decls === null) m.decls = filterLayoutDecls(sel.rule.style, entry.cs ?? styles.get(el));
1308
- if (m.decls.length > 0) m.ids.push(entry.id);
1309
- }
1310
- }
1311
- for (const sel of allSelectors(index)) {
1312
- const m = matches.get(sel);
1313
- if (!m || m.ids.length === 0 || !m.decls || m.decls.length === 0) continue;
1314
- rules.push({
1315
- ids: m.ids,
1316
- decls: m.decls,
1317
- media: sel.media,
1318
- ...sel.container.length > 0 ? { container: sel.container } : {},
1319
- layered: sel.layered,
1320
- spec: sel.spec,
1321
- order: sel.order
1322
- });
1323
- }
1324
- let order = 1e7;
1325
- for (const entry of elEntries) {
1326
- if (!hasInlineStyle(entry.el)) continue;
1327
- const style = entry.el.style;
1328
- if (!style || style.length === 0) continue;
1329
- const decls = filterLayoutDecls(style, entry.cs ?? styles.get(entry.el));
1330
- if (decls.length === 0) continue;
1331
- rules.push({
1332
- ids: [entry.id],
1333
- decls,
1334
- spec: SPEC_INLINE,
1335
- order: order++
1336
- });
1337
- }
1338
- return rules;
1339
- }
1340
- function* allSelectors(index) {
1341
- for (const list of index.byId.values()) yield* list;
1342
- for (const list of index.byClass.values()) yield* list;
1343
- for (const list of index.byTag.values()) yield* list;
1344
- yield* index.universal;
1345
- }
1346
- //#endregion
1347
- //#region src/breakpoints.ts
1348
- /**
1349
- * Per-plate breakpoint derivation (ADR 0004): the width thresholds that could
1350
- * change this plate come from the `@media` conditions on its own copied rules
1351
- * plus the queries the app evaluated through `matchMedia` during capture.
1352
- * Thresholds partition the width axis into views; identical captures in
1353
- * adjacent views collapse at merge time.
1354
- */
1355
- const FONT_SIZE_PX = 16;
1356
- /** A breakpoint is the first integer px width of the view ABOVE the boundary. */
1357
- function thresholdsOf(condition) {
1358
- const out = [];
1359
- const toPx = (value, unit) => Number.parseFloat(value) * (unit === "px" ? 1 : FONT_SIZE_PX);
1360
- const push = (raw, exclusiveBelow) => {
1361
- const bp = exclusiveBelow ? Number.isInteger(raw) ? raw + 1 : Math.ceil(raw) : Math.ceil(raw);
1362
- if (Number.isFinite(bp) && bp > 0) out.push(bp);
1363
- };
1364
- for (const m of condition.matchAll(/min-width:\s*([\d.]+)(px|em|rem)/g)) push(toPx(m[1] ?? "", m[2] ?? "px"), false);
1365
- for (const m of condition.matchAll(/max-width:\s*([\d.]+)(px|em|rem)/g)) push(toPx(m[1] ?? "", m[2] ?? "px"), true);
1366
- for (const m of condition.matchAll(/width\s*(>=?)\s*([\d.]+)(px|em|rem)/g)) push(toPx(m[2] ?? "", m[3] ?? "px"), m[1] === ">");
1367
- for (const m of condition.matchAll(/width\s*(<=?)\s*([\d.]+)(px|em|rem)/g)) push(toPx(m[2] ?? "", m[3] ?? "px"), m[1] === "<=");
1368
- for (const m of condition.matchAll(/([\d.]+)(px|em|rem)\s*(<=?)\s*width/g)) push(toPx(m[1] ?? "", m[2] ?? "px"), m[3] === "<");
1369
- return out;
1370
- }
1371
- /** Width thresholds for this plate: its rules' media conditions + the app's matchMedia queries. */
1372
- function deriveBreakpoints(rules, matchMediaQueries = []) {
1373
- const set = /* @__PURE__ */ new Set();
1374
- for (const rule of rules) for (const condition of rule.media ?? []) for (const bp of thresholdsOf(condition)) set.add(bp);
1375
- for (const query of matchMediaQueries) for (const bp of thresholdsOf(query)) set.add(bp);
1376
- return [...set].toSorted((a, b) => a - b);
1377
- }
1378
- /** The view interval `[min, max)` a given viewport width falls into. */
1379
- function regimeFor(width, breakpoints) {
1380
- let min;
1381
- let max;
1382
- for (const bp of breakpoints) if (bp <= width) min = bp;
1383
- else {
1384
- max = bp;
1385
- break;
1386
- }
1387
- return {
1388
- ...min === void 0 ? {} : { min },
1389
- ...max === void 0 ? {} : { max }
1390
- };
1391
- }
1392
- //#endregion
1393
- export { captureRegime as a, formatDiagnostic as c, capture as i, makeDiagnostic as l, regimeFor as n, defineXrayCaptureWalker as o, CaptureTooLargeError as r, diagnosticsHeader as s, deriveBreakpoints as t, classify as u };