@hueest/xray 0.1.0 → 0.2.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,4 +1,4 @@
1
- import { a as ROOT_ATTR, c as SPEC_INLINE, l as SPEC_LEAF } from "./plate-BuzRMPx4.js";
1
+ import { a as ROOT_ATTR, l as SPEC_INLINE, u as SPEC_LEAF } from "./plate-DoE1HEXp.js";
2
2
  //#region src/classify.ts
3
3
  /**
4
4
  * Turn id-form rules + an id-tree into the shipped plate: each distinct
@@ -16,7 +16,7 @@ import { a as ROOT_ATTR, c as SPEC_INLINE, l as SPEC_LEAF } from "./plate-BuzRMP
16
16
  * Pure data-in/data-out — runs at plugin load (node) and in tests.
17
17
  */
18
18
  function classify(rules, tree, plateName) {
19
- const classBase = `${CLASS_PREFIX}${cssSafe(plateName)}-`;
19
+ const classBase = `${CLASS_PREFIX}${encodePlateName(plateName)}-`;
20
20
  const sorted = rules.toSorted((a, b) => Number(b.layered ?? false) - Number(a.layered ?? false) || a.spec - b.spec || a.order - b.order);
21
21
  const nameForKey = /* @__PURE__ */ new Map();
22
22
  const body = /* @__PURE__ */ new Map();
@@ -90,9 +90,23 @@ function render(node, classes) {
90
90
  }
91
91
  /** Content-class prefix; distinct from the fixed `xr-root/node/leaf` base words. */
92
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, "_");
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, "--");
96
110
  }
97
111
  /**
98
112
  * What the class identity keys on, alongside media + declarations:
@@ -374,7 +388,146 @@ function similarity(a, b) {
374
388
  return total === 0 ? 1 : 1 - diff / total;
375
389
  }
376
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
377
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
+ };
378
531
  function isElementNode(node) {
379
532
  return node.nodeType === 1;
380
533
  }
@@ -488,14 +641,14 @@ const FILL_CONTRAST = 72;
488
641
  * mostly opaque and clearly differs from the nearest ancestor's background
489
642
  * (so a white button on a white card doesn't, but a blue one does).
490
643
  */
491
- function hasContrastingFill(el, cs, win) {
644
+ function hasContrastingFill(el, cs, win, styles) {
492
645
  if (hasPaintedImage(cs)) return true;
493
646
  const bg = toRgb(cs.backgroundColor, win);
494
647
  if (!bg || bg.a < .5) return false;
495
648
  let ancestor = el.parentElement;
496
649
  let behind = null;
497
650
  while (ancestor) {
498
- const candidate = toRgb(win.getComputedStyle(ancestor).backgroundColor, win);
651
+ const candidate = toRgb(styles.get(ancestor).backgroundColor, win);
499
652
  if (candidate && candidate.a > 0) {
500
653
  behind = candidate;
501
654
  break;
@@ -516,7 +669,10 @@ function hasContrastingFill(el, cs, win) {
516
669
  * wrapper); the synthetic root node (id 0) stands in for the wrapper itself.
517
670
  */
518
671
  function capture(roots, options) {
519
- const view = captureRegime(roots);
672
+ const view = captureRegime(roots, {
673
+ walker: options.walker,
674
+ captureRootIsBoundary: options.captureRootIsBoundary
675
+ });
520
676
  const { tree, css } = classify(view.rules, view.tree, options.name);
521
677
  return {
522
678
  v: 1,
@@ -533,6 +689,8 @@ function capture(roots, options) {
533
689
  */
534
690
  var CaptureTooLargeError = class extends Error {
535
691
  nodeCount;
692
+ /** Folds the oversized-capture warning into the shared diagnostics vocabulary (diagnostics.ts). */
693
+ code = "too-large";
536
694
  constructor(nodeCount) {
537
695
  super(`xray: capture has ${nodeCount} nodes, over the limit`);
538
696
  this.name = "CaptureTooLargeError";
@@ -549,43 +707,31 @@ function captureRegime(roots, options = {}) {
549
707
  const doc = roots[0]?.ownerDocument;
550
708
  const win = doc?.defaultView;
551
709
  if (!doc || !win) throw new Error("xray: capture roots must be attached to a document");
552
- const { tree, entries } = walkAll(roots, win);
710
+ const diagnostics = createDiagnostics();
711
+ const styles = new StyleCache(win);
712
+ const { tree, entries } = walkAll(roots, win, styles, diagnostics, options.walker, options.captureRootIsBoundary ?? false);
553
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();
554
716
  return {
555
717
  width: win.innerWidth,
556
718
  tree,
557
- rules: copyRules(entries, doc, win)
719
+ rules,
720
+ ...list.length > 0 ? { diagnostics: list } : {}
558
721
  };
559
722
  }
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) {
723
+ function walkAll(roots, win, styles, diagnostics, walker, captureRootIsBoundary = false) {
581
724
  const state = {
582
725
  nextId: 1,
583
726
  entries: [],
584
- win
727
+ win,
728
+ styles,
729
+ diagnostics,
730
+ walker
585
731
  };
586
732
  const kids = [];
587
733
  for (const root of roots) {
588
- const node = walk(root, state);
734
+ const node = walk(root, state, captureRootIsBoundary);
589
735
  if (node) kids.push(node);
590
736
  }
591
737
  const raw = kids.length > 0 ? {
@@ -594,7 +740,7 @@ function walkAll(roots, win) {
594
740
  } : { id: 0 };
595
741
  const dropped = /* @__PURE__ */ new Set();
596
742
  return {
597
- tree: pruneRuns(raw, dropped),
743
+ tree: pruneRuns(raw, dropped, diagnostics),
598
744
  entries: dropped.size > 0 ? state.entries.filter((entry) => !dropped.has(entry.id)) : state.entries
599
745
  };
600
746
  }
@@ -609,7 +755,7 @@ const RUN_SIMILARITY = .9;
609
755
  * first items. The dropped ids are pruned from the entries so no rules are
610
756
  * emitted for them.
611
757
  */
612
- function pruneRuns(node, dropped) {
758
+ function pruneRuns(node, dropped, diagnostics) {
613
759
  const kids = node.kids;
614
760
  if (!kids || kids.length === 0) return node;
615
761
  const pruned = [];
@@ -624,9 +770,10 @@ function pruneRuns(node, dropped) {
624
770
  j++;
625
771
  }
626
772
  const keep = j - i > RUN_LIMIT ? RUN_KEEP : j - i;
773
+ if (j - i > keep) diagnostics.add("pruned-run", { count: j - i - keep });
627
774
  for (let k = i; k < i + keep; k++) {
628
775
  const kid = kids[k];
629
- if (kid) pruned.push(pruneRuns(kid, dropped));
776
+ if (kid) pruned.push(pruneRuns(kid, dropped, diagnostics));
630
777
  }
631
778
  for (let k = i + keep; k < j; k++) {
632
779
  const kid = kids[k];
@@ -643,20 +790,122 @@ function collectIds(node, out) {
643
790
  out.add(node.id);
644
791
  for (const kid of node.kids ?? []) collectIds(kid, out);
645
792
  }
646
- function walk(el, state) {
793
+ function walk(el, state, isCaptureRoot = false) {
647
794
  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 {
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 = {
653
861
  id: state.nextId++,
654
- ref: refName
862
+ el,
863
+ fallback: [],
864
+ cs
655
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();
656
904
  const entry = {
657
905
  id: state.nextId++,
658
906
  el,
659
- fallback: []
907
+ fallback: [],
908
+ cs
660
909
  };
661
910
  state.entries.push(entry);
662
911
  const node = { id: entry.id };
@@ -668,7 +917,7 @@ function walk(el, state) {
668
917
  let { width, height } = rect;
669
918
  const parent = el.parentElement;
670
919
  if ((width < 8 || height < 8) && parent && parent.childElementCount === 1) {
671
- const pcs = state.win.getComputedStyle(parent);
920
+ const pcs = state.styles.get(parent);
672
921
  const prect = parent.getBoundingClientRect();
673
922
  const pw = prect.width - pad(pcs.paddingLeft) - pad(pcs.paddingRight);
674
923
  const ph = prect.height - pad(pcs.paddingTop) - pad(pcs.paddingBottom);
@@ -704,7 +953,7 @@ function walk(el, state) {
704
953
  } else if (isTextNode(child)) textRun.push(child);
705
954
  flushTextRun();
706
955
  if (kids.length > 0) {
707
- if (kids.every((kid) => kid.leaf !== void 0 && kid.kids === void 0) && hasContrastingFill(el, cs, state.win)) {
956
+ if (kids.every((kid) => kid.leaf !== void 0 && kid.kids === void 0) && hasContrastingFill(el, cs, state.win, state.styles)) {
708
957
  state.entries.length = entriesBefore;
709
958
  entry.leaf = "box";
710
959
  node.leaf = "box";
@@ -750,14 +999,14 @@ function textBar(run, state) {
750
999
  range.setStartBefore(first);
751
1000
  range.setEndAfter(last);
752
1001
  const rect = range.getBoundingClientRect();
753
- const { width, height } = clampToClipping(rect.width, rect.height, first.parentElement, state.win);
1002
+ const { width, height } = clampToClipping(rect.width, rect.height, first.parentElement, { getComputedStyle: (el) => state.styles.get(el) });
754
1003
  const entry = {
755
1004
  id: state.nextId++,
756
1005
  el: null,
757
1006
  leaf: "text",
758
1007
  fallback: ["display: inline-block", "vertical-align: middle"]
759
1008
  };
760
- if (width > 0 && height > 0) entry.sizeFallback = [`width: ${px(width)}`, `height: ${px(snapToLines(height, first.parentElement, state.win))}`];
1009
+ if (width > 0 && height > 0) entry.sizeFallback = [`width: ${px(width)}`, `height: ${px(snapToLines(height, first.parentElement, state.styles))}`];
761
1010
  state.entries.push(entry);
762
1011
  return {
763
1012
  id: entry.id,
@@ -773,9 +1022,9 @@ function textBar(run, state) {
773
1022
  * A single line is left untouched — its line box comes from the parent's pinned
774
1023
  * line-height, and the thin glyph bar reads as text rather than a fat block.
775
1024
  */
776
- function snapToLines(height, parent, win) {
1025
+ function snapToLines(height, parent, styles) {
777
1026
  if (!parent) return height;
778
- const lh = pxValue(win.getComputedStyle(parent).lineHeight);
1027
+ const lh = pxValue(styles.get(parent).lineHeight);
779
1028
  if (lh === null || lh <= 0 || height < lh * 1.5) return height;
780
1029
  return Math.round(height / lh) * lh;
781
1030
  }
@@ -801,12 +1050,12 @@ function clampToClipping(width, height, fromParent, win) {
801
1050
  };
802
1051
  }
803
1052
  /** The capture-time measurements everything starts from, at the lowest precedence. */
804
- function baseRules(entries, win) {
1053
+ function baseRules(entries, styles) {
805
1054
  const rules = [];
806
1055
  let order = 0;
807
1056
  const context = entries.find((e) => e.el)?.el?.parentElement;
808
1057
  if (context) {
809
- const cs = win.getComputedStyle(context);
1058
+ const cs = styles.get(context);
810
1059
  rules.push({
811
1060
  ids: [],
812
1061
  decls: [
@@ -881,7 +1130,7 @@ function supportsCondition(win, condition) {
881
1130
  }
882
1131
  }
883
1132
  /** Flatten every reachable author rule, tracking media + container conditions and layer membership. */
884
- function collectStyleRules(doc, win) {
1133
+ function collectStyleRules(doc, win, diagnostics) {
885
1134
  const out = [];
886
1135
  const visit = (list, media, container, layered) => {
887
1136
  if (!list) return;
@@ -913,10 +1162,16 @@ function collectStyleRules(doc, win) {
913
1162
  visit(rule.cssRules, media, container, true);
914
1163
  continue;
915
1164
  }
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 {}
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");
920
1175
  }
921
1176
  };
922
1177
  const sheets = [...Array.from(doc.styleSheets), ...doc.adoptedStyleSheets ?? []];
@@ -924,7 +1179,9 @@ function collectStyleRules(doc, win) {
924
1179
  const sheetMedia = sheet.media.mediaText;
925
1180
  try {
926
1181
  visit(sheet.cssRules, sheetMedia && sheetMedia !== "all" ? [sheetMedia] : [], [], false);
927
- } catch {}
1182
+ } catch {
1183
+ diagnostics.add("unreadable-stylesheet");
1184
+ }
928
1185
  }
929
1186
  return out;
930
1187
  }
@@ -935,10 +1192,10 @@ function safeMatches(el, selector) {
935
1192
  return false;
936
1193
  }
937
1194
  }
938
- function pushUnit(map, key, unit) {
1195
+ function pushSelector(map, key, sel) {
939
1196
  const list = map.get(key);
940
- if (list) list.push(unit);
941
- else map.set(key, [unit]);
1197
+ if (list) list.push(sel);
1198
+ else map.set(key, [sel]);
942
1199
  }
943
1200
  /** The rightmost compound of a selector — what an element must itself satisfy to match. */
944
1201
  function rightmostCompound(selector) {
@@ -965,28 +1222,33 @@ function rightmostCompound(selector) {
965
1222
  * near-linear one. Selectors keyed on nothing concrete (`*`, attribute-only)
966
1223
  * fall back to a universal bucket tested against every element.
967
1224
  */
968
- function indexRules(doc, win) {
969
- const buckets = {
1225
+ function indexRules(doc, win, diagnostics) {
1226
+ const index = {
970
1227
  byId: /* @__PURE__ */ new Map(),
971
1228
  byClass: /* @__PURE__ */ new Map(),
972
1229
  byTag: /* @__PURE__ */ new Map(),
973
1230
  universal: []
974
1231
  };
975
1232
  let order = 1e3;
976
- for (const found of collectStyleRules(doc, win)) {
1233
+ for (const found of collectStyleRules(doc, win, diagnostics)) {
977
1234
  order++;
978
1235
  for (const selector of splitSelectorList(found.rule.selectorText)) {
979
- if (DYNAMIC_PSEUDO.test(selector) || PSEUDO_ELEMENT.test(selector)) continue;
980
- const unit = {
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 = {
981
1245
  rule: found.rule,
982
1246
  selector,
983
1247
  media: found.media,
984
1248
  container: found.container,
985
1249
  layered: found.layered,
986
1250
  spec: specificity(selector),
987
- order,
988
- ids: [],
989
- decls: null
1251
+ order
990
1252
  };
991
1253
  const compound = rightmostCompound(selector).replace(/:{1,2}[\w-]+\([^()]*\)/g, "");
992
1254
  const id = /#(-?[A-Za-z_][\w-]*)/.exec(compound);
@@ -995,22 +1257,34 @@ function indexRules(doc, win) {
995
1257
  const idName = id?.[1];
996
1258
  const className = cls?.[1];
997
1259
  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);
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);
1002
1264
  }
1003
1265
  }
1004
- return buckets;
1266
+ return index;
1005
1267
  }
1006
1268
  function hasInlineStyle(el) {
1007
1269
  return "style" in el && typeof el.style === "object" && el.style !== null && "length" in el.style && "getPropertyValue" in el.style && "getPropertyPriority" in el.style;
1008
1270
  }
1009
1271
  /** Lift matching author rules, rewritten to plate-local ids (ADR 0005). */
1010
- function copyRules(entries, doc, win) {
1011
- const rules = baseRules(entries, win);
1272
+ function copyRules(entries, doc, win, styles, diagnostics) {
1273
+ const rules = baseRules(entries, styles);
1012
1274
  const elEntries = entries.filter((e) => e.el !== null);
1013
- const buckets = indexRules(doc, win);
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
+ };
1014
1288
  const seen = /* @__PURE__ */ new Set();
1015
1289
  for (const entry of elEntries) {
1016
1290
  const el = entry.el;
@@ -1018,31 +1292,33 @@ function copyRules(entries, doc, win) {
1018
1292
  const candidates = [];
1019
1293
  const collect = (list) => {
1020
1294
  if (!list) return;
1021
- for (const unit of list) if (!seen.has(unit)) {
1022
- seen.add(unit);
1023
- candidates.push(unit);
1295
+ for (const sel of list) if (!seen.has(sel)) {
1296
+ seen.add(sel);
1297
+ candidates.push(sel);
1024
1298
  }
1025
1299
  };
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);
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);
1034
1309
  }
1035
1310
  }
1036
- for (const unit of allUnits(buckets)) {
1037
- if (unit.ids.length === 0 || !unit.decls || unit.decls.length === 0) continue;
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;
1038
1314
  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
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
1046
1322
  });
1047
1323
  }
1048
1324
  let order = 1e7;
@@ -1050,7 +1326,7 @@ function copyRules(entries, doc, win) {
1050
1326
  if (!hasInlineStyle(entry.el)) continue;
1051
1327
  const style = entry.el.style;
1052
1328
  if (!style || style.length === 0) continue;
1053
- const decls = filterLayoutDecls(style, win.getComputedStyle(entry.el));
1329
+ const decls = filterLayoutDecls(style, entry.cs ?? styles.get(entry.el));
1054
1330
  if (decls.length === 0) continue;
1055
1331
  rules.push({
1056
1332
  ids: [entry.id],
@@ -1061,11 +1337,57 @@ function copyRules(entries, doc, win) {
1061
1337
  }
1062
1338
  return rules;
1063
1339
  }
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;
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
+ };
1069
1391
  }
1070
1392
  //#endregion
1071
- export { clampToClipping as a, captureRegime as i, audit as n, classify as o, capture as r, CaptureTooLargeError as t };
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 };