@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.
package/dist/client.d.ts CHANGED
@@ -1,19 +1,29 @@
1
- import { s as ViewCapture } from "./plate-Bv6W-GkA.js";
1
+ import { l as CaptureDiagnostic, o as StoredPlate } from "./plate-BIr62u7l.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. */
@@ -29,9 +39,12 @@ interface ClientOptions {
29
39
  */
30
40
  maxNodes?: number;
31
41
  /**
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.
42
+ * Initial state of the capture toggle: whether the recording session is active
43
+ * at boot. A per-tab override (sessionStorage / `?xray-capture`) wins over it.
44
+ * Defaults to `false` — recording is explicit opt-in (turn it on, browse/resize to
45
+ * accumulate Views, turn it off to write), so normal browsing never accumulates and
46
+ * an HMR/close can't drop an unsaved recording. The plugin passes the same
47
+ * off-by-default policy; a direct caller can pass `true` to capture on boot.
35
48
  */
36
49
  capture?: boolean;
37
50
  /**
@@ -41,7 +54,7 @@ interface ClientOptions {
41
54
  * does not advance to the next emulated width until the current writes have
42
55
  * been accepted by the dev server (backpressure — capture-all-backpressure plan).
43
56
  */
44
- sink: (payload: CapturePayload) => void | Promise<void>;
57
+ sink: (envelope: CaptureEnvelope) => void | Promise<void>;
45
58
  /**
46
59
  * Re-pull the on-disk per-plate coverage and feed it into the coverage store.
47
60
  * The capture-all sweep calls this after each pass so the next `sweepWidths()`
@@ -74,4 +87,4 @@ interface ClientOptions {
74
87
  }
75
88
  declare function installXrayClient(options: ClientOptions): () => void;
76
89
  //#endregion
77
- export { CapturePayload, ClientOptions, installXrayClient };
90
+ 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-DORoPAo7.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
@@ -639,6 +634,7 @@ function installXrayClient(options) {
639
634
  const sites = /* @__PURE__ */ new Set();
640
635
  const matchMediaQueries = /* @__PURE__ */ new Set();
641
636
  let captureGeneration = 0;
637
+ let sweeping = false;
642
638
  const livePlates = /* @__PURE__ */ new Map();
643
639
  const plateSerialized = /* @__PURE__ */ new Map();
644
640
  const plateListeners = /* @__PURE__ */ new Map();
@@ -661,10 +657,11 @@ function installXrayClient(options) {
661
657
  };
662
658
  const updatePlate = (name, plate) => {
663
659
  const json = JSON.stringify(plate);
664
- if (plateSerialized.get(name) === json) return;
660
+ if (plateSerialized.get(name) === json) return false;
665
661
  plateSerialized.set(name, json);
666
662
  livePlates.set(name, plate);
667
663
  for (const cb of plateListeners.get(name) ?? []) cb();
664
+ return true;
668
665
  };
669
666
  const nativeMatchMedia = win.matchMedia.bind(win);
670
667
  win.matchMedia = (query) => {
@@ -672,7 +669,7 @@ function installXrayClient(options) {
672
669
  return nativeMatchMedia(query);
673
670
  };
674
671
  const lightbox = createToggleStore(win, LIGHTBOX_KEY, LIGHTBOX_PARAM, false);
675
- const captureEnabled = createToggleStore(win, CAPTURE_KEY, CAPTURE_PARAM, options.capture ?? true);
672
+ const captureEnabled = createToggleStore(win, CAPTURE_KEY, CAPTURE_PARAM, options.capture ?? false);
676
673
  const replayEnabled = createToggleStore(win, REPLAY_KEY, REPLAY_PARAM, options.replay === "prefer" || options.replay === "missing-only");
677
674
  const replayMode = options.replay === "missing-only" ? "missing-only" : "prefer";
678
675
  const recordMode = options.record ?? "manual";
@@ -683,36 +680,36 @@ function installXrayClient(options) {
683
680
  }
684
681
  return true;
685
682
  };
686
- const runCapture = async (site, force, awaitSink) => {
687
- if (!force && !captureEnabled.get() || site.tooLarge || !isPrimaryForName(site)) return;
688
- const generation = captureGeneration;
683
+ /**
684
+ * Measure one View into a LOCAL, fresh 1-View `PlateIR` (no shared bundle) and
685
+ * return it only if the generation guard still holds AFTER the settle — the
686
+ * heart of the ADR 0022 generation-guard preservation (plan §4). Because
687
+ * `captureView(roots, plate)` MUTATES the passed bundle in place
688
+ * (`plate.views.push`), a stale capture appended to a SHARED session PlateIR
689
+ * would corrupt the whole Plate's projection (one width's geometry under
690
+ * another's island/gate), and post-hoc rollback is impossible. So we capture
691
+ * into a local bundle FIRST, re-check the guard, and only the caller splices the
692
+ * survivor into the session bundle. Returns the local View on success, or
693
+ * `undefined` when the guard moved / the capture was refused / the site was torn
694
+ * down — the caller then DISCARDS it (never appends).
695
+ *
696
+ * `generation` is the token snapshotted by the caller BEFORE the settle; a
697
+ * width-change/disposal/uninstall mid-settle moves `captureGeneration` and this
698
+ * returns undefined. A `CaptureTooLargeError` marks the site and warns. Any
699
+ * other failure warns and returns undefined.
700
+ */
701
+ const captureLocalView = async (site, generation) => {
689
702
  await settle(site.el, site.delay, settleCap, site.mode);
690
703
  if (generation !== captureGeneration || site.disposed || site.tooLarge || !site.el.isConnected || !isPrimaryForName(site)) return;
691
704
  const roots = rootsFor(site);
692
- if (roots.length === 0) return;
693
- let payload;
694
- let key;
705
+ if (roots.length === 0) return void 0;
706
+ let local;
695
707
  try {
696
- const capture = captureRegime(roots, {
708
+ local = captureView(roots, {
697
709
  maxNodes,
698
710
  walker: site.walker,
699
711
  captureRootIsBoundary: site.mode === "manual"
700
712
  });
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
713
  } catch (error) {
717
714
  if (error instanceof CaptureTooLargeError) {
718
715
  site.tooLarge = true;
@@ -722,56 +719,173 @@ function installXrayClient(options) {
722
719
  console.warn("[xray] capture failed for", site.name, error);
723
720
  return;
724
721
  }
725
- if (generation !== captureGeneration || site.disposed) {
726
- site.sent.delete(key);
722
+ const view = local.views[0];
723
+ if (!view) return void 0;
724
+ reportDiagnostics(site.name, view.diagnostics);
725
+ if (generation !== captureGeneration || site.disposed) return void 0;
726
+ return view;
727
+ };
728
+ const recording = /* @__PURE__ */ new Map();
729
+ const recordingBreakpoints = (buf) => [...new Set([...buf.views.flatMap((v) => v.bpSeed ?? []), ...deriveBreakpoints([], [...matchMediaQueries])])].toSorted((a, b) => a - b);
730
+ const projectRecording = (buf) => {
731
+ if (buf.views.length === 0) return void 0;
732
+ const breakpoints = recordingBreakpoints(buf);
733
+ const perBand = /* @__PURE__ */ new Map();
734
+ for (const view of buf.views) perBand.set(regimeFor(view.width, breakpoints).min ?? 0, view);
735
+ const views = [...perBand.values()].toSorted((a, b) => a.width - b.width);
736
+ return serializePlateIR({
737
+ name: buf.name,
738
+ views,
739
+ breakpoints
740
+ });
741
+ };
742
+ const previewRecording = (buf) => {
743
+ const stored = projectRecording(buf);
744
+ return stored ? updatePlate(buf.name, serializePlate(stored)) : false;
745
+ };
746
+ const finalizeRecording = async () => {
747
+ const entries = [...recording];
748
+ recording.clear();
749
+ await Promise.all(entries.map(async ([name, buf]) => {
750
+ const stored = projectRecording(buf);
751
+ if (!stored) return;
752
+ const diagnostics = buf.views.flatMap((v) => v.diagnostics ?? []);
753
+ const envelope = {
754
+ plate: stored,
755
+ ...diagnostics.length > 0 ? { diagnostics } : {}
756
+ };
757
+ try {
758
+ await options.sink(envelope);
759
+ } catch (error) {
760
+ if (!recording.has(name)) recording.set(name, buf);
761
+ console.warn(`[xray] recording write failed for "${name}" — kept in memory; retried when you next start a recording`, error);
762
+ }
763
+ }));
764
+ };
765
+ const runCapture = async (site, force) => {
766
+ if (!force && (!captureEnabled.get() || sweeping) || site.tooLarge || !isPrimaryForName(site)) return;
767
+ const view = await captureLocalView(site, captureGeneration);
768
+ if (!view) return;
769
+ if (sweeping && force) {
770
+ (site.sessionPlate ??= {
771
+ name: site.name,
772
+ views: [],
773
+ breakpoints: []
774
+ }).views.push(view);
727
775
  return;
728
776
  }
729
- await sendCapture(site, payload, key, awaitSink);
777
+ if (!captureEnabled.get()) return;
778
+ view.bpSeed = deriveBreakpoints(emit(view.bundle).rules, []);
779
+ runPlatePasses({
780
+ name: site.name,
781
+ views: [view],
782
+ breakpoints: []
783
+ });
784
+ let buf = recording.get(site.name);
785
+ if (!buf) {
786
+ buf = {
787
+ name: site.name,
788
+ views: [],
789
+ breakpoints: []
790
+ };
791
+ recording.set(site.name, buf);
792
+ }
793
+ buf.views = buf.views.filter((v) => v.width !== view.width);
794
+ buf.views.push(view);
795
+ if (previewRecording(buf)) flashCapture(site.el);
730
796
  };
731
- const sendCapture = async (site, payload, key, awaitSink) => {
797
+ /**
798
+ * Project a per-site `PlateIR` to the structured `StoredPlate` IN THE BROWSER
799
+ * (the ADR 0022 Serialize) and hand the envelope to the sink. Derives the
800
+ * breakpoint union ONCE over every View's rules + the recorded matchMedia
801
+ * queries (relocating the old per-View derivation), runs the reduction passes
802
+ * once over the whole bundle, then `serializePlateIR`. Dedupes per-PLATE by the
803
+ * serialized StoredPlate string. ALWAYS catches so neither mode leaks an
804
+ * unhandled rejection; on failure it clears the dedupe so a retry re-sends and,
805
+ * ONLY when `awaitSink`, rethrows so capture-all still fails loudly (pieces
806
+ * 3/8). The radiographic flash runs only AFTER a successful send.
807
+ */
808
+ const sendPlate = async (site, plate, awaitSink) => {
809
+ if (plate.views.length === 0) return;
810
+ plate.name = site.name;
811
+ plate.breakpoints = deriveBreakpoints(plate.views.flatMap((v) => emit(v.bundle).rules), [...matchMediaQueries]);
812
+ runPlatePasses(plate);
813
+ const stored = serializePlateIR(plate);
814
+ const diagnostics = plate.views.flatMap((v) => v.diagnostics ?? []);
815
+ const envelope = {
816
+ plate: stored,
817
+ ...diagnostics.length > 0 ? { diagnostics } : {}
818
+ };
819
+ const body = JSON.stringify(envelope);
820
+ if (site.lastSent === body) return;
821
+ site.lastSent = body;
732
822
  try {
733
- await options.sink(payload);
823
+ await options.sink(envelope);
734
824
  } catch (error) {
735
- site.sent.delete(key);
825
+ site.lastSent = void 0;
736
826
  if (awaitSink) throw error;
737
827
  console.warn("[xray] capture send failed for", site.name, error);
738
828
  return;
739
829
  }
740
830
  flashCapture(site.el);
741
831
  };
742
- const scheduleCapture = (site, force = false, awaitSink = false) => {
832
+ /**
833
+ * Finalize a site's accumulated sweep `PlateIR` and POST it ONCE (awaited, the
834
+ * backpressure point). Clears `site.sessionPlate` first so any straggler capture
835
+ * for the site falls back to the browse path rather than mutating the bundle
836
+ * mid-flight. A session that captured no surviving View posts nothing.
837
+ */
838
+ const finalizeSessionPlate = async (site) => {
839
+ const session = site.sessionPlate;
840
+ site.sessionPlate = void 0;
841
+ if (!session || session.views.length === 0) return;
842
+ await sendPlate(site, session, true);
843
+ };
844
+ const scheduleCapture = (site, force = false) => {
743
845
  if (site.inflight) {
744
846
  site.dirty = true;
745
847
  site.dirtyForce = site.dirtyForce || force;
746
- site.dirtyAwaitSink = site.dirtyAwaitSink || awaitSink;
747
848
  return site.inflight;
748
849
  }
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);
850
+ const loop = async (initialForce) => {
851
+ let wantForce = initialForce;
852
+ for (;;) {
853
+ await runCapture(site, wantForce);
854
+ if (!site.dirty) {
855
+ site.inflight = void 0;
856
+ return;
857
+ }
858
+ site.dirty = false;
859
+ wantForce = site.dirtyForce;
860
+ site.dirtyForce = false;
861
+ }
758
862
  };
759
- const promise = loop(force, awaitSink).finally(() => {
760
- site.inflight = void 0;
761
- });
863
+ const promise = loop(force);
762
864
  site.inflight = promise;
763
865
  return promise;
764
866
  };
765
867
  let resizeTimer;
766
868
  const onResize = () => {
869
+ if (sweeping) {
870
+ clearTimeout(resizeTimer);
871
+ return;
872
+ }
767
873
  clearTimeout(resizeTimer);
768
874
  resizeTimer = setTimeout(() => {
875
+ if (sweeping) return;
769
876
  for (const site of sites) scheduleCapture(site);
770
877
  }, RESIZE_DEBOUNCE);
771
878
  };
772
879
  win.addEventListener("resize", onResize);
880
+ let recordingOn = captureEnabled.get();
773
881
  const unsubscribeCapture = captureEnabled.subscribe(() => {
774
- if (captureEnabled.get()) for (const site of sites) scheduleCapture(site);
882
+ const on = captureEnabled.get();
883
+ if (on === recordingOn) return;
884
+ recordingOn = on;
885
+ if (on) {
886
+ finalizeRecording();
887
+ for (const site of sites) scheduleCapture(site);
888
+ } else finalizeRecording();
775
889
  });
776
890
  const extAcks = /* @__PURE__ */ new Map();
777
891
  let nextExtReqId = 1;
@@ -869,7 +983,7 @@ function installXrayClient(options) {
869
983
  };
870
984
  const sweepWidths = () => {
871
985
  const set = new Set(deriveBreakpoints([], [...matchMediaQueries]));
872
- for (const site of sites) for (const bp of coverageMap.get(site.name)?.breakpoints ?? []) set.add(bp);
986
+ 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
987
  const breakpoints = [...set].toSorted((a, b) => a - b);
874
988
  const min = breakpoints[0];
875
989
  const first = min === void 0 ? 390 : Math.max(1, Math.min(390, min - 1));
@@ -878,6 +992,12 @@ function installXrayClient(options) {
878
992
  const MAX_SWEEP_PASSES = 8;
879
993
  const captureAllViews = async () => {
880
994
  const visited = /* @__PURE__ */ new Set();
995
+ sweeping = true;
996
+ for (const site of sites) site.sessionPlate = {
997
+ name: site.name,
998
+ views: [],
999
+ breakpoints: []
1000
+ };
881
1001
  try {
882
1002
  for (let pass = 0;; pass++) {
883
1003
  const pending = sweepWidths().filter((width) => !visited.has(width));
@@ -891,11 +1011,15 @@ function installXrayClient(options) {
891
1011
  height: 2400,
892
1012
  mobile: width < 600
893
1013
  });
894
- await Promise.all([...sites].map((site) => scheduleCapture(site, true, true)));
1014
+ await Promise.all([...sites].map((site) => scheduleCapture(site, true)));
895
1015
  }
896
1016
  await options.refreshCoverage?.();
897
1017
  }
1018
+ sweeping = false;
1019
+ await Promise.all([...sites].map((site) => finalizeSessionPlate(site)));
898
1020
  } finally {
1021
+ sweeping = false;
1022
+ for (const site of sites) site.sessionPlate = void 0;
899
1023
  await extCommand("reset").catch(() => void 0);
900
1024
  }
901
1025
  };
@@ -917,12 +1041,10 @@ function installXrayClient(options) {
917
1041
  delay: opts?.delay ?? defaultDelay,
918
1042
  mode: opts?.mode ?? "auto",
919
1043
  walker: opts?.walker,
920
- sent: /* @__PURE__ */ new Map(),
921
1044
  disposed: false,
922
1045
  tooLarge: false,
923
1046
  dirty: false,
924
- dirtyForce: false,
925
- dirtyAwaitSink: false
1047
+ dirtyForce: false
926
1048
  };
927
1049
  sites.add(site);
928
1050
  scheduleCapture(site);
@@ -1010,9 +1132,29 @@ function createToggleStore(win, key, param, initial) {
1010
1132
  * made single-shot capture non-deterministic in the M1 harness; this is the
1011
1133
  * cure. Hard-capped so a perpetually-animating subtree still captures.
1012
1134
  */
1135
+ /**
1136
+ * Force lazy images in the subtree to load and await their decode, so the walk
1137
+ * measures their real boxes. A `loading="lazy"` image below the fold never enters
1138
+ * the viewport during capture, so its IntersectionObserver never fires — flip it
1139
+ * to eager to trigger the load. Capped: a slow or broken image resolves the race
1140
+ * via the timeout instead of blocking the capture.
1141
+ */
1142
+ function awaitImages(root, cap) {
1143
+ const imgs = Array.from(root.querySelectorAll("img"));
1144
+ if (imgs.length === 0) return Promise.resolve();
1145
+ const decoded = imgs.map((img) => {
1146
+ if (img.loading === "lazy") img.loading = "eager";
1147
+ if (typeof img.decode !== "function") return Promise.resolve();
1148
+ return img.decode().then(() => void 0, () => void 0);
1149
+ });
1150
+ return Promise.race([Promise.all(decoded).then(() => void 0), new Promise((resolve) => {
1151
+ setTimeout(resolve, cap);
1152
+ })]);
1153
+ }
1013
1154
  function settle(el, delay, cap, mode = "auto") {
1014
1155
  const doc = el.ownerDocument;
1015
- return (doc.fonts ? doc.fonts.ready : Promise.resolve()).then(() => new Promise((resolve) => {
1156
+ const fonts = doc.fonts ? doc.fonts.ready : Promise.resolve();
1157
+ return Promise.all([fonts, awaitImages(el, cap)]).then(() => new Promise((resolve) => {
1016
1158
  let timer;
1017
1159
  let capTimer;
1018
1160
  const observer = new MutationObserver(bump);
package/dist/core.d.ts CHANGED
@@ -1,5 +1,5 @@
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 { a as RenderNode, c as emptyPlate, i as PlateNode, n as MergedPlate, r as Plate, s as ViewCapture, t as LeafKind } from "./plate-BIr62u7l.js";
2
+ import { a as XrayCaptureWalker, i as WalkerDecision, n as CaptureTooLargeError, o as defineXrayCaptureWalker, r as CaptureWalkerContext, t as CaptureOptions } from "./serialize-Cs7hUxmK.js";
3
3
 
4
4
  //#region src/chunk.d.ts
5
5
  /**
@@ -9,36 +9,25 @@ import { a as WalkerResult, c as captureRegime, i as CaptureWalkerContext, l as
9
9
  * chunks and renders the fallback.
10
10
  */
11
11
  declare function serializePlate(merged: MergedPlate): Plate;
12
- //#endregion
13
- //#region src/merge.d.ts
14
12
  /**
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.
13
+ * Distinct plate names referenced by the tree's stitches, in first-seen order.
14
+ * Walks a `RenderNode` tree (the stored, post-classify shape), so it drives the
15
+ * gen-side stitch import graph straight off a `StoredPlate.tree`
16
+ * (`renderPlateModule`, index.ts). Relocated here from the now-deleted `merge.ts`
17
+ * in the ADR 0022 cutover — it is the one node-side primitive that survived the
18
+ * `mergeViews`/`addCapture` removal (the multi-View merge/gate now lives in the
19
+ * in-browser `serializePlateIR`, project.ts).
30
20
  */
31
- declare function mergeViews(file: PlateFile): MergedPlate;
32
- /** Distinct plate names referenced by the tree's stitches, in first-seen order. */
33
21
  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
22
  //#endregion
37
23
  //#region src/core.d.ts
38
24
  /**
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>`.
25
+ * Capture a live DOM subtree straight to a renderable Plate. Runs the same
26
+ * uniform pipeline the dev client does for a single View measure (`captureView`),
27
+ * reduce (`runPlatePasses`), project (`serializePlateIR` the structured
28
+ * `StoredPlate`), then lower to chunks (`serializePlate`) — in one call. `roots`
29
+ * are the top-level elements to capture (a component's rendered roots). Pass the
30
+ * result to a `<Skeleton plate>`.
42
31
  *
43
32
  * `options.walker` is the public programmable capture walker (ADR 0018, Q2 —
44
33
  * yes, public): the same `XrayCaptureWalker` the `<Skeleton captureWalker>` prop
@@ -57,4 +46,4 @@ declare function captureElement(roots: readonly Element[], options: CaptureOptio
57
46
  */
58
47
  declare function renderPlateHtml(plate: Plate): string;
59
48
  //#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 };
49
+ export { type CaptureOptions, CaptureTooLargeError, type CaptureWalkerContext, type LeafKind, type MergedPlate, type Plate, type PlateNode, type ViewCapture, type WalkerDecision, type XrayCaptureWalker, captureElement, collectRefs, defineXrayCaptureWalker, emptyPlate, renderPlateHtml, serializePlate };