@hueest/xray 0.3.0 → 0.5.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,4 +1,4 @@
1
- import { l as CaptureDiagnostic, o as StoredPlate } from "./plate-BIr62u7l.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
  /**
@@ -34,10 +34,13 @@ interface ClientOptions {
34
34
  */
35
35
  settleCap?: number;
36
36
  /**
37
- * Refuse captures larger than this many nodes a skeleton that big means
38
- * 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)`.
39
42
  */
40
- maxNodes?: number;
43
+ collect?: CollectLimits;
41
44
  /**
42
45
  * Initial state of the capture toggle: whether the recording session is active
43
46
  * at boot. A per-tab override (sessionStorage / `?xray-capture`) wins over it.
package/dist/client.js CHANGED
@@ -1,4 +1,4 @@
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";
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) {
@@ -607,7 +607,9 @@ function isExtensionAck(value) {
607
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");
608
608
  }
609
609
  const DEFAULT_SETTLE_CAP = 5e3;
610
- 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);
611
613
  const RESIZE_DEBOUNCE = 300;
612
614
  /**
613
615
  * The capture roots for a site, the ONE place the auto/manual root models
@@ -630,7 +632,16 @@ function installXrayClient(options) {
630
632
  const win = window;
631
633
  const defaultDelay = options.delay ?? 200;
632
634
  const settleCap = options.settleCap ?? DEFAULT_SETTLE_CAP;
633
- 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
+ };
634
645
  const sites = /* @__PURE__ */ new Set();
635
646
  const matchMediaQueries = /* @__PURE__ */ new Set();
636
647
  let captureGeneration = 0;
@@ -706,14 +717,14 @@ function installXrayClient(options) {
706
717
  let local;
707
718
  try {
708
719
  local = captureView(roots, {
709
- maxNodes,
720
+ collect: resolveCollect(site.collect),
710
721
  walker: site.walker,
711
722
  captureRootIsBoundary: site.mode === "manual"
712
723
  });
713
724
  } catch (error) {
714
725
  if (error instanceof CaptureTooLargeError) {
715
726
  site.tooLarge = true;
716
- 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})` })]);
717
728
  return;
718
729
  }
719
730
  console.warn("[xray] capture failed for", site.name, error);
@@ -1041,6 +1052,7 @@ function installXrayClient(options) {
1041
1052
  delay: opts?.delay ?? defaultDelay,
1042
1053
  mode: opts?.mode ?? "auto",
1043
1054
  walker: opts?.walker,
1055
+ collect: opts?.collect,
1044
1056
  disposed: false,
1045
1057
  tooLarge: false,
1046
1058
  dirty: false,
package/dist/core.d.ts CHANGED
@@ -1,5 +1,4 @@
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";
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
  /**
@@ -46,4 +45,4 @@ declare function captureElement(roots: readonly Element[], options: CaptureOptio
46
45
  */
47
46
  declare function renderPlateHtml(plate: Plate): string;
48
47
  //#endregion
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 };
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 };
package/dist/core.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { a as ROOT_ATTR, d as emptyPlate, o as ROOT_CLASS, t as BASE_CSS } from "./plate-BRR6d8Se.js";
2
- import { a as serializePlate, c as defineXrayCaptureWalker, i as collectRefs, l as runPlatePasses, o as CaptureTooLargeError, s as captureView, t as serializePlateIR } from "./project-DORoPAo7.js";
2
+ import { a as serializePlate, c as defineXrayCaptureWalker, i as collectRefs, l as runPlatePasses, o as CaptureTooLargeError, s as captureView, t as serializePlateIR } from "./project-BZosujs9.js";
3
3
  import { t as escapeStyleText } from "./css-escape-N7bOusGW.js";
4
4
  //#region src/core.ts
5
5
  /**
@@ -17,6 +17,7 @@ import { t as escapeStyleText } from "./css-escape-N7bOusGW.js";
17
17
  */
18
18
  function captureElement(roots, options) {
19
19
  const plate = captureView(roots, {
20
+ collect: options.collect,
20
21
  walker: options.walker,
21
22
  captureRootIsBoundary: options.captureRootIsBoundary
22
23
  });
package/dist/index.d.ts CHANGED
@@ -323,6 +323,11 @@ interface CaptureHook {
323
323
  delay?: number;
324
324
  walker?: unknown;
325
325
  mode?: 'auto' | 'manual';
326
+ collect?: {
327
+ maxNodes?: number;
328
+ maxBreadth?: number;
329
+ maxDepth?: number;
330
+ };
326
331
  }) => (() => void) | undefined;
327
332
  /** Light Box toggle: force every `<Skeleton>` to its skeleton, read by `<Skeleton>`. */
328
333
  lightbox?: ToggleStore;
@@ -425,8 +430,19 @@ interface XrayOptions {
425
430
  delay?: number;
426
431
  /** Upper bound in milliseconds on waiting for a busy subtree to go quiet before capturing anyway. Default: `5000`. */
427
432
  settleCap?: number;
428
- /** Refuse captures larger than this many nodes (Skeleton too high in the tree). Default: `4000`. */
429
- maxNodes?: number;
433
+ /**
434
+ * Collect-stage limits (ADR 0022): refuse to capture a boundary whose measured
435
+ * tree exceeds any axis — too big means the Skeleton sits too high in the tree.
436
+ * `maxNodes` caps total surviving nodes (default `4096`), `maxBreadth` the widest
437
+ * level (default `512`), `maxDepth` the nesting depth (default `32`). Omit
438
+ * `maxNodes` and it derives as `round(maxBreadth × maxDepth / 4)`. Override one
439
+ * boundary with the flat `maxNodes`/`maxBreadth`/`maxDepth` `<Skeleton>` props.
440
+ */
441
+ collect?: {
442
+ maxNodes?: number;
443
+ maxBreadth?: number;
444
+ maxDepth?: number;
445
+ };
430
446
  /**
431
447
  * Dev-only Fixtures (ADR 0015): record a `<Skeleton>`'s render-input `data` to a
432
448
  * `plates/<name>.fixture.json` sidecar and replay it in place of live data so
package/dist/index.js CHANGED
@@ -260,7 +260,7 @@ const DIAGNOSTIC_MESSAGES = {
260
260
  "dropped-tag": "dropped hidden or non-visual elements (display:none, visibility:hidden, opacity:0, script/style/etc.)",
261
261
  "dropped-subset": "dropped bones wholly inside a larger solid bone — the superset renders the same area (overlapping bones collapse to their union)",
262
262
  "dropped-cropped": "dropped bones below the fold (off-screen at capture, in the viewport or a scroll container); real content fills in below without shifting the visible skeleton",
263
- "too-large": "capture exceeded the node limit and was skipped; the <Skeleton> likely sits too high in the tree",
263
+ "too-large": "capture exceeded a Collect limit and was skipped; the <Skeleton> likely sits too high in the tree",
264
264
  "invalid-plate-file": "a committed plate file could not be read (corrupt JSON, wrong shape, or a version mismatch) and was ignored"
265
265
  };
266
266
  /**
@@ -505,7 +505,7 @@ function xrayVitePlugin(options = {}) {
505
505
  const capture = options.capture ?? false;
506
506
  const delay = options.delay ?? 200;
507
507
  const settleCap = options.settleCap ?? 5e3;
508
- const maxNodes = options.maxNodes ?? 4e3;
508
+ const collect = options.collect;
509
509
  const hud = options.hud ?? false;
510
510
  const recordMode = options.fixtures?.record ?? "manual";
511
511
  const replayMode = options.fixtures?.replay ?? false;
@@ -554,7 +554,7 @@ function xrayVitePlugin(options = {}) {
554
554
  },
555
555
  load(id) {
556
556
  if (id !== RESOLVED_BOOT_ID) return void 0;
557
- return bootstrapCode(delay, settleCap, maxNodes, hud, capture, recordMode, replayMode);
557
+ return bootstrapCode(delay, settleCap, collect, hud, capture, recordMode, replayMode);
558
558
  },
559
559
  configureServer(server) {
560
560
  const dir = () => resolve(root, platesDir);
@@ -591,14 +591,14 @@ function xrayVitePlugin(options = {}) {
591
591
  * The injected dev module: boot the client, POST captures back, optionally
592
592
  * mount the HUD. The client installs first so the HUD finds the light box store.
593
593
  */
594
- function bootstrapCode(delay, settleCap, maxNodes, hud, capture, recordMode, replayMode) {
594
+ function bootstrapCode(delay, settleCap, collect, hud, capture, recordMode, replayMode) {
595
595
  return [
596
596
  `import { installXrayClient } from '@hueest/xray/internal/client'`,
597
597
  ...hud ? [`import { installXrayHud } from '@hueest/xray/internal/hud'`] : [],
598
598
  "installXrayClient({",
599
599
  ` delay: ${delay},`,
600
600
  ` settleCap: ${settleCap},`,
601
- ` maxNodes: ${maxNodes},`,
601
+ ` collect: ${JSON.stringify(collect ?? {})},`,
602
602
  ` capture: ${capture},`,
603
603
  ` record: ${JSON.stringify(recordMode)},`,
604
604
  ` replay: ${JSON.stringify(replayMode)},`,
@@ -837,7 +837,7 @@ const DIAGNOSTIC_MESSAGES = {
837
837
  "dropped-tag": "dropped hidden or non-visual elements (display:none, visibility:hidden, opacity:0, script/style/etc.)",
838
838
  "dropped-subset": "dropped bones wholly inside a larger solid bone — the superset renders the same area (overlapping bones collapse to their union)",
839
839
  "dropped-cropped": "dropped bones below the fold (off-screen at capture, in the viewport or a scroll container); real content fills in below without shifting the visible skeleton",
840
- "too-large": "capture exceeded the node limit and was skipped; the <Skeleton> likely sits too high in the tree",
840
+ "too-large": "capture exceeded a Collect limit and was skipped; the <Skeleton> likely sits too high in the tree",
841
841
  "invalid-plate-file": "a committed plate file could not be read (corrupt JSON, wrong shape, or a version mismatch) and was ignored"
842
842
  };
843
843
  /**
@@ -9281,20 +9281,23 @@ function resolvePaintAndSalience(el, cs, rect, state) {
9281
9281
  salience: nodeSalience(el, cs, rect, state.rootArea, state.win, state.styles)
9282
9282
  };
9283
9283
  }
9284
- /**
9285
- * Thrown when a subtree has more nodes than the capture limit — almost always
9286
- * a `<Skeleton>` sitting too high in the tree. Raised after the cheap walk and
9287
- * before the O(rules × nodes) CSS extraction, so an oversized capture is
9288
- * refused without freezing the main thread on it.
9289
- */
9290
9284
  var CaptureTooLargeError = class extends Error {
9285
+ /** The full measured size of the surviving tree that tripped the gate. */
9286
+ size;
9287
+ /** Which axis exceeded its cap, and the cap it exceeded. */
9288
+ axis;
9289
+ max;
9290
+ /** Back-compat alias for `size.nodes` (the sole axis before breadth/depth). */
9291
9291
  nodeCount;
9292
9292
  /** Folds the oversized-capture warning into the shared diagnostics vocabulary (diagnostics.ts). */
9293
9293
  code = "too-large";
9294
- constructor(nodeCount) {
9295
- super(`xray: capture has ${nodeCount} nodes, over the limit`);
9294
+ constructor(size, axis, max) {
9295
+ super(`xray: capture ${axis} ${size[axis]} over the limit ${max} (nodes ${size.nodes}, breadth ${size.breadth}, depth ${size.depth})`);
9296
9296
  this.name = "CaptureTooLargeError";
9297
- this.nodeCount = nodeCount;
9297
+ this.size = size;
9298
+ this.axis = axis;
9299
+ this.max = max;
9300
+ this.nodeCount = size.nodes;
9298
9301
  }
9299
9302
  };
9300
9303
  /**
@@ -9313,7 +9316,7 @@ var CaptureTooLargeError = class extends Error {
9313
9316
  * (`ids: []`, the inherited parent font `baseRules` derives), lifted onto the
9314
9317
  * bundle for `emit` to fold back in.
9315
9318
  */
9316
- function build(walked, doc, win, styles, diagnostics, maxNodes, walker) {
9319
+ function build(walked, doc, win, styles, diagnostics, collect, walker) {
9317
9320
  const bundle = {
9318
9321
  root: walked.root,
9319
9322
  nodes: walked.nodes,
@@ -9321,8 +9324,12 @@ function build(walked, doc, win, styles, diagnostics, maxNodes, walker) {
9321
9324
  };
9322
9325
  if (walker?.element) applyWalker(bundle.root, walker);
9323
9326
  markInk(bundle.root);
9324
- const surviving = maxNodes !== void 0 ? countSurviving(bundle.root) : 0;
9325
- if (maxNodes !== void 0 && surviving > maxNodes) throw new CaptureTooLargeError(surviving);
9327
+ if (collect?.maxNodes !== void 0 || collect?.maxBreadth !== void 0 || collect?.maxDepth !== void 0) {
9328
+ const size = measureTree(bundle.root);
9329
+ if (collect?.maxNodes !== void 0 && size.nodes > collect.maxNodes) throw new CaptureTooLargeError(size, "nodes", collect.maxNodes);
9330
+ if (collect?.maxBreadth !== void 0 && size.breadth > collect.maxBreadth) throw new CaptureTooLargeError(size, "breadth", collect.maxBreadth);
9331
+ if (collect?.maxDepth !== void 0 && size.depth > collect.maxDepth) throw new CaptureTooLargeError(size, "depth", collect.maxDepth);
9332
+ }
9326
9333
  const contextRule = copyRules(walked.nodes, doc, win, styles, diagnostics, walked.extraRules, indexById(bundle.root)).find((rule) => rule.spec === -2 && rule.ids.length === 0);
9327
9334
  if (contextRule) bundle.contextRule = contextRule;
9328
9335
  return bundle;
@@ -9380,22 +9387,41 @@ function runPlatePasses(plate) {
9380
9387
  });
9381
9388
  }
9382
9389
  /**
9383
- * Count the SURVIVING (non-dropped) measured nodes under the synthetic root the
9384
- * gate's units. Walks via `effectiveKids`, so a `dropped` node (and its non-re-shown
9385
- * subtree) and a `flattened` wrapper are handled exactly as `emit` will lower them;
9386
- * the gate then measures the real post-mark output rather than every measured
9387
- * element (which now includes the hidden subtrees Measure descends). The synthetic
9388
- * root (id 0, el null) is NOT a measured entry, so it is excluded keeping the
9389
- * count in the same units as the legacy `entries.length` gate.
9390
+ * Measure the SURVIVING (non-dropped) tree in one DFS pass: total `nodes`, max
9391
+ * nesting `depth`, and `breadth` the widest level (most nodes at any one depth).
9392
+ *
9393
+ * Walks via `effectiveKids`, so a `dropped` node (and its non-re-shown subtree) and
9394
+ * a `flattened` wrapper are handled exactly as `emit` will lower them — the gate
9395
+ * measures the real post-mark output, not every element Measure descended. The
9396
+ * synthetic root (id 0, el null) is NOT a measured entry, so it is excluded,
9397
+ * keeping `nodes` in the same units as the legacy `entries.length` gate.
9398
+ *
9399
+ * Widest-level `breadth` is the realistic "how wide does this tree get" signal: a
9400
+ * DOM tree's total tracks its widest level (~5×), not `breadth × depth` (which
9401
+ * assumes every level is maximally wide — a fractal that never occurs). Feeds the
9402
+ * Collect gate.
9390
9403
  */
9391
- function countSurviving(root) {
9392
- let count = 0;
9393
- const visit = (node) => {
9394
- count += 1;
9395
- for (const kid of effectiveKids(node)) visit(kid);
9404
+ function measureTree(root) {
9405
+ let nodes = 0;
9406
+ let depth = 0;
9407
+ const perLevel = [];
9408
+ const visit = (node, d) => {
9409
+ nodes += 1;
9410
+ if (d > depth) depth = d;
9411
+ perLevel[d] = (perLevel[d] ?? 0) + 1;
9412
+ for (const kid of effectiveKids(node)) visit(kid, d + 1);
9413
+ };
9414
+ for (const kid of effectiveKids(root)) visit(kid, 1);
9415
+ let breadth = 0;
9416
+ for (let d = 1; d <= depth; d += 1) {
9417
+ const w = perLevel[d] ?? 0;
9418
+ if (w > breadth) breadth = w;
9419
+ }
9420
+ return {
9421
+ nodes,
9422
+ depth,
9423
+ breadth
9396
9424
  };
9397
- for (const kid of effectiveKids(root)) visit(kid);
9398
- return count;
9399
9425
  }
9400
9426
  /**
9401
9427
  * Measure one View's subtree into an `IRBundle` — the Measure stage (ADR 0022
@@ -9417,7 +9443,7 @@ function measureBundle(roots, options = {}) {
9417
9443
  const diagnostics = createDiagnostics();
9418
9444
  const styles = new StyleCache(win);
9419
9445
  return {
9420
- bundle: build(walkAll(roots, win, styles, diagnostics, options.captureRootIsBoundary ?? false), doc, win, styles, diagnostics, options.maxNodes, options.walker),
9446
+ bundle: build(walkAll(roots, win, styles, diagnostics, options.captureRootIsBoundary ?? false), doc, win, styles, diagnostics, options.collect, options.walker),
9421
9447
  win,
9422
9448
  diagnostics
9423
9449
  };
@@ -268,5 +268,27 @@ function renderChunks(chunks, resolveStitch) {
268
268
  }, i);
269
269
  });
270
270
  }
271
+ /**
272
+ * A never-settling promise for the loading suspender below. It never needs to
273
+ * resolve: React retries a suspended boundary when its parent re-renders with new
274
+ * props (here, `loading` flipping to false), so the prop change — not the promise —
275
+ * drives recovery.
276
+ */
277
+ const SUSPEND_UNTIL_READY = new Promise(() => {});
278
+ /**
279
+ * Suspends while `loading` is true. It exists so a `suspense`-mode `<Skeleton>` can
280
+ * show its plate through the ONE Suspense fallback across BOTH readiness phases — the
281
+ * data-loading phase (this suspender) AND the lazy code-chunk phase (the content
282
+ * itself suspending). Because the plate is the SAME fallback DOM node the whole time,
283
+ * there is no unmount/remount between the two phases and thus no flicker (a top-level
284
+ * `return skeleton` for the data phase followed by a Suspense-fallback skeleton for
285
+ * the chunk phase are different tree positions, so they DID remount). While this
286
+ * throws, the content sibling is never committed, preserving "children never render
287
+ * without data" (ADR 0002).
288
+ */
289
+ function SuspendWhileLoading({ loading }) {
290
+ if (loading) throw SUSPEND_UNTIL_READY;
291
+ return null;
292
+ }
271
293
  //#endregion
272
- export { renderPlate as a, markStitchesLive as i, isRenderProp as n, resolveContent as o, isStitchLive as r, useSkeletonTiming as s, collectStitchNames as t };
294
+ export { markStitchesLive as a, useSkeletonTiming as c, isStitchLive as i, collectStitchNames as n, renderPlate as o, isRenderProp as r, resolveContent as s, SuspendWhileLoading as t };
@@ -1,5 +1,4 @@
1
- import { r as Plate } from "./plate-BIr62u7l.js";
2
- import { a as XrayCaptureWalker } from "./serialize-Cs7hUxmK.js";
1
+ import { c as XrayCaptureWalker, f as Plate } from "./serialize-BgdGt34A.js";
3
2
  import { ReactNode } from "react";
4
3
 
5
4
  //#region src/react.core.d.ts
@@ -48,6 +47,17 @@ interface SkeletonBase {
48
47
  * `root` variants of the union, so a manual root may carry one too.
49
48
  */
50
49
  captureWalker?: XrayCaptureWalker;
50
+ /**
51
+ * Per-Skeleton Collect limits (ADR 0022, dev only): override the plugin's
52
+ * `collect` defaults for THIS boundary's capture. Flat primitives — not a nested
53
+ * object — so they stay referentially stable across renders and never churn the
54
+ * dev capture effect's deps. `maxNodes` caps total surviving nodes, `maxBreadth`
55
+ * the widest level, `maxDepth` the nesting depth; an omitted `maxNodes` derives
56
+ * from the resolved breadth × depth. PRODUCTION ignores them (it never captures).
57
+ */
58
+ maxNodes?: number;
59
+ maxBreadth?: number;
60
+ maxDepth?: number;
51
61
  }
52
62
  /**
53
63
  * Props accepted by the React `<Skeleton>` component, generic over the optional
package/dist/react.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { t as SkeletonProps } from "./react.core-BUj8ziwh.js";
1
+ import { t as SkeletonProps } from "./react.core-NhWc9qan.js";
2
2
 
3
3
  //#region src/react.d.ts
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { t as SkeletonProps } from "./react.core-BUj8ziwh.js";
1
+ import { t as SkeletonProps } from "./react.core-NhWc9qan.js";
2
2
 
3
3
  //#region src/react.dev.d.ts
4
4
  /**
package/dist/react.dev.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { n as BOUNDARY_ATTR } from "./plate-BRR6d8Se.js";
2
- import { a as renderPlate, i as markStitchesLive, n as isRenderProp, o as resolveContent, r as isStitchLive, s as useSkeletonTiming, t as collectStitchNames } from "./react.core-DMIhXHZF.js";
2
+ import { a as markStitchesLive, c as useSkeletonTiming, i as isStitchLive, n as collectStitchNames, o as renderPlate, r as isRenderProp, s as resolveContent, t as SuspendWhileLoading } from "./react.core-BC02ukac.js";
3
3
  import { Suspense, useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
4
- import { Fragment as Fragment$1, jsx } from "react/jsx-runtime";
4
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
5
5
  //#region src/react.dev.tsx
6
6
  const noSubscribe = () => () => {};
7
7
  const alwaysFalse = () => false;
@@ -88,7 +88,7 @@ function useLivePlate(plate) {
88
88
  * `react.tsx`.
89
89
  */
90
90
  function Skeleton(props) {
91
- const { plate, loading = false, suspense = false, fallback, delay, children, data, captureWalker } = props;
91
+ const { plate, loading = false, suspense = false, fallback, delay, children, data, captureWalker, maxNodes, maxBreadth, maxDepth } = props;
92
92
  const manual = props.root === "manual";
93
93
  const lightBoxOn = useLightBox();
94
94
  const livePlate = useLivePlate(plate);
@@ -112,11 +112,14 @@ function Skeleton(props) {
112
112
  manual,
113
113
  data,
114
114
  captureWalker,
115
+ maxNodes,
116
+ maxBreadth,
117
+ maxDepth,
115
118
  children
116
119
  });
117
- if (suspense) return /* @__PURE__ */ jsx(Suspense, {
120
+ if (suspense) return /* @__PURE__ */ jsxs(Suspense, {
118
121
  fallback: skeleton,
119
- children: content
122
+ children: [/* @__PURE__ */ jsx(SuspendWhileLoading, { loading: showSkeleton }), content]
120
123
  });
121
124
  if (showSkeleton) return skeleton;
122
125
  return content;
@@ -128,6 +131,20 @@ function Content(props) {
128
131
  return /* @__PURE__ */ jsx(CaptureBoundary, { ...props });
129
132
  }
130
133
  /**
134
+ * Assemble the per-site Collect limits (ADR 0022) from the flat `<Skeleton>` props
135
+ * into the nested shape `captured` expects, or `undefined` when none are set (so
136
+ * the client's plugin defaults apply). Called inside the capture effect, keyed on
137
+ * the flat primitives, so it never builds an object during render.
138
+ */
139
+ function collectLimits(maxNodes, maxBreadth, maxDepth) {
140
+ if (maxNodes === void 0 && maxBreadth === void 0 && maxDepth === void 0) return void 0;
141
+ return {
142
+ maxNodes,
143
+ maxBreadth,
144
+ maxDepth
145
+ };
146
+ }
147
+ /**
131
148
  * `root="auto"` (default): the existing `display:contents` boundary wrapper marks
132
149
  * the capture seam so a parent capture stops here and references this plate by
133
150
  * name rather than re-serializing the content (ADR 0006). A render prop is called
@@ -135,7 +152,7 @@ function Content(props) {
135
152
  * element, carries the marker); its result renders inside the wrapper exactly as
136
153
  * plain children do.
137
154
  */
138
- function CaptureBoundary({ plate, delay, children, data, captureWalker }) {
155
+ function CaptureBoundary({ plate, delay, children, data, captureWalker, maxNodes, maxBreadth, maxDepth }) {
139
156
  const ref = useRef(null);
140
157
  useEffect(() => {
141
158
  const el = ref.current;
@@ -143,12 +160,16 @@ function CaptureBoundary({ plate, delay, children, data, captureWalker }) {
143
160
  return globalThis.__XRAY__?.captured(plate.name, el, {
144
161
  delay,
145
162
  walker: captureWalker,
146
- mode: "auto"
163
+ mode: "auto",
164
+ collect: collectLimits(maxNodes, maxBreadth, maxDepth)
147
165
  });
148
166
  }, [
149
167
  plate.name,
150
168
  delay,
151
- captureWalker
169
+ captureWalker,
170
+ maxNodes,
171
+ maxBreadth,
172
+ maxDepth
152
173
  ]);
153
174
  const effective = useFixtureData(plate.name, data, isRenderProp(children));
154
175
  return /* @__PURE__ */ jsx("div", {
@@ -171,7 +192,7 @@ function CaptureBoundary({ plate, delay, children, data, captureWalker }) {
171
192
  * can produce. A dev-time effect asserts the ref fired and loudly `console.error`s
172
193
  * when it did not.
173
194
  */
174
- function ManualBoundary({ plate, delay, children, data, captureWalker }) {
195
+ function ManualBoundary({ plate, delay, children, data, captureWalker, maxNodes, maxBreadth, maxDepth }) {
175
196
  const disposeRef = useRef(void 0);
176
197
  const attachedRef = useRef(false);
177
198
  const ref = useCallback((el) => {
@@ -183,12 +204,16 @@ function ManualBoundary({ plate, delay, children, data, captureWalker }) {
183
204
  disposeRef.current = globalThis.__XRAY__?.captured(plate.name, el, {
184
205
  delay,
185
206
  walker: captureWalker,
186
- mode: "manual"
207
+ mode: "manual",
208
+ collect: collectLimits(maxNodes, maxBreadth, maxDepth)
187
209
  });
188
210
  }, [
189
211
  plate.name,
190
212
  delay,
191
- captureWalker
213
+ captureWalker,
214
+ maxNodes,
215
+ maxBreadth,
216
+ maxDepth
192
217
  ]);
193
218
  useEffect(() => () => disposeRef.current?.(), []);
194
219
  useEffect(() => {
package/dist/react.js CHANGED
@@ -1,6 +1,6 @@
1
- import { a as renderPlate, i as markStitchesLive, o as resolveContent, r as isStitchLive, s as useSkeletonTiming, t as collectStitchNames } from "./react.core-DMIhXHZF.js";
1
+ import { a as markStitchesLive, c as useSkeletonTiming, i as isStitchLive, n as collectStitchNames, o as renderPlate, s as resolveContent, t as SuspendWhileLoading } from "./react.core-BC02ukac.js";
2
2
  import { Suspense, useEffect, useRef, useState } from "react";
3
- import { Fragment as Fragment$1, jsx } from "react/jsx-runtime";
3
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
4
4
  //#region src/react.tsx
5
5
  /**
6
6
  * React adapter for rendering committed Plates through `<Skeleton>`.
@@ -44,9 +44,9 @@ function Skeleton(props) {
44
44
  rootRef,
45
45
  instant
46
46
  });
47
- if (suspense) return /* @__PURE__ */ jsx(Suspense, {
47
+ if (suspense) return /* @__PURE__ */ jsxs(Suspense, {
48
48
  fallback: skeleton,
49
- children: resolveContent(children, data)
49
+ children: [/* @__PURE__ */ jsx(SuspendWhileLoading, { loading: showSkeleton }), resolveContent(children, data)]
50
50
  });
51
51
  if (showSkeleton) return skeleton;
52
52
  return /* @__PURE__ */ jsx(Fragment$1, { children: resolveContent(children, data) });
@@ -323,6 +323,11 @@ interface CaptureHook {
323
323
  delay?: number;
324
324
  walker?: unknown;
325
325
  mode?: 'auto' | 'manual';
326
+ collect?: {
327
+ maxNodes?: number;
328
+ maxBreadth?: number;
329
+ maxDepth?: number;
330
+ };
326
331
  }) => (() => void) | undefined;
327
332
  /** Light Box toggle: force every `<Skeleton>` to its skeleton, read by `<Skeleton>`. */
328
333
  lightbox?: ToggleStore;
@@ -360,4 +365,137 @@ declare global {
360
365
  var __XRAY__: CaptureHook | undefined;
361
366
  }
362
367
  //#endregion
363
- export { RenderNode as a, emptyPlate as c, PlateNode as i, CaptureDiagnostic as l, MergedPlate as n, StoredPlate as o, Plate as r, ViewCapture as s, LeafKind as t };
368
+ //#region src/serialize.d.ts
369
+ /**
370
+ * Browser-side capture: walk a live rendered subtree into a Plate — one node
371
+ * tree plus one self-contained, scoped CSS string (ADR 0003). The CSS is
372
+ * lifted from the author rules that matched each node, rewritten to
373
+ * plate-local selectors — it carries `@media` for free and keeps layout live
374
+ * (the M1 bake-off winner; ADR 0005).
375
+ */
376
+ interface CaptureOptions {
377
+ /** Plate name; comes from the virtual-module specifier. */
378
+ name: string;
379
+ /** Collect limits (ADR 0022): refuse a capture whose measured tree exceeds any axis. */
380
+ collect?: CollectLimits;
381
+ /**
382
+ * Optional programmable capture walker (ADR 0018). The declarative `data-xr-*`
383
+ * annotations cover the 90%; this is the escape hatch for the 10% a markup
384
+ * attribute cannot express. Capture-only: it shapes the resulting `PlateNode`
385
+ * tree but is never persisted into the Plate, and the production adapter never
386
+ * captures so it ignores this entirely.
387
+ */
388
+ walker?: XrayCaptureWalker;
389
+ /**
390
+ * Whether the top-level capture roots ARE this plate's own boundary, so the
391
+ * stitch guard must be SKIPPED for them (ADR 0006/0014, finding #6). True ONLY
392
+ * for a MANUAL root: there `site.el` is the consumer's own element and its
393
+ * `data-xr-boundary` marks the boundary of THIS plate, so the root must be
394
+ * serialized as a real Entry (its box/classes/padding/border) rather than a
395
+ * stitch-to-itself. Defaults to `false` — the auto path, where the top-level
396
+ * roots are the wrapper's CHILDREN and any boundary marker on a direct-child
397
+ * root is a NESTED `<Skeleton>` that MUST stitch (the capture-leak guard).
398
+ */
399
+ captureRootIsBoundary?: boolean;
400
+ }
401
+ /**
402
+ * What a capture walker decides for one element (ADR 0018; ADR 0022 §5). The
403
+ * `applyWalker` PASS reads the decision and applies it to the measured IR node —
404
+ * a walker NEVER hand-builds a `PlateNode`, so xray keeps ownership of the Plate
405
+ * format. `ignore` drops the element + its subtree; `bone` collapses it to one
406
+ * Bone leaf (inferring the kind when omitted); `keep` defers to the default
407
+ * classifier (`markInk`).
408
+ */
409
+ type WalkerDecision = {
410
+ type: 'ignore';
411
+ } | {
412
+ type: 'bone';
413
+ kind?: LeafKind;
414
+ } | {
415
+ type: 'keep';
416
+ };
417
+ /**
418
+ * The context handed to a capture walker's `element` hook (ADR 0018). The
419
+ * helpers are PURE — each returns a `WalkerDecision` the `applyWalker` pass
420
+ * applies; they do NOT mutate. `el` is the element under consideration.
421
+ */
422
+ interface CaptureWalkerContext {
423
+ /** The element currently being classified. */
424
+ el: Element;
425
+ /** Drop this element AND its whole subtree (no bone). */
426
+ ignore(): WalkerDecision;
427
+ /**
428
+ * Collapse this element's subtree into ONE Bone leaf. `kind` omitted shares
429
+ * the default classifier's kind-inference heuristic (`inferLeafKind`; ADR 0018,
430
+ * Q4 — ONE code path).
431
+ */
432
+ bone(opts?: {
433
+ kind?: LeafKind;
434
+ }): WalkerDecision;
435
+ /** Defer to the default classifier (`markInk`) — recurse into the subtree. */
436
+ keep(): WalkerDecision;
437
+ }
438
+ /**
439
+ * The programmable capture walker (ADR 0018; ADR 0022 §5). `element` is offered
440
+ * each non-boundary element TOP-DOWN over the measured IR (the `applyWalker`
441
+ * pass, NOT the DOM walk) and returns a `WalkerDecision` the pass applies. The
442
+ * declarative `data-xr-*` annotations cover the 90%; this is the 10% escape
443
+ * hatch for what a markup attribute cannot express.
444
+ */
445
+ interface XrayCaptureWalker {
446
+ /**
447
+ * Called for every non-boundary element node (a stitch carries no element and
448
+ * is skipped — ADR 0006 — so a boundary never reaches here). Return one of
449
+ * `ctx.ignore()`, `ctx.bone()`, or `ctx.keep()`. An `ignore`/`bone` decision
450
+ * stops the descent (the subtree lowers); `keep` recurses.
451
+ */
452
+ element?(ctx: CaptureWalkerContext): WalkerDecision;
453
+ }
454
+ /**
455
+ * Typed identity wrapper for authoring a capture walker (ADR 0018) — the
456
+ * `defineConfig` pattern. It returns its argument unchanged at runtime; its only
457
+ * job is to infer and check the `XrayCaptureWalker` shape at the call site so an
458
+ * author gets completion and a typo in a hook name is caught.
459
+ */
460
+ declare function defineXrayCaptureWalker(walker: XrayCaptureWalker): XrayCaptureWalker;
461
+ /**
462
+ * Thrown when a subtree has more nodes than the capture limit — almost always
463
+ * a `<Skeleton>` sitting too high in the tree. Raised after the cheap walk and
464
+ * before the O(rules × nodes) CSS extraction, so an oversized capture is
465
+ * refused without freezing the main thread on it.
466
+ */
467
+ /** The measured size of a capture's surviving tree, one number per Collect axis (ADR 0022). */
468
+ type CaptureSize = {
469
+ nodes: number;
470
+ breadth: number;
471
+ depth: number;
472
+ };
473
+ /** Which Collect axis a too-large capture tripped. */
474
+ type CollectAxis = 'nodes' | 'breadth' | 'depth';
475
+ declare class CaptureTooLargeError extends Error {
476
+ /** The full measured size of the surviving tree that tripped the gate. */
477
+ readonly size: CaptureSize;
478
+ /** Which axis exceeded its cap, and the cap it exceeded. */
479
+ readonly axis: CollectAxis;
480
+ readonly max: number;
481
+ /** Back-compat alias for `size.nodes` (the sole axis before breadth/depth). */
482
+ readonly nodeCount: number;
483
+ /** Folds the oversized-capture warning into the shared diagnostics vocabulary (diagnostics.ts). */
484
+ readonly code: "too-large";
485
+ constructor(size: CaptureSize, axis: CollectAxis, max: number);
486
+ }
487
+ /**
488
+ * Collect-stage size limits (ADR 0022): refuse to capture a boundary whose
489
+ * measured tree exceeds any axis. `maxNodes` caps the total surviving-node count;
490
+ * `maxBreadth` the widest level (most nodes at any one depth); `maxDepth` the
491
+ * nesting depth. Each axis is enforced only when its cap is defined. The defaults
492
+ * and the `maxNodes = round(maxBreadth × maxDepth / 4)` derivation live in the
493
+ * client (where the other defaults live); this core only enforces what it is handed.
494
+ */
495
+ type CollectLimits = {
496
+ maxNodes?: number;
497
+ maxBreadth?: number;
498
+ maxDepth?: number;
499
+ };
500
+ //#endregion
501
+ export { emptyPlate as _, CollectAxis as a, XrayCaptureWalker as c, MergedPlate as d, Plate as f, ViewCapture as g, StoredPlate as h, CaptureWalkerContext as i, defineXrayCaptureWalker as l, RenderNode as m, CaptureSize as n, CollectLimits as o, PlateNode as p, CaptureTooLargeError as r, WalkerDecision as s, CaptureOptions as t, LeafKind as u, CaptureDiagnostic as v };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hueest/xray",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Auto-captured skeleton loading screens for Vite — capture the structure, ship the plate.",
5
5
  "keywords": [
6
6
  "loading",
@@ -1,107 +0,0 @@
1
- import { t as LeafKind } from "./plate-BIr62u7l.js";
2
-
3
- //#region src/serialize.d.ts
4
- /**
5
- * Browser-side capture: walk a live rendered subtree into a Plate — one node
6
- * tree plus one self-contained, scoped CSS string (ADR 0003). The CSS is
7
- * lifted from the author rules that matched each node, rewritten to
8
- * plate-local selectors — it carries `@media` for free and keeps layout live
9
- * (the M1 bake-off winner; ADR 0005).
10
- */
11
- interface CaptureOptions {
12
- /** Plate name; comes from the virtual-module specifier. */
13
- name: string;
14
- /**
15
- * Optional programmable capture walker (ADR 0018). The declarative `data-xr-*`
16
- * annotations cover the 90%; this is the escape hatch for the 10% a markup
17
- * attribute cannot express. Capture-only: it shapes the resulting `PlateNode`
18
- * tree but is never persisted into the Plate, and the production adapter never
19
- * captures so it ignores this entirely.
20
- */
21
- walker?: XrayCaptureWalker;
22
- /**
23
- * Whether the top-level capture roots ARE this plate's own boundary, so the
24
- * stitch guard must be SKIPPED for them (ADR 0006/0014, finding #6). True ONLY
25
- * for a MANUAL root: there `site.el` is the consumer's own element and its
26
- * `data-xr-boundary` marks the boundary of THIS plate, so the root must be
27
- * serialized as a real Entry (its box/classes/padding/border) rather than a
28
- * stitch-to-itself. Defaults to `false` — the auto path, where the top-level
29
- * roots are the wrapper's CHILDREN and any boundary marker on a direct-child
30
- * root is a NESTED `<Skeleton>` that MUST stitch (the capture-leak guard).
31
- */
32
- captureRootIsBoundary?: boolean;
33
- }
34
- /**
35
- * What a capture walker decides for one element (ADR 0018; ADR 0022 §5). The
36
- * `applyWalker` PASS reads the decision and applies it to the measured IR node —
37
- * a walker NEVER hand-builds a `PlateNode`, so xray keeps ownership of the Plate
38
- * format. `ignore` drops the element + its subtree; `bone` collapses it to one
39
- * Bone leaf (inferring the kind when omitted); `keep` defers to the default
40
- * classifier (`markInk`).
41
- */
42
- type WalkerDecision = {
43
- type: 'ignore';
44
- } | {
45
- type: 'bone';
46
- kind?: LeafKind;
47
- } | {
48
- type: 'keep';
49
- };
50
- /**
51
- * The context handed to a capture walker's `element` hook (ADR 0018). The
52
- * helpers are PURE — each returns a `WalkerDecision` the `applyWalker` pass
53
- * applies; they do NOT mutate. `el` is the element under consideration.
54
- */
55
- interface CaptureWalkerContext {
56
- /** The element currently being classified. */
57
- el: Element;
58
- /** Drop this element AND its whole subtree (no bone). */
59
- ignore(): WalkerDecision;
60
- /**
61
- * Collapse this element's subtree into ONE Bone leaf. `kind` omitted shares
62
- * the default classifier's kind-inference heuristic (`inferLeafKind`; ADR 0018,
63
- * Q4 — ONE code path).
64
- */
65
- bone(opts?: {
66
- kind?: LeafKind;
67
- }): WalkerDecision;
68
- /** Defer to the default classifier (`markInk`) — recurse into the subtree. */
69
- keep(): WalkerDecision;
70
- }
71
- /**
72
- * The programmable capture walker (ADR 0018; ADR 0022 §5). `element` is offered
73
- * each non-boundary element TOP-DOWN over the measured IR (the `applyWalker`
74
- * pass, NOT the DOM walk) and returns a `WalkerDecision` the pass applies. The
75
- * declarative `data-xr-*` annotations cover the 90%; this is the 10% escape
76
- * hatch for what a markup attribute cannot express.
77
- */
78
- interface XrayCaptureWalker {
79
- /**
80
- * Called for every non-boundary element node (a stitch carries no element and
81
- * is skipped — ADR 0006 — so a boundary never reaches here). Return one of
82
- * `ctx.ignore()`, `ctx.bone()`, or `ctx.keep()`. An `ignore`/`bone` decision
83
- * stops the descent (the subtree lowers); `keep` recurses.
84
- */
85
- element?(ctx: CaptureWalkerContext): WalkerDecision;
86
- }
87
- /**
88
- * Typed identity wrapper for authoring a capture walker (ADR 0018) — the
89
- * `defineConfig` pattern. It returns its argument unchanged at runtime; its only
90
- * job is to infer and check the `XrayCaptureWalker` shape at the call site so an
91
- * author gets completion and a typo in a hook name is caught.
92
- */
93
- declare function defineXrayCaptureWalker(walker: XrayCaptureWalker): XrayCaptureWalker;
94
- /**
95
- * Thrown when a subtree has more nodes than the capture limit — almost always
96
- * a `<Skeleton>` sitting too high in the tree. Raised after the cheap walk and
97
- * before the O(rules × nodes) CSS extraction, so an oversized capture is
98
- * refused without freezing the main thread on it.
99
- */
100
- declare class CaptureTooLargeError extends Error {
101
- readonly nodeCount: number;
102
- /** Folds the oversized-capture warning into the shared diagnostics vocabulary (diagnostics.ts). */
103
- readonly code: "too-large";
104
- constructor(nodeCount: number);
105
- }
106
- //#endregion
107
- export { XrayCaptureWalker as a, WalkerDecision as i, CaptureTooLargeError as n, defineXrayCaptureWalker as o, CaptureWalkerContext as r, CaptureOptions as t };