@hueest/xray 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.d.ts CHANGED
@@ -1,19 +1,29 @@
1
- import { s as ViewCapture } from "./plate-Bv6W-GkA.js";
1
+ import { h as StoredPlate, o as CollectLimits, v as CaptureDiagnostic } from "./serialize-BgdGt34A.js";
2
2
 
3
3
  //#region src/client.d.ts
4
4
  /**
5
5
  * The dev capture client (M2). Runs in the browser during dev only: installs
6
- * the `__XRAY__` hook `<Skeleton>` calls when content becomes ready,
7
- * settles, serializes the subtree into a view capture, and hands it to the
8
- * sink (the plugin's POST endpoint in M3). Re-captures when the viewport
6
+ * the `__XRAY__` hook `<Skeleton>` calls when content becomes ready, settles,
7
+ * measures the subtree into a per-Plate `PlateIR`, projects it to the structured
8
+ * `StoredPlate` in the browser (the ADR 0022 in-browser Serialize), and hands the
9
+ * envelope to the sink (the plugin's POST endpoint). Re-captures when the viewport
9
10
  * crosses into another view while a site is still mounted.
11
+ *
12
+ * Two paths produce a Plate. BROWSE-TIME is a RECORDING SESSION (the Capture toggle):
13
+ * while on, each capture accumulates a View into a name-keyed `PlateIR` buffer
14
+ * (deduped per breakpoint band) and live-previews it in-memory; toggling off writes
15
+ * each accumulated Plate to the sink ONCE. The capture-all SWEEP threads ONE `PlateIR`
16
+ * through every emulated width (one View each) and posts the projected StoredPlate once.
10
17
  */
11
- interface CapturePayload {
12
- name: string;
13
- /** This capture, view interval already derived. */
14
- capture: ViewCapture;
15
- /** Width thresholds derived for this plate (rules' @media + matchMedia queries). */
16
- breakpoints: number[];
18
+ /**
19
+ * The `/__xray` POST envelope (ADR 0022 cutover). `plate` is the finished,
20
+ * in-browser-projected `StoredPlate` written to disk as-is; `diagnostics` is a
21
+ * SIBLING dev-only field the server logs and drops — it NEVER reaches the stored
22
+ * artifact (ADR 0008).
23
+ */
24
+ interface CaptureEnvelope {
25
+ plate: StoredPlate;
26
+ diagnostics?: CaptureDiagnostic[];
17
27
  }
18
28
  interface ClientOptions {
19
29
  /** Settle delay (ms) once the subtree goes quiet. Default 200. */
@@ -24,14 +34,20 @@ interface ClientOptions {
24
34
  */
25
35
  settleCap?: number;
26
36
  /**
27
- * Refuse captures larger than this many nodes a skeleton that big means
28
- * the Skeleton sits too high in the component tree. Default 4000.
37
+ * Collect limits (ADR 0022): refuse a capture whose measured tree exceeds any
38
+ * axis — that big means the Skeleton sits too high in the component tree.
39
+ * `maxNodes` caps total surviving nodes (default 4096), `maxBreadth` the widest
40
+ * level (default 512), `maxDepth` the nesting depth (default 32). When `maxNodes`
41
+ * is omitted it derives as `round(maxBreadth × maxDepth / 4)`.
29
42
  */
30
- maxNodes?: number;
43
+ collect?: CollectLimits;
31
44
  /**
32
- * Initial state of the capture toggle: whether browsing re-captures plates.
33
- * A per-tab override (sessionStorage / `?xray-capture`) wins over it.
34
- * Defaults to `true` — the plugin passes its own (off-by-default) policy.
45
+ * Initial state of the capture toggle: whether the recording session is active
46
+ * at boot. A per-tab override (sessionStorage / `?xray-capture`) wins over it.
47
+ * Defaults to `false` — recording is explicit opt-in (turn it on, browse/resize to
48
+ * accumulate Views, turn it off to write), so normal browsing never accumulates and
49
+ * an HMR/close can't drop an unsaved recording. The plugin passes the same
50
+ * off-by-default policy; a direct caller can pass `true` to capture on boot.
35
51
  */
36
52
  capture?: boolean;
37
53
  /**
@@ -41,7 +57,7 @@ interface ClientOptions {
41
57
  * does not advance to the next emulated width until the current writes have
42
58
  * been accepted by the dev server (backpressure — capture-all-backpressure plan).
43
59
  */
44
- sink: (payload: CapturePayload) => void | Promise<void>;
60
+ sink: (envelope: CaptureEnvelope) => void | Promise<void>;
45
61
  /**
46
62
  * Re-pull the on-disk per-plate coverage and feed it into the coverage store.
47
63
  * The capture-all sweep calls this after each pass so the next `sweepWidths()`
@@ -74,4 +90,4 @@ interface ClientOptions {
74
90
  }
75
91
  declare function installXrayClient(options: ClientOptions): () => void;
76
92
  //#endregion
77
- export { CapturePayload, ClientOptions, installXrayClient };
93
+ export { CaptureEnvelope, ClientOptions, installXrayClient };
package/dist/client.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as captureRegime, c as formatDiagnostic, l as makeDiagnostic, n as regimeFor, r as CaptureTooLargeError, s as diagnosticsHeader, t as deriveBreakpoints } from "./breakpoints-CMoFUUOv.js";
1
+ import { a as serializePlate, d as formatDiagnostic, f as makeDiagnostic, l as runPlatePasses, n as deriveBreakpoints, o as CaptureTooLargeError, p as emit, r as regimeFor, s as captureView, t as serializePlateIR, u as diagnosticsHeader } from "./project-BZosujs9.js";
2
2
  //#region ../../node_modules/.pnpm/devalue@5.8.1/node_modules/devalue/src/base64.js
3
3
  /** @type {(array_buffer: ArrayBuffer) => string} */
4
4
  function encode_native(array_buffer) {
@@ -528,14 +528,18 @@ function stringify_primitive(thing) {
528
528
  //#endregion
529
529
  //#region src/flash.ts
530
530
  /**
531
- * Dev-only radiographic capture effect — pure delight. When a capture lands we
532
- * briefly "expose" the captured element: a cyan bloom, a negative blink (an
533
- * x-ray IS a negative — `backdrop-filter: invert`), and a scan-line sweep down
534
- * the box. Honors `prefers-reduced-motion` (then just a soft bloom). It's a
535
- * transient `position: fixed`, `pointer-events: none` overlay sitting just under
536
- * the HUD; it never touches the capture pipeline or the captured DOM.
531
+ * Dev-only radiographic capture effect — pure delight. When a capture lands (and
532
+ * actually CHANGES the stored result) we briefly "expose" the captured element: a
533
+ * negative blink (an x-ray IS a negative — `backdrop-filter: invert`) that enters
534
+ * SHARPLY and fades a touch slower, framed by a soft shadow ring around the box so
535
+ * you can see WHICH area was captured. Short and quiet — it's feedback that a
536
+ * resize/navigation (or the capture-all sweep) recorded something, not a light show.
537
+ * Honors `prefers-reduced-motion` (then just a shadow pulse, no invert). A transient
538
+ * `position: fixed`, `pointer-events: none` overlay just under the HUD; it never
539
+ * touches the capture pipeline or the captured DOM.
537
540
  */
538
541
  const STYLE_ID = "xr-flash-style";
542
+ const DURATION_MS = 300;
539
543
  function ensureStyle() {
540
544
  if (document.getElementById(STYLE_ID)) return;
541
545
  const style = document.createElement("style");
@@ -543,31 +547,22 @@ function ensureStyle() {
543
547
  style.textContent = `
544
548
  .xr-flash {
545
549
  position: fixed; pointer-events: none; z-index: 2147483646;
546
- overflow: hidden; border-radius: 4px;
547
- animation: xr-expose 480ms ease-out forwards;
548
- }
549
- .xr-flash::after {
550
- content: ''; position: absolute; left: 0; right: 0; height: 18%;
551
- background: linear-gradient(180deg, transparent, rgba(186, 230, 253, 0.9), transparent);
552
- filter: blur(1px);
553
- animation: xr-scan 480ms ease-in-out forwards;
550
+ border-radius: 4px;
551
+ animation: xr-expose ${DURATION_MS}ms ease-out forwards;
554
552
  }
555
553
  @keyframes xr-expose {
556
- 0% { background: rgba(186, 230, 253, 0); backdrop-filter: invert(0); opacity: 1 }
557
- 18% { background: rgba(186, 230, 253, 0.28); backdrop-filter: invert(1) }
558
- 100% { background: rgba(186, 230, 253, 0); backdrop-filter: invert(0); opacity: 0 }
559
- }
560
- @keyframes xr-scan {
561
- from { transform: translateY(-100%) }
562
- to { transform: translateY(560%) }
554
+ /* Sharp in: full invert + ring by ~12% (~36ms). */
555
+ 0% { backdrop-filter: invert(0); box-shadow: 0 0 0 1px rgba(56, 189, 248, 0), 0 0 0 0 rgba(56, 189, 248, 0) }
556
+ 12% { backdrop-filter: invert(1); box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.85), 0 0 14px 1px rgba(56, 189, 248, 0.5) }
557
+ /* Slower fade out (ease-out decelerates the remaining ~88%). */
558
+ 100% { backdrop-filter: invert(0); box-shadow: 0 0 0 2px rgba(56, 189, 248, 0), 0 0 14px 1px rgba(56, 189, 248, 0) }
563
559
  }
564
560
  @keyframes xr-expose-soft {
565
- 0% { background: rgba(186, 230, 253, 0.25); opacity: 1 }
566
- 100% { background: rgba(186, 230, 253, 0); opacity: 0 }
561
+ 0% { box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.7) }
562
+ 100% { box-shadow: 0 0 0 2px rgba(56, 189, 248, 0) }
567
563
  }
568
564
  @media (prefers-reduced-motion: reduce) {
569
- .xr-flash { animation: xr-expose-soft 320ms ease-out forwards }
570
- .xr-flash::after { display: none }
565
+ .xr-flash { animation: xr-expose-soft ${DURATION_MS}ms ease-out forwards }
571
566
  }
572
567
  `;
573
568
  (document.head ?? document.documentElement).append(style);
@@ -604,7 +599,7 @@ function flashCapture(el) {
604
599
  flash.style.height = `${box.height}px`;
605
600
  flash.addEventListener("animationend", () => flash.remove(), { once: true });
606
601
  (document.body ?? document.documentElement).append(flash);
607
- setTimeout(() => flash.remove(), 1e3);
602
+ setTimeout(() => flash.remove(), 500);
608
603
  }
609
604
  //#endregion
610
605
  //#region src/client.ts
@@ -612,7 +607,9 @@ function isExtensionAck(value) {
612
607
  return typeof value === "object" && value !== null && "source" in value && value.source === "xray-ext" && "kind" in value && value.kind === "ack" && "id" in value && typeof value.id === "number" && (!("ok" in value) || typeof value.ok === "boolean") && (!("error" in value) || typeof value.error === "string");
613
608
  }
614
609
  const DEFAULT_SETTLE_CAP = 5e3;
615
- const DEFAULT_MAX_NODES = 4e3;
610
+ const DEFAULT_MAX_DEPTH = 32;
611
+ const DEFAULT_MAX_BREADTH = 512;
612
+ const deriveMaxNodes = (maxBreadth, maxDepth) => Math.round(maxBreadth * maxDepth / 4);
616
613
  const RESIZE_DEBOUNCE = 300;
617
614
  /**
618
615
  * The capture roots for a site, the ONE place the auto/manual root models
@@ -635,10 +632,20 @@ function installXrayClient(options) {
635
632
  const win = window;
636
633
  const defaultDelay = options.delay ?? 200;
637
634
  const settleCap = options.settleCap ?? DEFAULT_SETTLE_CAP;
638
- const maxNodes = options.maxNodes ?? DEFAULT_MAX_NODES;
635
+ const baseCollect = options.collect ?? {};
636
+ const resolveCollect = (site) => {
637
+ const maxDepth = site?.maxDepth ?? baseCollect.maxDepth ?? DEFAULT_MAX_DEPTH;
638
+ const maxBreadth = site?.maxBreadth ?? baseCollect.maxBreadth ?? DEFAULT_MAX_BREADTH;
639
+ return {
640
+ maxNodes: site?.maxNodes ?? baseCollect.maxNodes ?? deriveMaxNodes(maxBreadth, maxDepth),
641
+ maxBreadth,
642
+ maxDepth
643
+ };
644
+ };
639
645
  const sites = /* @__PURE__ */ new Set();
640
646
  const matchMediaQueries = /* @__PURE__ */ new Set();
641
647
  let captureGeneration = 0;
648
+ let sweeping = false;
642
649
  const livePlates = /* @__PURE__ */ new Map();
643
650
  const plateSerialized = /* @__PURE__ */ new Map();
644
651
  const plateListeners = /* @__PURE__ */ new Map();
@@ -661,10 +668,11 @@ function installXrayClient(options) {
661
668
  };
662
669
  const updatePlate = (name, plate) => {
663
670
  const json = JSON.stringify(plate);
664
- if (plateSerialized.get(name) === json) return;
671
+ if (plateSerialized.get(name) === json) return false;
665
672
  plateSerialized.set(name, json);
666
673
  livePlates.set(name, plate);
667
674
  for (const cb of plateListeners.get(name) ?? []) cb();
675
+ return true;
668
676
  };
669
677
  const nativeMatchMedia = win.matchMedia.bind(win);
670
678
  win.matchMedia = (query) => {
@@ -672,7 +680,7 @@ function installXrayClient(options) {
672
680
  return nativeMatchMedia(query);
673
681
  };
674
682
  const lightbox = createToggleStore(win, LIGHTBOX_KEY, LIGHTBOX_PARAM, false);
675
- const captureEnabled = createToggleStore(win, CAPTURE_KEY, CAPTURE_PARAM, options.capture ?? true);
683
+ const captureEnabled = createToggleStore(win, CAPTURE_KEY, CAPTURE_PARAM, options.capture ?? false);
676
684
  const replayEnabled = createToggleStore(win, REPLAY_KEY, REPLAY_PARAM, options.replay === "prefer" || options.replay === "missing-only");
677
685
  const replayMode = options.replay === "missing-only" ? "missing-only" : "prefer";
678
686
  const recordMode = options.record ?? "manual";
@@ -683,95 +691,212 @@ function installXrayClient(options) {
683
691
  }
684
692
  return true;
685
693
  };
686
- const runCapture = async (site, force, awaitSink) => {
687
- if (!force && !captureEnabled.get() || site.tooLarge || !isPrimaryForName(site)) return;
688
- const generation = captureGeneration;
694
+ /**
695
+ * Measure one View into a LOCAL, fresh 1-View `PlateIR` (no shared bundle) and
696
+ * return it only if the generation guard still holds AFTER the settle — the
697
+ * heart of the ADR 0022 generation-guard preservation (plan §4). Because
698
+ * `captureView(roots, plate)` MUTATES the passed bundle in place
699
+ * (`plate.views.push`), a stale capture appended to a SHARED session PlateIR
700
+ * would corrupt the whole Plate's projection (one width's geometry under
701
+ * another's island/gate), and post-hoc rollback is impossible. So we capture
702
+ * into a local bundle FIRST, re-check the guard, and only the caller splices the
703
+ * survivor into the session bundle. Returns the local View on success, or
704
+ * `undefined` when the guard moved / the capture was refused / the site was torn
705
+ * down — the caller then DISCARDS it (never appends).
706
+ *
707
+ * `generation` is the token snapshotted by the caller BEFORE the settle; a
708
+ * width-change/disposal/uninstall mid-settle moves `captureGeneration` and this
709
+ * returns undefined. A `CaptureTooLargeError` marks the site and warns. Any
710
+ * other failure warns and returns undefined.
711
+ */
712
+ const captureLocalView = async (site, generation) => {
689
713
  await settle(site.el, site.delay, settleCap, site.mode);
690
714
  if (generation !== captureGeneration || site.disposed || site.tooLarge || !site.el.isConnected || !isPrimaryForName(site)) return;
691
715
  const roots = rootsFor(site);
692
- if (roots.length === 0) return;
693
- let payload;
694
- let key;
716
+ if (roots.length === 0) return void 0;
717
+ let local;
695
718
  try {
696
- const capture = captureRegime(roots, {
697
- maxNodes,
719
+ local = captureView(roots, {
720
+ collect: resolveCollect(site.collect),
698
721
  walker: site.walker,
699
722
  captureRootIsBoundary: site.mode === "manual"
700
723
  });
701
- reportDiagnostics(site.name, capture.diagnostics);
702
- const breakpoints = deriveBreakpoints(capture.rules, [...matchMediaQueries]);
703
- const span = regimeFor(capture.width, breakpoints);
704
- payload = {
705
- name: site.name,
706
- capture: {
707
- ...capture,
708
- ...span
709
- },
710
- breakpoints
711
- };
712
- key = JSON.stringify(span);
713
- const body = JSON.stringify(payload);
714
- if (site.sent.get(key) === body) return;
715
- site.sent.set(key, body);
716
724
  } catch (error) {
717
725
  if (error instanceof CaptureTooLargeError) {
718
726
  site.tooLarge = true;
719
- reportDiagnostics(site.name, [makeDiagnostic(error.code, { detail: `${error.nodeCount} nodes (max ${maxNodes})` })]);
727
+ reportDiagnostics(site.name, [makeDiagnostic(error.code, { detail: `${error.axis} ${error.size[error.axis]} over the ${error.axis} limit ${error.max} (nodes ${error.size.nodes}, breadth ${error.size.breadth}, depth ${error.size.depth})` })]);
720
728
  return;
721
729
  }
722
730
  console.warn("[xray] capture failed for", site.name, error);
723
731
  return;
724
732
  }
725
- if (generation !== captureGeneration || site.disposed) {
726
- site.sent.delete(key);
733
+ const view = local.views[0];
734
+ if (!view) return void 0;
735
+ reportDiagnostics(site.name, view.diagnostics);
736
+ if (generation !== captureGeneration || site.disposed) return void 0;
737
+ return view;
738
+ };
739
+ const recording = /* @__PURE__ */ new Map();
740
+ const recordingBreakpoints = (buf) => [...new Set([...buf.views.flatMap((v) => v.bpSeed ?? []), ...deriveBreakpoints([], [...matchMediaQueries])])].toSorted((a, b) => a - b);
741
+ const projectRecording = (buf) => {
742
+ if (buf.views.length === 0) return void 0;
743
+ const breakpoints = recordingBreakpoints(buf);
744
+ const perBand = /* @__PURE__ */ new Map();
745
+ for (const view of buf.views) perBand.set(regimeFor(view.width, breakpoints).min ?? 0, view);
746
+ const views = [...perBand.values()].toSorted((a, b) => a.width - b.width);
747
+ return serializePlateIR({
748
+ name: buf.name,
749
+ views,
750
+ breakpoints
751
+ });
752
+ };
753
+ const previewRecording = (buf) => {
754
+ const stored = projectRecording(buf);
755
+ return stored ? updatePlate(buf.name, serializePlate(stored)) : false;
756
+ };
757
+ const finalizeRecording = async () => {
758
+ const entries = [...recording];
759
+ recording.clear();
760
+ await Promise.all(entries.map(async ([name, buf]) => {
761
+ const stored = projectRecording(buf);
762
+ if (!stored) return;
763
+ const diagnostics = buf.views.flatMap((v) => v.diagnostics ?? []);
764
+ const envelope = {
765
+ plate: stored,
766
+ ...diagnostics.length > 0 ? { diagnostics } : {}
767
+ };
768
+ try {
769
+ await options.sink(envelope);
770
+ } catch (error) {
771
+ if (!recording.has(name)) recording.set(name, buf);
772
+ console.warn(`[xray] recording write failed for "${name}" — kept in memory; retried when you next start a recording`, error);
773
+ }
774
+ }));
775
+ };
776
+ const runCapture = async (site, force) => {
777
+ if (!force && (!captureEnabled.get() || sweeping) || site.tooLarge || !isPrimaryForName(site)) return;
778
+ const view = await captureLocalView(site, captureGeneration);
779
+ if (!view) return;
780
+ if (sweeping && force) {
781
+ (site.sessionPlate ??= {
782
+ name: site.name,
783
+ views: [],
784
+ breakpoints: []
785
+ }).views.push(view);
727
786
  return;
728
787
  }
729
- await sendCapture(site, payload, key, awaitSink);
788
+ if (!captureEnabled.get()) return;
789
+ view.bpSeed = deriveBreakpoints(emit(view.bundle).rules, []);
790
+ runPlatePasses({
791
+ name: site.name,
792
+ views: [view],
793
+ breakpoints: []
794
+ });
795
+ let buf = recording.get(site.name);
796
+ if (!buf) {
797
+ buf = {
798
+ name: site.name,
799
+ views: [],
800
+ breakpoints: []
801
+ };
802
+ recording.set(site.name, buf);
803
+ }
804
+ buf.views = buf.views.filter((v) => v.width !== view.width);
805
+ buf.views.push(view);
806
+ if (previewRecording(buf)) flashCapture(site.el);
730
807
  };
731
- const sendCapture = async (site, payload, key, awaitSink) => {
808
+ /**
809
+ * Project a per-site `PlateIR` to the structured `StoredPlate` IN THE BROWSER
810
+ * (the ADR 0022 Serialize) and hand the envelope to the sink. Derives the
811
+ * breakpoint union ONCE over every View's rules + the recorded matchMedia
812
+ * queries (relocating the old per-View derivation), runs the reduction passes
813
+ * once over the whole bundle, then `serializePlateIR`. Dedupes per-PLATE by the
814
+ * serialized StoredPlate string. ALWAYS catches so neither mode leaks an
815
+ * unhandled rejection; on failure it clears the dedupe so a retry re-sends and,
816
+ * ONLY when `awaitSink`, rethrows so capture-all still fails loudly (pieces
817
+ * 3/8). The radiographic flash runs only AFTER a successful send.
818
+ */
819
+ const sendPlate = async (site, plate, awaitSink) => {
820
+ if (plate.views.length === 0) return;
821
+ plate.name = site.name;
822
+ plate.breakpoints = deriveBreakpoints(plate.views.flatMap((v) => emit(v.bundle).rules), [...matchMediaQueries]);
823
+ runPlatePasses(plate);
824
+ const stored = serializePlateIR(plate);
825
+ const diagnostics = plate.views.flatMap((v) => v.diagnostics ?? []);
826
+ const envelope = {
827
+ plate: stored,
828
+ ...diagnostics.length > 0 ? { diagnostics } : {}
829
+ };
830
+ const body = JSON.stringify(envelope);
831
+ if (site.lastSent === body) return;
832
+ site.lastSent = body;
732
833
  try {
733
- await options.sink(payload);
834
+ await options.sink(envelope);
734
835
  } catch (error) {
735
- site.sent.delete(key);
836
+ site.lastSent = void 0;
736
837
  if (awaitSink) throw error;
737
838
  console.warn("[xray] capture send failed for", site.name, error);
738
839
  return;
739
840
  }
740
841
  flashCapture(site.el);
741
842
  };
742
- const scheduleCapture = (site, force = false, awaitSink = false) => {
843
+ /**
844
+ * Finalize a site's accumulated sweep `PlateIR` and POST it ONCE (awaited, the
845
+ * backpressure point). Clears `site.sessionPlate` first so any straggler capture
846
+ * for the site falls back to the browse path rather than mutating the bundle
847
+ * mid-flight. A session that captured no surviving View posts nothing.
848
+ */
849
+ const finalizeSessionPlate = async (site) => {
850
+ const session = site.sessionPlate;
851
+ site.sessionPlate = void 0;
852
+ if (!session || session.views.length === 0) return;
853
+ await sendPlate(site, session, true);
854
+ };
855
+ const scheduleCapture = (site, force = false) => {
743
856
  if (site.inflight) {
744
857
  site.dirty = true;
745
858
  site.dirtyForce = site.dirtyForce || force;
746
- site.dirtyAwaitSink = site.dirtyAwaitSink || awaitSink;
747
859
  return site.inflight;
748
860
  }
749
- const loop = async (f, a) => {
750
- await runCapture(site, f, a);
751
- if (!site.dirty) return;
752
- site.dirty = false;
753
- const nextForce = site.dirtyForce;
754
- const nextAwaitSink = site.dirtyAwaitSink;
755
- site.dirtyForce = false;
756
- site.dirtyAwaitSink = false;
757
- await loop(nextForce, nextAwaitSink);
861
+ const loop = async (initialForce) => {
862
+ let wantForce = initialForce;
863
+ for (;;) {
864
+ await runCapture(site, wantForce);
865
+ if (!site.dirty) {
866
+ site.inflight = void 0;
867
+ return;
868
+ }
869
+ site.dirty = false;
870
+ wantForce = site.dirtyForce;
871
+ site.dirtyForce = false;
872
+ }
758
873
  };
759
- const promise = loop(force, awaitSink).finally(() => {
760
- site.inflight = void 0;
761
- });
874
+ const promise = loop(force);
762
875
  site.inflight = promise;
763
876
  return promise;
764
877
  };
765
878
  let resizeTimer;
766
879
  const onResize = () => {
880
+ if (sweeping) {
881
+ clearTimeout(resizeTimer);
882
+ return;
883
+ }
767
884
  clearTimeout(resizeTimer);
768
885
  resizeTimer = setTimeout(() => {
886
+ if (sweeping) return;
769
887
  for (const site of sites) scheduleCapture(site);
770
888
  }, RESIZE_DEBOUNCE);
771
889
  };
772
890
  win.addEventListener("resize", onResize);
891
+ let recordingOn = captureEnabled.get();
773
892
  const unsubscribeCapture = captureEnabled.subscribe(() => {
774
- if (captureEnabled.get()) for (const site of sites) scheduleCapture(site);
893
+ const on = captureEnabled.get();
894
+ if (on === recordingOn) return;
895
+ recordingOn = on;
896
+ if (on) {
897
+ finalizeRecording();
898
+ for (const site of sites) scheduleCapture(site);
899
+ } else finalizeRecording();
775
900
  });
776
901
  const extAcks = /* @__PURE__ */ new Map();
777
902
  let nextExtReqId = 1;
@@ -869,7 +994,7 @@ function installXrayClient(options) {
869
994
  };
870
995
  const sweepWidths = () => {
871
996
  const set = new Set(deriveBreakpoints([], [...matchMediaQueries]));
872
- for (const site of sites) for (const bp of coverageMap.get(site.name)?.breakpoints ?? []) set.add(bp);
997
+ for (const site of sites) for (const view of site.sessionPlate?.views ?? []) for (const bp of deriveBreakpoints(emit(view.bundle).rules, [...matchMediaQueries])) set.add(bp);
873
998
  const breakpoints = [...set].toSorted((a, b) => a - b);
874
999
  const min = breakpoints[0];
875
1000
  const first = min === void 0 ? 390 : Math.max(1, Math.min(390, min - 1));
@@ -878,6 +1003,12 @@ function installXrayClient(options) {
878
1003
  const MAX_SWEEP_PASSES = 8;
879
1004
  const captureAllViews = async () => {
880
1005
  const visited = /* @__PURE__ */ new Set();
1006
+ sweeping = true;
1007
+ for (const site of sites) site.sessionPlate = {
1008
+ name: site.name,
1009
+ views: [],
1010
+ breakpoints: []
1011
+ };
881
1012
  try {
882
1013
  for (let pass = 0;; pass++) {
883
1014
  const pending = sweepWidths().filter((width) => !visited.has(width));
@@ -891,11 +1022,15 @@ function installXrayClient(options) {
891
1022
  height: 2400,
892
1023
  mobile: width < 600
893
1024
  });
894
- await Promise.all([...sites].map((site) => scheduleCapture(site, true, true)));
1025
+ await Promise.all([...sites].map((site) => scheduleCapture(site, true)));
895
1026
  }
896
1027
  await options.refreshCoverage?.();
897
1028
  }
1029
+ sweeping = false;
1030
+ await Promise.all([...sites].map((site) => finalizeSessionPlate(site)));
898
1031
  } finally {
1032
+ sweeping = false;
1033
+ for (const site of sites) site.sessionPlate = void 0;
899
1034
  await extCommand("reset").catch(() => void 0);
900
1035
  }
901
1036
  };
@@ -917,12 +1052,11 @@ function installXrayClient(options) {
917
1052
  delay: opts?.delay ?? defaultDelay,
918
1053
  mode: opts?.mode ?? "auto",
919
1054
  walker: opts?.walker,
920
- sent: /* @__PURE__ */ new Map(),
1055
+ collect: opts?.collect,
921
1056
  disposed: false,
922
1057
  tooLarge: false,
923
1058
  dirty: false,
924
- dirtyForce: false,
925
- dirtyAwaitSink: false
1059
+ dirtyForce: false
926
1060
  };
927
1061
  sites.add(site);
928
1062
  scheduleCapture(site);
@@ -1010,9 +1144,29 @@ function createToggleStore(win, key, param, initial) {
1010
1144
  * made single-shot capture non-deterministic in the M1 harness; this is the
1011
1145
  * cure. Hard-capped so a perpetually-animating subtree still captures.
1012
1146
  */
1147
+ /**
1148
+ * Force lazy images in the subtree to load and await their decode, so the walk
1149
+ * measures their real boxes. A `loading="lazy"` image below the fold never enters
1150
+ * the viewport during capture, so its IntersectionObserver never fires — flip it
1151
+ * to eager to trigger the load. Capped: a slow or broken image resolves the race
1152
+ * via the timeout instead of blocking the capture.
1153
+ */
1154
+ function awaitImages(root, cap) {
1155
+ const imgs = Array.from(root.querySelectorAll("img"));
1156
+ if (imgs.length === 0) return Promise.resolve();
1157
+ const decoded = imgs.map((img) => {
1158
+ if (img.loading === "lazy") img.loading = "eager";
1159
+ if (typeof img.decode !== "function") return Promise.resolve();
1160
+ return img.decode().then(() => void 0, () => void 0);
1161
+ });
1162
+ return Promise.race([Promise.all(decoded).then(() => void 0), new Promise((resolve) => {
1163
+ setTimeout(resolve, cap);
1164
+ })]);
1165
+ }
1013
1166
  function settle(el, delay, cap, mode = "auto") {
1014
1167
  const doc = el.ownerDocument;
1015
- return (doc.fonts ? doc.fonts.ready : Promise.resolve()).then(() => new Promise((resolve) => {
1168
+ const fonts = doc.fonts ? doc.fonts.ready : Promise.resolve();
1169
+ return Promise.all([fonts, awaitImages(el, cap)]).then(() => new Promise((resolve) => {
1016
1170
  let timer;
1017
1171
  let capTimer;
1018
1172
  const observer = new MutationObserver(bump);
package/dist/core.d.ts CHANGED
@@ -1,5 +1,4 @@
1
- import { a as PlateNode, c as emptyPlate, i as PlateFile, n as MergedPlate, o as RenderNode, r as Plate, s as ViewCapture, t as LeafKind } from "./plate-Bv6W-GkA.js";
2
- import { a as WalkerResult, c as captureRegime, i as CaptureWalkerContext, l as defineXrayCaptureWalker, n as CaptureRegimeOptions, o as XrayCaptureWalker, r as CaptureTooLargeError, s as capture, t as CaptureOptions } from "./serialize-B--oY2bV.js";
1
+ import { _ as emptyPlate, a as CollectAxis, c as XrayCaptureWalker, d as MergedPlate, f as Plate, g as ViewCapture, i as CaptureWalkerContext, l as defineXrayCaptureWalker, m as RenderNode, n as CaptureSize, o as CollectLimits, p as PlateNode, r as CaptureTooLargeError, s as WalkerDecision, t as CaptureOptions, u as LeafKind } from "./serialize-BgdGt34A.js";
3
2
 
4
3
  //#region src/chunk.d.ts
5
4
  /**
@@ -9,36 +8,25 @@ import { a as WalkerResult, c as captureRegime, i as CaptureWalkerContext, l as
9
8
  * chunks and renders the fallback.
10
9
  */
11
10
  declare function serializePlate(merged: MergedPlate): Plate;
12
- //#endregion
13
- //#region src/merge.d.ts
14
11
  /**
15
- * Turn the per-view captures in a plate file into the one plate a Skeleton
16
- * renders.
17
- *
18
- * Each view is kept whole its own subtree, its own ids, its own rules —
19
- * and shown only across the viewport range it owns, via `@media display:none`
20
- * gates. We do NOT merge the views' trees into one. An earlier design (ADR
21
- * 0004) did, sharing common structure and forking the rest, but aligning two
22
- * genuinely-different DOM trees (mobile stacked vs desktop two-column) without
23
- * stable keys proved unreliable: every heuristic mis-paired some nodes and
24
- * remapped one view's rules onto another's elements, corrupting both. A lone
25
- * capture is pixel-faithful; keeping each view lone preserves that. The cost
26
- * is a larger plate (roughly the sum of the views) — bounded by the plate
27
- * size cap — in exchange for correctness.
28
- *
29
- * Pure data-in/data-out — runs in node (plugin load) and in tests.
12
+ * Distinct plate names referenced by the tree's stitches, in first-seen order.
13
+ * Walks a `RenderNode` tree (the stored, post-classify shape), so it drives the
14
+ * gen-side stitch import graph straight off a `StoredPlate.tree`
15
+ * (`renderPlateModule`, index.ts). Relocated here from the now-deleted `merge.ts`
16
+ * in the ADR 0022 cutover — it is the one node-side primitive that survived the
17
+ * `mergeViews`/`addCapture` removal (the multi-View merge/gate now lives in the
18
+ * in-browser `serializePlateIR`, project.ts).
30
19
  */
31
- declare function mergeViews(file: PlateFile): MergedPlate;
32
- /** Distinct plate names referenced by the tree's stitches, in first-seen order. */
33
20
  declare function collectRefs(tree: RenderNode | null): string[];
34
- /** Fold a fresh capture into the plate file: union breakpoints, one capture per view. */
35
- declare function addCapture(file: PlateFile | null, name: string, capture: ViewCapture, breakpoints: readonly number[]): PlateFile;
36
21
  //#endregion
37
22
  //#region src/core.d.ts
38
23
  /**
39
- * Capture a live DOM subtree straight to a renderable Plate `capture` followed
40
- * by `serializePlate` in one call. `roots` are the top-level elements to capture
41
- * (a component's rendered roots). Pass the result to a `<Skeleton plate>`.
24
+ * Capture a live DOM subtree straight to a renderable Plate. Runs the same
25
+ * uniform pipeline the dev client does for a single View measure (`captureView`),
26
+ * reduce (`runPlatePasses`), project (`serializePlateIR` the structured
27
+ * `StoredPlate`), then lower to chunks (`serializePlate`) — in one call. `roots`
28
+ * are the top-level elements to capture (a component's rendered roots). Pass the
29
+ * result to a `<Skeleton plate>`.
42
30
  *
43
31
  * `options.walker` is the public programmable capture walker (ADR 0018, Q2 —
44
32
  * yes, public): the same `XrayCaptureWalker` the `<Skeleton captureWalker>` prop
@@ -57,4 +45,4 @@ declare function captureElement(roots: readonly Element[], options: CaptureOptio
57
45
  */
58
46
  declare function renderPlateHtml(plate: Plate): string;
59
47
  //#endregion
60
- export { type CaptureOptions, type CaptureRegimeOptions, CaptureTooLargeError, type CaptureWalkerContext, type LeafKind, type MergedPlate, type Plate, type PlateFile, type PlateNode, type ViewCapture, type WalkerResult, type XrayCaptureWalker, addCapture, capture, captureElement, captureRegime, collectRefs, defineXrayCaptureWalker, emptyPlate, mergeViews, renderPlateHtml, serializePlate };
48
+ export { type CaptureOptions, type CaptureSize, CaptureTooLargeError, type CaptureWalkerContext, type CollectAxis, type CollectLimits, type LeafKind, type MergedPlate, type Plate, type PlateNode, type ViewCapture, type WalkerDecision, type XrayCaptureWalker, captureElement, collectRefs, defineXrayCaptureWalker, emptyPlate, renderPlateHtml, serializePlate };