@hueest/xray 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,51 @@
1
+ //#region src/diagnostics.d.ts
2
+ /**
3
+ * Dev-only capture diagnostics: a small, serializable record of why a Plate may
4
+ * be incomplete or lower-fidelity, plus the one shared formatter both the
5
+ * browser console (`client.ts`) and the Vite server output (`index.ts`) print.
6
+ *
7
+ * Motivation: the serializer silently swallows a handful of capture omissions
8
+ * (an unreadable cross-origin stylesheet, skipped pseudo-elements, a collapsed
9
+ * list run, an over-the-limit subtree). Early adopters asking "why does this
10
+ * skeleton look wrong?" had to read the serializer source to find out. These
11
+ * diagnostics surface those omissions as data the capture process returns, so a
12
+ * caller can log them — without the core performing any console side effects.
13
+ *
14
+ * Strictly DEV-ONLY (see the plan's decisions and ADR 0008): this type is NOT a
15
+ * public package export and MUST NEVER reach the persisted Plate JSON, the
16
+ * `MergedPlate`, or the production adapter. It rides on the TRANSIENT
17
+ * `ViewCapture.diagnostics` field only, which `captureRegime` populates and the
18
+ * dev client strips before posting (so committed `plates/<name>.json` stays
19
+ * byte-identical to a capture that produced no diagnostics).
20
+ *
21
+ * Centralized on purpose: a follow-up plan (capture-input-validation) reuses
22
+ * this same messaging for invalid posted/committed plates, so the code table
23
+ * and formatter are kept reusable. Browser and server share `formatDiagnostic`
24
+ * so their warning text cannot drift.
25
+ *
26
+ * @module
27
+ */
28
+ /**
29
+ * One aggregated capture omission. Serializable on purpose: NO DOM nodes, NO
30
+ * CSS bodies, NO captured text — only a stable `code`, a human message, an
31
+ * optional aggregate `count`, and an optional short `detail` (e.g. a plate name
32
+ * or a node count, never user content). `count` aggregates occurrences: there
33
+ * is at most one record per code per capture, never one per skipped selector or
34
+ * node.
35
+ */
36
+ interface CaptureDiagnostic {
37
+ code: DiagnosticCode;
38
+ message: string;
39
+ count?: number;
40
+ detail?: string;
41
+ }
42
+ /**
43
+ * The closed set of capture-omission codes. Each maps to one silent site in the
44
+ * serializer (or an existing oversized-capture warning folded into the same
45
+ * vocabulary). Kept as a const union so the message table is exhaustive.
46
+ */
47
+ type DiagnosticCode = 'unreadable-stylesheet' | 'unreadable-import' | 'skipped-dynamic-pseudo' | 'skipped-pseudo-element' | 'unsupported-rule' | 'dropped-tag' | 'pruned-run' | 'too-large' | 'invalid-plate-file';
48
+ //#endregion
1
49
  //#region src/plate.d.ts
2
50
  type LeafKind = 'text' | 'media' | 'box';
3
51
  /**
@@ -108,6 +156,17 @@ interface ViewCapture {
108
156
  max?: number;
109
157
  tree: PlateNode;
110
158
  rules: PlateRule[];
159
+ /**
160
+ * TRANSIENT, dev-only capture diagnostics (why this view may be lower
161
+ * fidelity). Populated by `captureRegime` in the browser; the dev client logs
162
+ * it and posts it (so the Vite server can print the same text), then it is
163
+ * STRIPPED at the merge layer (`addCapture`) before persisting. It MUST NEVER
164
+ * be persisted: it is not part of the committed `plates/<name>.json` shape,
165
+ * the `isViewCapture` validator does not require it, and `addCapture` drops it
166
+ * so neither it nor `mergeViews` ever writes it. Absent in production (the
167
+ * prod adapter ships no capture). See diagnostics.ts and ADR 0008.
168
+ */
169
+ diagnostics?: CaptureDiagnostic[];
111
170
  }
112
171
  /** The committed `plates/<name>.json`: per-view captures, merged at load. */
113
172
  interface PlateFile {
@@ -151,6 +210,68 @@ interface Span {
151
210
  min?: number;
152
211
  max?: number;
153
212
  }
213
+ /**
214
+ * Dev-only Fixture store (ADR 0015): stored render-INPUT `data` per Plate,
215
+ * recorded to and replayed from a `plates/<name>.fixture.json` sidecar. Entirely
216
+ * dev-only — devalue and this store never reach the production bundle.
217
+ *
218
+ * Two cooperating halves, both flowing through the render-prop `data` seam (ADR
219
+ * 0014):
220
+ * - The dev adapter (`react.dev.tsx`) calls `register(name, data)` at
221
+ * `loading=false` with the live `data` it is about to render, so the latest
222
+ * recordable input for each mounted Plate is always in scope for Record.
223
+ * - `record(name)` serializes that registered live `data` with devalue and POSTs
224
+ * it to the sidecar (the HUD button and the `first-ready`/`always` modes call
225
+ * it). `has(name)` reports whether a Fixture exists (HUD coverage marker), and
226
+ * `read(name)` returns the parsed Fixture `data` for Replay to substitute in
227
+ * front of the render prop.
228
+ *
229
+ * The store holds DECODED values (the dev client owns the devalue parse, since it
230
+ * has the live runtime); only the on-disk sidecar and the wire carry the opaque
231
+ * devalue string.
232
+ */
233
+ interface FixtureStore {
234
+ /**
235
+ * Record the latest live `data` the dev adapter is about to render for `name`,
236
+ * so Record always has the current recordable input in scope. A `register` with
237
+ * the same value is cheap; the adapter calls it on every ready render. Returns a
238
+ * disposer the adapter runs on unmount so an unmounted Skeleton's `data` never
239
+ * lingers in the store: an unmounted instance must never be recorded or shadow a
240
+ * still-mounted one (ADR 0015). With several Skeletons of the same plate name
241
+ * mounted, the live value is the most-recently-registered still-mounted instance.
242
+ */
243
+ register: (name: string, data: unknown) => () => void;
244
+ /** True once a recorded Fixture exists for `name` (drives the HUD coverage marker). */
245
+ has: (name: string) => boolean;
246
+ /** The parsed Fixture `data` for `name`, or `undefined` when none is recorded. */
247
+ read: (name: string) => {
248
+ data: unknown;
249
+ } | undefined;
250
+ /**
251
+ * Serialize the registered live `data` for `name` with devalue and POST it to
252
+ * the sidecar. Returns a promise that resolves once the write is
253
+ * server-confirmed (so the HUD can surface a failed Record). Throws loudly when
254
+ * the live `data` is unrecordable (devalue's contract) or no live `data` was
255
+ * registered (a plain-children `<Skeleton>` has none).
256
+ */
257
+ record: (name: string) => Promise<void>;
258
+ /**
259
+ * Record every Skeleton that currently has live `data` registered (the HUD's
260
+ * single "Record fixture" button — one click captures the inputs of all mounted
261
+ * render-prop Skeletons). Resolves once all writes are server-confirmed; a
262
+ * rejected write surfaces to the HUD.
263
+ */
264
+ recordAll: () => Promise<void>;
265
+ /**
266
+ * Seed the store from the on-disk sidecars on boot: a `{ <name>: devalueString }`
267
+ * map (the opaque strings the server serves). The client decodes each with
268
+ * devalue. Existing entries are not clobbered, so a Fixture recorded this
269
+ * session survives a re-seed.
270
+ */
271
+ seed: (encoded: Record<string, string>) => void;
272
+ /** React subscription so the HUD re-renders when a Fixture is recorded or seeded. */
273
+ subscribe: (onChange: () => void) => () => void;
274
+ }
154
275
  /** A plate's detected width thresholds and the spans actually captured, from disk. */
155
276
  interface PlateCoverage {
156
277
  breakpoints: number[];
@@ -175,6 +296,8 @@ interface CoverageStore {
175
296
  interface CaptureHook {
176
297
  captured: (name: string, el: Element, opts?: {
177
298
  delay?: number;
299
+ walker?: unknown;
300
+ mode?: 'auto' | 'manual';
178
301
  }) => (() => void) | undefined;
179
302
  /** Light Box toggle: force every `<Skeleton>` to its skeleton, read by `<Skeleton>`. */
180
303
  lightbox?: ToggleStore;
@@ -188,6 +311,25 @@ interface CaptureHook {
188
311
  captureAllViews?: () => Promise<void>;
189
312
  /** Dev-only per-plate breakpoints + captured spans, from disk; fed by the bootstrap. */
190
313
  coverage?: CoverageStore;
314
+ /**
315
+ * Replay toggle (ADR 0015): when active, the dev adapter substitutes a recorded
316
+ * Fixture's `data` for the live `data` at `loading=false`. The boolean is the
317
+ * coarse on/off the HUD owns per tab; the per-mode policy (`prefer` vs
318
+ * `missing-only`) is carried alongside in `replayMode`.
319
+ */
320
+ replay?: ToggleStore;
321
+ /** Dev-only Replay policy: how a Fixture substitutes for live `data` (ADR 0015). */
322
+ replayMode?: 'prefer' | 'missing-only';
323
+ /**
324
+ * Dev-only Record policy (ADR 0015): when the dev adapter records the live
325
+ * `data` it is about to render. `'manual'` (the default) records nothing
326
+ * automatically — only the HUD button does; `'first-ready'` records once when a
327
+ * Skeleton goes ready and has no Fixture yet; `'always'` records on every ready
328
+ * render.
329
+ */
330
+ recordMode?: 'manual' | 'first-ready' | 'always';
331
+ /** Dev-only Fixture record/replay store (ADR 0015); never present in production. */
332
+ fixtures?: FixtureStore;
191
333
  }
192
334
  declare global {
193
335
  var __XRAY__: CaptureHook | undefined;
@@ -0,0 +1,152 @@
1
+ /** Marks the rendered skeleton root; carries the plate name. Also a dev capture marker. */
2
+ const ROOT_ATTR = "data-xr-root";
3
+ /**
4
+ * Marks an OUTERMOST skeleton root that is a stitch continuation (ADR 0020): a
5
+ * standalone `<Skeleton>` replacing a stitch a still-showing parent skeleton was
6
+ * just rendering. BASE_CSS reveals such a root instantly (no `--xr-skeleton-delay`,
7
+ * no fade) so the bones do not flash away and back. Never present on a nested
8
+ * stitch (which inherits the parent's reveal and has no `@starting-style` of its
9
+ * own) nor on an ordinary top-level skeleton (which keeps the normal delay).
10
+ */
11
+ const ROOT_INSTANT_ATTR = "data-xr-instant";
12
+ /**
13
+ * Marks a `<Content>` capture boundary in dev; carries the plate name. A parent
14
+ * capture treats it as a stitch — it references the child plate by name instead of
15
+ * re-serializing the child's subtree (ADR 0006).
16
+ */
17
+ const BOUNDARY_ATTR = "data-xr-boundary";
18
+ const SPEC_LEAF = 9e6;
19
+ const SPEC_INLINE = 1e7;
20
+ const SPEC_GATE = 2e7;
21
+ /** Fixed renderer-owned classes every rendered node carries. */
22
+ const ROOT_CLASS = "xr-root";
23
+ const NODE_CLASS = "xr-node";
24
+ const LEAF_CLASS = "xr-leaf";
25
+ /**
26
+ * Renderer-owned base styles, emitted once by the outermost view (ADR 0007),
27
+ * before a plate's node rules so a node's captured class wins by source order.
28
+ * Everything visual is driven by inheritable custom properties, so consumers
29
+ * theme by setting `--xr-*` in `:root` or on any ancestor — and per-plate via
30
+ * `[data-xr-root="name"]` (the root carries its plate name). See ADR 0011 and
31
+ * docs/architecture.md. Class-based and unlayered — `@layer` was rejected
32
+ * (unlayered app CSS would beat layered skeleton rules, and it inverts
33
+ * `!important`).
34
+ *
35
+ * Progressive enhancement (ADR 0011): the plain bone fill + opacity pulse use
36
+ * only universally-supported CSS and are the floor. `light-dark()` is guarded
37
+ * by `@supports` because an unsupported VALUE drops the whole declaration (an
38
+ * invisible bone), and it is not Baseline-widely-available until 2026-11-13 —
39
+ * drop the guard then. Features that merely no-op when absent (`@property`,
40
+ * `prefers-contrast`, relative-color-as-fallback) carry no hard guard.
41
+ *
42
+ * Tokens (ADR 0017 taxonomy). The `--xr-bone-*` family styles an individual
43
+ * Bone: `--xr-bone-color` (fill), `--xr-bone-highlight-color` (sheen),
44
+ * `--xr-bone-border-radius` (+ `--xr-bone-text-border-radius` /
45
+ * `--xr-bone-media-border-radius`), `--xr-bone-animation-duration`, the pulse
46
+ * range `--xr-bone-pulse-opacity-min`/`--xr-bone-pulse-opacity-max`,
47
+ * `--xr-bone-animation` (mode: `xr-pulse` | `none` | a custom keyframe), and
48
+ * `--xr-bone-fill` (override the leaf paint, e.g. a sheen gradient built from the
49
+ * two bone color tokens). The `--xr-skeleton-*` family governs whole-skeleton
50
+ * display timing (ADR 0016): `--xr-skeleton-delay` (grace before the skeleton
51
+ * becomes visible), `--xr-skeleton-min-duration` (minimum visible time once
52
+ * shown; read by the `useSkeletonTiming` hook, not used in CSS), and
53
+ * `--xr-skeleton-transition-duration` (the reveal/swap fade). The private
54
+ * channels `--xr-o` (pulse) and `--xr-reveal` (reveal) stay undocumented
55
+ * `@property` plumbing, not a theming surface.
56
+ */
57
+ const BASE_CSS = `
58
+ /* One timeline per skeleton, not per leaf: the root animates one inherited
59
+ opacity property and every leaf reads it. Hundreds of independent infinite
60
+ opacity animations would pin the compositor and cook the CPU. */
61
+ @property --xr-o { syntax: "<number>"; inherits: true; initial-value: 1 }
62
+ /* The reveal channel (ADR 0016): a second inherited opacity factor the leaf
63
+ multiplies into --xr-o. Transitioning THIS (not opacity directly) sidesteps
64
+ the animation-vs-transition collision ADR 0012 flagged. initial-value 1 (NOT
65
+ 0) is the degradation floor: without @starting-style the reveal can never be
66
+ driven 0->1, so it must REST at 1 (fully visible). The delay-hide below is
67
+ pure progressive enhancement layered on top. */
68
+ @property --xr-reveal { syntax: "<number>"; inherits: true; initial-value: 1 }
69
+ .${ROOT_CLASS} {
70
+ --xr-bone-highlight-color: #f4f4f5;
71
+ display: contents;
72
+ animation: var(--xr-bone-animation, xr-pulse) var(--xr-bone-animation-duration, 1.2s) ease-in-out infinite alternate;
73
+ }
74
+ /* No pointer-events:none — it blocks inspecting bones in devtools and buys
75
+ nothing (no real content to click). border-color: a node may capture
76
+ border-width for layout; its paint stays out. list-style: suppress a ::marker
77
+ a captured display:list-item would otherwise paint. */
78
+ .${NODE_CLASS} { border-color: transparent; list-style: none }
79
+ .${LEAF_CLASS} {
80
+ background: var(--xr-bone-fill, var(--xr-bone-color, #e4e4e7));
81
+ border-radius: var(--xr-bone-border-radius, 4px);
82
+ /* Composed opacity (ADR 0016): the pulse channel times the reveal channel.
83
+ Never transition opacity directly (the pulse animation owns it). */
84
+ opacity: calc(var(--xr-o) * var(--xr-reveal));
85
+ }
86
+ /* Per-kind defaults: text reads as rounded bars, media as blocks. Re-theme any
87
+ kind via [data-xr-root] .${LEAF_CLASS}-text { ... }. */
88
+ .${LEAF_CLASS}-text { border-radius: var(--xr-bone-text-border-radius, 999px) }
89
+ .${LEAF_CLASS}-media { border-radius: var(--xr-bone-media-border-radius, var(--xr-bone-border-radius, 4px)) }
90
+ @keyframes xr-pulse {
91
+ from { --xr-o: var(--xr-bone-pulse-opacity-max, 1) }
92
+ to { --xr-o: var(--xr-bone-pulse-opacity-min, 0.5) }
93
+ }
94
+ /* Reveal delay-hide (ADR 0016), a progressive enhancement guarded so it can NEVER
95
+ strand a skeleton invisible (open Q3). @starting-style is the trigger: only a
96
+ browser that supports it enters this block at all, and such a browser also
97
+ honors @property + transitions, so the 0->1 reveal completes. The @supports
98
+ selector(...) probe is the broadest cross-engine @starting-style feature test
99
+ available. Without support the block is skipped entirely and --xr-reveal stays
100
+ at its initial-value 1 = fully visible instant skeleton. INSIDE the block we
101
+ start at 0 and transition to 1 after the delay: invisible for
102
+ --xr-skeleton-delay, then a --xr-skeleton-transition-duration fade-in; a fast
103
+ load unmounts before the delay elapses and the skeleton is never seen. */
104
+ @supports (selector(:has(*))) {
105
+ /* The delay-hide applies to every root EXCEPT a stitch continuation (ADR 0020).
106
+ Scoping with :not([data-xr-instant]) keeps an ordinary top-level skeleton
107
+ byte-identical to before: it still mounts at --xr-reveal 0 and fades in after
108
+ the delay. The transition/initial-1 rest state matches the original. */
109
+ .${ROOT_CLASS}:not([${ROOT_INSTANT_ATTR}]) {
110
+ --xr-reveal: 1;
111
+ transition: --xr-reveal var(--xr-skeleton-transition-duration, 150ms) linear var(--xr-skeleton-delay, 250ms);
112
+ }
113
+ @starting-style { .${ROOT_CLASS}:not([${ROOT_INSTANT_ATTR}]) { --xr-reveal: 0 } }
114
+ /* A stitch continuation (ADR 0020) reveals INSTANTLY: it was already visible as a
115
+ stitch under a still-showing parent, so re-paying the delay would flash it away
116
+ and back. No @starting-style and no transition — it rests at fully visible from
117
+ its first frame, matching how the stitch inherited the parent's reveal. */
118
+ .${ROOT_CLASS}[${ROOT_INSTANT_ATTR}] { --xr-reveal: 1 }
119
+ }
120
+ /* Reduced motion (open Q2): keep the anti-flash delay + min-duration (they are
121
+ not motion), drop only the FADE — collapse the reveal transition to instant and
122
+ kill the pulse animation (as before). */
123
+ @media (prefers-reduced-motion: reduce) {
124
+ .${ROOT_CLASS} { animation: none; --xr-skeleton-transition-duration: 0s }
125
+ }
126
+ /* Dark + high-contrast bone defaults. Guarded: light-dark() is an invalid value
127
+ without support and would drop the declaration — see the note above; remove
128
+ this @supports once widely available (2026-11-13). */
129
+ @supports (color: light-dark(#000, #fff)) {
130
+ .${ROOT_CLASS} { --xr-bone-highlight-color: light-dark(#f4f4f5, #52525b) }
131
+ .${LEAF_CLASS} { background: var(--xr-bone-fill, var(--xr-bone-color, light-dark(#e4e4e7, #3f3f46))) }
132
+ @media (prefers-contrast: more) {
133
+ .${LEAF_CLASS} { background: var(--xr-bone-fill, var(--xr-bone-color, light-dark(#d4d4d8, #52525b))) }
134
+ }
135
+ }
136
+ /* Derive the sheen highlight from --xr-bone-color so re-theming one token updates
137
+ both. Relative color isn't widely available yet; the static highlight above is
138
+ the fallback (sheen just looks flatter), so no hard guard is needed. */
139
+ @supports (color: oklch(from red l c h)) {
140
+ .${ROOT_CLASS} { --xr-bone-highlight-color: oklch(from var(--xr-bone-color, #e4e4e7) calc(l + 0.08) c h) }
141
+ }`.trim();
142
+ /** The (unserialized) plate an import resolves to before anything has been captured. */
143
+ function emptyPlate(name) {
144
+ return {
145
+ v: 1,
146
+ name,
147
+ tree: null,
148
+ css: ""
149
+ };
150
+ }
151
+ //#endregion
152
+ export { ROOT_ATTR as a, SPEC_GATE as c, emptyPlate as d, NODE_CLASS as i, SPEC_INLINE as l, BOUNDARY_ATTR as n, ROOT_CLASS as o, LEAF_CLASS as r, ROOT_INSTANT_ATTR as s, BASE_CSS as t, SPEC_LEAF as u };
@@ -0,0 +1,79 @@
1
+ import { r as Plate } from "./plate-Bv6W-GkA.js";
2
+ import { o as XrayCaptureWalker } from "./serialize-B--oY2bV.js";
3
+ import { ReactNode } from "react";
4
+
5
+ //#region src/react.core.d.ts
6
+ /**
7
+ * The additive render-prop children form (ADR 0014). Called only at
8
+ * `loading=false` (null included — `loading` stays the sole readiness axis, ADR
9
+ * 0002), it hands back the typed `data` verbatim plus a `ref` for the manual
10
+ * capture root.
11
+ *
12
+ * `ref` is a CALLBACK ref by contract: in `root="manual"` (dev) it imperatively
13
+ * writes the `data-xr-boundary` marker onto the consumer's own element and
14
+ * registers it as the capture root, so it must run on the real DOM node. In
15
+ * `root="auto"` and in production it is a no-op (omitted), and the existing
16
+ * `display:contents` boundary wrapper does the marking instead.
17
+ *
18
+ * It is typed as a callback ref over the base `Element` rather than a `Ref<T>`.
19
+ * A callback that accepts `Element | null` is, by parameter contravariance,
20
+ * assignable to a more specific `ref` slot (`<section ref={ref}>` wants
21
+ * `(el: HTMLElement | null) => void`), so the consumer spreads it onto any host
22
+ * element without a cast. A `RefObject` would NOT compose this way and is not
23
+ * offered: writing the boundary attribute needs the imperative callback anyway.
24
+ */
25
+ type SkeletonRef = (el: Element | null) => void;
26
+ type SkeletonRenderProp<T> = (ctx: {
27
+ data: T;
28
+ ref?: SkeletonRef;
29
+ }) => ReactNode;
30
+ /** Fields shared by both `root` variants of `<Skeleton>`. */
31
+ interface SkeletonBase {
32
+ /** The plate to render while loading, imported from `virtual:xray/plates/<name>`. */
33
+ plate: Plate;
34
+ /** Explicit readiness mode: render the Skeleton while true, then render children and capture them in dev when false. */
35
+ loading?: boolean;
36
+ /** Suspense mode: make `<Skeleton>` the Suspense boundary and use the Plate as the fallback until children resolve. */
37
+ suspense?: boolean;
38
+ /** Shown while loading when the plate has no capture yet. */
39
+ fallback?: ReactNode;
40
+ /** Override the plugin's settle delay before this site is captured (dev only). */
41
+ delay?: number;
42
+ /**
43
+ * Programmable capture walker for this Skeleton's subtree (ADR 0018, dev only).
44
+ * The escape hatch beyond the declarative `data-xr-*` annotations: a closure
45
+ * that decides, per element, whether to ignore it, collapse it to one Bone, or
46
+ * defer to the built-in walk. Threaded through the dev adapter into
47
+ * `captureRegime`; PRODUCTION ignores it entirely (it never captures). On both
48
+ * `root` variants of the union, so a manual root may carry one too.
49
+ */
50
+ captureWalker?: XrayCaptureWalker;
51
+ }
52
+ /**
53
+ * Props accepted by the React `<Skeleton>` component, generic over the optional
54
+ * render-prop `data` payload `T` (ADR 0014). The generic defaults to `undefined`
55
+ * so existing call sites — `<Skeleton plate loading />`, plain children — keep
56
+ * working with zero type arguments; passing `data` infers `T` into the render
57
+ * prop. `data` is a pure pass-through (the consumer owns nullability), never
58
+ * `NonNullable<T>`.
59
+ *
60
+ * The two members discriminate on `root`:
61
+ * - `root="auto"` (default): the existing `display:contents` boundary wrapper
62
+ * marks the capture seam (ADR 0006); children may be plain `ReactNode` OR a
63
+ * render prop, and the surfaced `ref` is a no-op.
64
+ * - `root="manual"`: no wrapper; the consumer owns the capture root element. This
65
+ * REQUIRES render-prop children (the only way to hand back the `ref` that
66
+ * writes the boundary marker), so plain children + `root="manual"` is a type
67
+ * error.
68
+ */
69
+ type SkeletonProps<T = undefined> = SkeletonBase & ({
70
+ /** Auto capture root (default): xray inserts a `display:contents` boundary wrapper. */root?: 'auto'; /** Render-prop payload, passed back to the children function verbatim as `T`. */
71
+ data?: T; /** Real content. In dev, these children are also the capture source once ready. Either plain `ReactNode` or a render prop. */
72
+ children?: ReactNode | SkeletonRenderProp<T>;
73
+ } | {
74
+ /** Manual capture root: the consumer's element owns the boundary marker; no wrapper (ADR 0014). */root: 'manual'; /** Render-prop payload, passed back to the children function verbatim as `T`. */
75
+ data?: T; /** Render prop required: spread the supplied `ref` onto your root element so capture does not leak into the parent plate. */
76
+ children: SkeletonRenderProp<T>;
77
+ });
78
+ //#endregion
79
+ export { SkeletonProps as t };
@@ -0,0 +1,267 @@
1
+ import { a as ROOT_ATTR, o as ROOT_CLASS, s as ROOT_INSTANT_ATTR, t as BASE_CSS } from "./plate-DoE1HEXp.js";
2
+ import { t as escapeStyleText } from "./css-escape-N7bOusGW.js";
3
+ import { Fragment, useEffect, useRef, useState } from "react";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ //#region src/react.core.tsx
6
+ /**
7
+ * The live-stitch registry (ADR 0020): a module-level ref-counted set of stitch
8
+ * names that are CURRENTLY rendered as a stitch inside a still-SHOWING parent
9
+ * skeleton. It exists to kill the "stitch flash": when a parent skeleton resolves
10
+ * and its real content mounts a descendant's OWN standalone `<Skeleton>` for the
11
+ * same plate, that standalone is a NEW outermost skeleton — it would re-pay the
12
+ * full `--xr-skeleton-delay` (ADR 0016) from zero and visually disappear for
13
+ * ~250ms even though the child was continuously visible as a stitch. A standalone
14
+ * skeleton replacing such a live stitch must reveal INSTANTLY (no delay, no fade),
15
+ * matching how the stitch inherited its parent's already-revealed channel.
16
+ *
17
+ * Why a loading-keyed registry and not wall-clock time: the parent marks its
18
+ * direct stitch names live in an effect WHILE it shows its skeleton (an earlier
19
+ * commit). When the parent resolves, React renders the content tree — the child's
20
+ * standalone Skeleton first-renders and reads `isStitchLive(child)` BEFORE the
21
+ * parent's unmark cleanup runs (render happens before that commit's effects), so
22
+ * the read deterministically sees `child` still live. The unmark then clears it.
23
+ * "Recently" is defined by React's render-before-effect ordering, not a timer, so
24
+ * it is race-free AND does not over-apply to independent nested skeletons (a
25
+ * tab-hidden skeleton was never marked live by a currently-showing parent → not
26
+ * live → normal delay). It is portal-correct for free: only genuine stitches
27
+ * (names in a parent plate's stitch set) are ever registered, so a portaled child
28
+ * is never a stitch, never live, and reveals on the normal delay (ADR 0020).
29
+ *
30
+ * This ships to PROD: the flash is a production issue too, and the cost is a Map
31
+ * plus a register effect and a read-once `useState` per `<Skeleton>` — a few
32
+ * bytes, consistent with the prod-hooks relaxation ADR 0016 opened.
33
+ */
34
+ const liveStitches = /* @__PURE__ */ new Map();
35
+ /**
36
+ * Mark a parent skeleton's direct stitch names live (ref-counted, so concurrent
37
+ * showing parents that stitch the same child stack), returning a disposer that
38
+ * decrements each. The adapter calls this in an effect while `showSkeleton` is
39
+ * true and runs the disposer when it stops showing (resolve/unmount). Several
40
+ * marks of the same name increment the count; the count drops to 0 (entry
41
+ * removed) only when every showing parent has unmarked it (ADR 0020).
42
+ */
43
+ function markStitchesLive(names) {
44
+ for (const name of names) liveStitches.set(name, (liveStitches.get(name) ?? 0) + 1);
45
+ return () => {
46
+ queueMicrotask(() => {
47
+ for (const name of names) {
48
+ const next = (liveStitches.get(name) ?? 0) - 1;
49
+ if (next <= 0) liveStitches.delete(name);
50
+ else liveStitches.set(name, next);
51
+ }
52
+ });
53
+ };
54
+ }
55
+ /** True while `name` is rendered as a stitch inside a still-showing parent
56
+ * skeleton — i.e. a standalone `<Skeleton>` for it is a continuation, not a fresh
57
+ * skeleton, and must reveal instantly (ADR 0020). */
58
+ function isStitchLive(name) {
59
+ return (liveStitches.get(name) ?? 0) > 0;
60
+ }
61
+ /**
62
+ * The direct stitch names a plate's chunks reference (ADR 0020): a `{ r: name }`
63
+ * chunk IS a stitch; a `{ c, k }` chunk is a stitch-bearing element kept real, so
64
+ * recurse its `k`. Walks the SHIPPED `chunks` (the render contract), not
65
+ * `plate.refs` (empty in dev, where the live store resolves stitches) nor
66
+ * `collectRefs` (which walks the build-time RenderNode tree, not the shipped
67
+ * chunks). One level of direct stitch names per Skeleton is enough: each level
68
+ * self-registers as it becomes a real `<Skeleton>` (the cascade), so arbitrary
69
+ * nesting is covered without recursing into child plates here.
70
+ */
71
+ function collectStitchNames(chunks) {
72
+ if (!chunks) return [];
73
+ const names = [];
74
+ const walk = (cs) => {
75
+ for (const chunk of cs) {
76
+ if (typeof chunk === "string") continue;
77
+ if ("r" in chunk) names.push(chunk.r);
78
+ else walk(chunk.k);
79
+ }
80
+ };
81
+ walk(chunks);
82
+ return names;
83
+ }
84
+ /** Narrow unknown children to the render-prop form. */
85
+ function isRenderProp(children) {
86
+ return typeof children === "function";
87
+ }
88
+ /**
89
+ * Resolve `<Skeleton>` content at the ready (`loading=false`) path: invoke the
90
+ * render prop with the verbatim `data` and the adapter's `ref` (a no-op in prod
91
+ * and in `root="auto"`, the boundary-writing callback in dev `root="manual"`), or
92
+ * pass plain children through untouched. Centralizes the single `data as T` step:
93
+ * `data` is declared `data?: T`, so it reads back as `T | undefined`, but it is
94
+ * the exact value the consumer passed for the render prop's `T` (ADR 0014 keeps it
95
+ * a pass-through, not `NonNullable<T>`).
96
+ */
97
+ function resolveContent(children, data, ref) {
98
+ if (!isRenderProp(children)) return children;
99
+ return children({
100
+ data,
101
+ ref
102
+ });
103
+ }
104
+ /**
105
+ * The token defaults that mirror the BASE_CSS `var(--xr-skeleton-*, …)` fallbacks
106
+ * (ADR 0016). The hook reads the COMPUTED token off the boundary element, but a
107
+ * consumer who never sets it leaves the computed value empty (the tokens are not
108
+ * declared on `.xr-root`, only referenced with these inline fallbacks). So when a
109
+ * read comes back empty/unparseable the hook falls back to these same numbers,
110
+ * keeping the JS hold and the CSS reveal in lockstep on identical defaults.
111
+ */
112
+ const DEFAULT_SKELETON_DELAY_MS = 250;
113
+ const DEFAULT_SKELETON_MIN_DURATION_MS = 600;
114
+ /**
115
+ * Parse a CSS time token (`getPropertyValue` returns a trimmed string like
116
+ * `"250ms"`, `"0.6s"`, or `""`) to milliseconds, falling back to `fallback` when
117
+ * empty or unparseable. Supports `ms` and `s`; a bare number is treated as ms.
118
+ */
119
+ function parseMs(value, fallback) {
120
+ const text = value.trim();
121
+ if (text === "") return fallback;
122
+ const match = /^(-?[\d.]+)(ms|s)?$/.exec(text);
123
+ if (!match?.[1]) return fallback;
124
+ const n = Number.parseFloat(match[1]);
125
+ if (!Number.isFinite(n)) return fallback;
126
+ return match[2] === "s" ? n * 1e3 : n;
127
+ }
128
+ /**
129
+ * The min-duration hold (ADR 0016), shared by the production (`react.tsx`) and dev
130
+ * (`react.dev.tsx`) adapters so both keep identical timing. Returns whether the
131
+ * skeleton should render RIGHT NOW.
132
+ *
133
+ * The reveal DELAY is pure CSS (BASE_CSS `--xr-reveal`, zero JS); this hook is
134
+ * ONLY the part CSS cannot express: keeping the skeleton mounted past
135
+ * `loading=false` for a minimum visible window. The hook returns `loading ||
136
+ * holding`: the `loading` term is SYNCHRONOUS, so the skeleton shows on the very
137
+ * render `loading` flips true (no content frame at a false→true restart, and no
138
+ * stale-state lag), and `holding` is state used SOLELY for the post-ready hold.
139
+ *
140
+ * While loading, an effect anchors `t0` (loading start) and pre-arms `holding`
141
+ * true, so that pre-armed value carries into the first `loading=false` render with
142
+ * no content flash at the true→false transition. When `loading` flips false the
143
+ * effect reads the COMPUTED `--xr-skeleton-delay` + `--xr-skeleton-min-duration`
144
+ * off `ref` and holds until `t0 + delay + min-duration`. Two cases skip the hold
145
+ * entirely (swap synchronously, no timer): min-duration resolves to 0, or `ref` is
146
+ * null / no `t0` was recorded — meaning no skeleton root was ever shown (e.g. a
147
+ * plate with no capture, just a fallback), so there is nothing to hold and a hold
148
+ * would only keep blank/fallback UI on screen after data is ready.
149
+ *
150
+ * Reading computed custom props off the skeleton root (open Q4): the root carries
151
+ * `display:contents`, but custom-property inheritance and resolution are
152
+ * unaffected by `display` — `getComputedStyle(el).getPropertyValue('--xr-…')`
153
+ * returns the inherited token value on a `display:contents` element just as on any
154
+ * other. The tokens are not declared on `.xr-root` (only referenced with inline
155
+ * fallbacks), so a consumer who never themes them yields an empty read; `parseMs`
156
+ * then applies the same defaults the CSS uses, so the hold matches the reveal.
157
+ */
158
+ function useSkeletonTiming(loading, ref) {
159
+ const [holding, setHolding] = useState(loading);
160
+ const startRef = useRef(null);
161
+ const timerRef = useRef(void 0);
162
+ useEffect(() => {
163
+ const clear = () => {
164
+ if (timerRef.current !== void 0) {
165
+ clearTimeout(timerRef.current);
166
+ timerRef.current = void 0;
167
+ }
168
+ };
169
+ if (loading) {
170
+ clear();
171
+ if (startRef.current === null) startRef.current = Date.now();
172
+ setHolding(true);
173
+ return clear;
174
+ }
175
+ const start = startRef.current;
176
+ startRef.current = null;
177
+ const el = ref.current;
178
+ if (el === null || start === null) {
179
+ clear();
180
+ setHolding(false);
181
+ return clear;
182
+ }
183
+ const style = getComputedStyle(el);
184
+ const delay = parseMs(style.getPropertyValue("--xr-skeleton-delay"), DEFAULT_SKELETON_DELAY_MS);
185
+ const minDuration = parseMs(style.getPropertyValue("--xr-skeleton-min-duration"), DEFAULT_SKELETON_MIN_DURATION_MS);
186
+ if (minDuration <= 0) {
187
+ clear();
188
+ setHolding(false);
189
+ return clear;
190
+ }
191
+ const remaining = start + delay + minDuration - Date.now();
192
+ if (remaining <= 0) {
193
+ clear();
194
+ setHolding(false);
195
+ return clear;
196
+ }
197
+ clear();
198
+ timerRef.current = setTimeout(() => {
199
+ timerRef.current = void 0;
200
+ setHolding(false);
201
+ }, remaining);
202
+ return clear;
203
+ }, [loading, ref]);
204
+ return loading || holding;
205
+ }
206
+ /** Layout-neutral wrapper for an HTML island sitting beside React stitch nodes —
207
+ * its children participate in the grandparent's flow, so grid/flex item counts
208
+ * are unchanged. */
209
+ const DISPLAY_CONTENTS = { display: "contents" };
210
+ /**
211
+ * Render a shipped plate's skeleton. `resolveStitch` turns a child plate name
212
+ * into the node that mounts it (static `refs` in prod, a store-subscribed
213
+ * component in dev). `nested` is true for a stitch-mounted child, which skips
214
+ * BASE_CSS — the outermost view already emitted it (ADR 0007). Returns null
215
+ * when the plate has no capture yet; the caller renders the fallback.
216
+ *
217
+ * `instant` (ADR 0020) marks this root a stitch continuation: a standalone
218
+ * `<Skeleton>` replacing a stitch a still-showing parent was just rendering. It
219
+ * lands as `data-xr-instant` on the OUTERMOST root only (`instant && !nested`) so
220
+ * BASE_CSS reveals it without the `--xr-skeleton-delay` fade.
221
+ */
222
+ function renderPlate(plate, resolveStitch, nested, rootRef, instant) {
223
+ if (!plate.chunks) return null;
224
+ const className = plate.rootCls ? `${ROOT_CLASS} ${plate.rootCls}` : ROOT_CLASS;
225
+ const rootAttrs = {
226
+ [ROOT_ATTR]: plate.name,
227
+ className,
228
+ "aria-hidden": true,
229
+ "aria-busy": true,
230
+ ...rootRef ? { ref: rootRef } : {},
231
+ ...instant && !nested ? { [ROOT_INSTANT_ATTR]: "" } : {}
232
+ };
233
+ const css = nested ? plate.css : `${BASE_CSS}\n${plate.css}`;
234
+ const chunks = plate.chunks;
235
+ if (chunks.length === 1 && typeof chunks[0] === "string") {
236
+ const html = `<style>${escapeStyleText(css)}</style>${chunks[0]}`;
237
+ return /* @__PURE__ */ jsx("div", {
238
+ ...rootAttrs,
239
+ dangerouslySetInnerHTML: { __html: html }
240
+ });
241
+ }
242
+ return /* @__PURE__ */ jsxs("div", {
243
+ ...rootAttrs,
244
+ children: [/* @__PURE__ */ jsx("style", { children: css }), renderChunks(chunks, resolveStitch)]
245
+ });
246
+ }
247
+ /**
248
+ * Each `string` chunk is injected as a display:contents innerHTML island; each
249
+ * `{ r }` stitch resolves through `resolveStitch`; a `{ c, k }` node stays a
250
+ * real element and recurses (it holds a stitch deeper). No per-node tree walk —
251
+ * the strings are already serialized.
252
+ */
253
+ function renderChunks(chunks, resolveStitch) {
254
+ return chunks.map((chunk, i) => {
255
+ if (typeof chunk === "string") return /* @__PURE__ */ jsx("div", {
256
+ style: DISPLAY_CONTENTS,
257
+ dangerouslySetInnerHTML: { __html: chunk }
258
+ }, i);
259
+ if ("r" in chunk) return /* @__PURE__ */ jsx(Fragment, { children: resolveStitch(chunk.r) }, i);
260
+ return /* @__PURE__ */ jsx("div", {
261
+ className: chunk.c,
262
+ children: renderChunks(chunk.k, resolveStitch)
263
+ }, i);
264
+ });
265
+ }
266
+ //#endregion
267
+ export { renderPlate as a, markStitchesLive as i, isRenderProp as n, resolveContent as o, isStitchLive as r, useSkeletonTiming as s, collectStitchNames as t };