@hueest/xray 0.1.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.
@@ -0,0 +1,363 @@
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, off-screen
9
+ * bones dropped at the fold, 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
+ * `ViewIR.diagnostics` field only, which `captureView` 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' | 'dropped-subset' | 'dropped-cropped' | 'too-large' | 'invalid-plate-file';
48
+ //#endregion
49
+ //#region src/plate.d.ts
50
+ type LeafKind = 'text' | 'text-block' | 'media' | 'box';
51
+ /**
52
+ * The shipped structural capture of a component's rendered DOM — the data a
53
+ * Skeleton renders from (see CONTEXT.md). Self-contained: pre-serialized chunks
54
+ * plus one scoped CSS string; depends on nothing in the consumer's build (ADR
55
+ * 0003). Built from the RenderNode tree at plugin load (see chunk.ts), so the
56
+ * adapter injects strings instead of walking a tree on the latency-critical
57
+ * first paint — and no per-framework serializer ships to the client (ADR 0008).
58
+ */
59
+ interface Plate {
60
+ v: number;
61
+ /** Capture name; comes from the virtual-module specifier, never typed at a call site. */
62
+ name: string;
63
+ /**
64
+ * The root's children, pre-serialized (ADR 0008): a stitch-free run is one
65
+ * HTML `string`; a stitch is `{ r: name }`; a stitch-bearing element is
66
+ * `{ c: className, k: chunk[] }`. Null until first capture.
67
+ */
68
+ chunks: Chunk[] | null;
69
+ /** The root node's own content classes (its inherited font context), if any. */
70
+ rootCls?: string;
71
+ /** Layout-relevant CSS (incl. `@media`) scoped under the plate root, paint stripped. */
72
+ css: string;
73
+ /**
74
+ * Child plates named by the chunks' `{ r }` stitches, resolved through the
75
+ * import graph (ADR 0006). Populated by the loader's generated imports, never
76
+ * serialized into the plates JSON; absent in production when there are no
77
+ * stitches. In dev the live store takes precedence, so this need not be present.
78
+ */
79
+ refs?: Record<string, Plate>;
80
+ }
81
+ /**
82
+ * One serialized child of a plate root (or of a stitch-bearing node): a
83
+ * stitch-free HTML run (`string`), a stitch to a child plate (`{ r: name }`),
84
+ * a stitch-bearing element kept real so its inner stitches can mount
85
+ * (`{ c: className, k: chunk[] }`), or a templated uniform run (`{ t: html, n }`):
86
+ * the cell's HTML once plus a repeat count the adapter expands to N copies (Phase
87
+ * 2). The framework-neutral render contract: an adapter injects the strings,
88
+ * mounts a child plate at each stitch, and repeats each template (ADR 0008).
89
+ */
90
+ type Chunk = string | {
91
+ r: string;
92
+ } | {
93
+ c: string;
94
+ k: Chunk[];
95
+ } | {
96
+ t: string;
97
+ n: number;
98
+ };
99
+ /**
100
+ * A plate after the in-browser Serialize (emit + merge/gate + classify) but
101
+ * before chunk serialization: the build-time RenderNode tree plus its scoped
102
+ * CSS. Produced by `serializePlateIR` (the structural part of `StoredPlate`) and
103
+ * the single-View `capture`, then serialized into the shipped `Plate` (chunks) at
104
+ * load (`serializePlate`). Never shipped to the client.
105
+ */
106
+ interface MergedPlate {
107
+ v: number;
108
+ name: string;
109
+ tree: RenderNode | null;
110
+ css: string;
111
+ }
112
+ interface PlateNode {
113
+ /** Plate-local id; rules reference it pre-classify. Stripped from the shipped tree. */
114
+ id: number;
115
+ /** Present on paint leaves; absent on layout containers. */
116
+ leaf?: LeafKind;
117
+ /**
118
+ * A stitch: render the named child plate's skeleton here instead of own
119
+ * structure (ADR 0006). Mutually exclusive with `leaf`/`kids`. Resolved
120
+ * against `Plate.refs` (prod) or the live store (dev).
121
+ */
122
+ ref?: string;
123
+ /**
124
+ * A templated uniform run (Phase 2): render this node's subtree `count` times.
125
+ * The serializer emits the subtree's HTML once plus the count; the adapter
126
+ * expands it to N copies at render. Absent on ordinary nodes.
127
+ */
128
+ count?: number;
129
+ kids?: PlateNode[];
130
+ }
131
+ /**
132
+ * A node in the shipped tree, after classify (ADR 0007). Carries the
133
+ * content-addressed style classes it needs instead of an id — the CSS targets
134
+ * those classes, not a per-node `data-xr` value, so node scoping (the old
135
+ * `nodeKey`) is gone. `cls` is a space-joined class-token list.
136
+ */
137
+ interface RenderNode {
138
+ leaf?: LeafKind;
139
+ ref?: string;
140
+ cls?: string;
141
+ /** A templated uniform run (Phase 2): serialize this subtree once + a count the adapter repeats. */
142
+ count?: number;
143
+ kids?: RenderNode[];
144
+ }
145
+ /**
146
+ * A captured style rule in id form — selectors are generated at render time,
147
+ * so view merging can remap node ids without string surgery. `ids: []`
148
+ * targets the plate root.
149
+ */
150
+ interface PlateRule {
151
+ ids: number[];
152
+ decls: string[];
153
+ /** Media-condition stack, outermost first; conditions AND together. */
154
+ media?: string[];
155
+ /** `@container`-condition stack, outermost first; conditions AND together. Absent when none. */
156
+ container?: string[];
157
+ layered?: boolean;
158
+ spec: number;
159
+ order: number;
160
+ }
161
+ /**
162
+ * One capture of a component within one viewport view — `[min, max)` in px,
163
+ * either side open-ended when undefined. The id-form `{ tree, rules }` shape
164
+ * `emit` projects a measured View into (ADR 0004). NO LONGER the disk/wire shape
165
+ * after the ADR 0022 cutover — `StoredPlate` (below) is. Kept as the
166
+ * framework-neutral per-View capture shape, exported for consumers reading the
167
+ * id-form projection directly.
168
+ */
169
+ interface ViewCapture {
170
+ /** Viewport width at capture time. */
171
+ width: number;
172
+ min?: number;
173
+ max?: number;
174
+ tree: PlateNode;
175
+ rules: PlateRule[];
176
+ /**
177
+ * TRANSIENT, dev-only capture diagnostics (why this view may be lower
178
+ * fidelity). Captured in the browser (mirrored onto `ViewIR.diagnostics`); the
179
+ * dev client logs it and posts it alongside the `StoredPlate` (so the Vite
180
+ * server can print the same text). It MUST NEVER be persisted: it is not part
181
+ * of the committed
182
+ * `StoredPlate` disk shape, the validator does not carry it, and the POST
183
+ * envelope keeps it in a SIBLING field that never reaches the stored artifact
184
+ * (ADR 0008). Absent in production (the prod adapter ships no capture). See
185
+ * diagnostics.ts and ADR 0008.
186
+ */
187
+ diagnostics?: CaptureDiagnostic[];
188
+ }
189
+ /**
190
+ * The committed `plates/<name>.json` shape AFTER the ADR 0022 cutover: the whole
191
+ * Plate already projected in the browser (the in-browser Serialize folds emit +
192
+ * the multi-View merge/gate + classify in one session), so disk holds ONE gated,
193
+ * content-addressed tree + css instead of per-View captures merged at load. It is
194
+ * a `MergedPlate` (the structural `{tree, css}` `serializePlate` consumes) PLUS the
195
+ * `breakpoints` the HUD coverage reads — the analysis-only geometry never crosses
196
+ * the wire (ADR 0021). The gen-side runs `serializePlate(stored) → Plate` (chunks)
197
+ * at module-load, leaving the Tailwind/Bundle passthrough a clean gen-side seam.
198
+ * Replaces `PlateFile` (+ `ViewCapture`) on disk and on the `/__xray` wire.
199
+ */
200
+ interface StoredPlate extends MergedPlate {
201
+ /** Width thresholds (view boundaries); the HUD coverage derives its bands from these. */
202
+ breakpoints: number[];
203
+ }
204
+ /** The (unserialized) plate an import resolves to before anything has been captured. */
205
+ declare function emptyPlate(name: string): MergedPlate;
206
+ /**
207
+ * A dev-only sticky boolean toggle (get/set/subscribe), persisted per tab.
208
+ * Used for both "light box" (render every `<Skeleton>` regardless of readiness)
209
+ * and "capture" (whether browsing re-captures plates).
210
+ */
211
+ interface ToggleStore {
212
+ get: () => boolean;
213
+ set: (value: boolean) => void;
214
+ subscribe: (onChange: () => void) => () => void;
215
+ }
216
+ /**
217
+ * Dev-only store of the freshest plate per name, updated in place when a
218
+ * capture lands. `<Skeleton>` reads through it so a new plate re-renders the
219
+ * site rather than reloading the module — which would remount the capture
220
+ * boundary and re-fire the capture, a feedback loop.
221
+ */
222
+ interface PlateStore {
223
+ get: (name: string) => Plate | undefined;
224
+ subscribe: (name: string, onChange: () => void) => () => void;
225
+ /**
226
+ * Register a plate imported via the ref graph if the store has none yet — so a
227
+ * stitch resolves even before its child is re-captured, and survives a parent
228
+ * hot-swap (whose pushed plate carries no resolved `refs`). Never overwrites a
229
+ * live capture.
230
+ */
231
+ seed: (name: string, plate: Plate) => void;
232
+ }
233
+ /** A view interval `[min, max)`; an open end is undefined (ADR 0004). */
234
+ interface Span {
235
+ min?: number;
236
+ max?: number;
237
+ }
238
+ /**
239
+ * Dev-only Fixture store (ADR 0015): stored render-INPUT `data` per Plate,
240
+ * recorded to and replayed from a `plates/<name>.fixture.json` sidecar. Entirely
241
+ * dev-only — devalue and this store never reach the production bundle.
242
+ *
243
+ * Two cooperating halves, both flowing through the render-prop `data` seam (ADR
244
+ * 0014):
245
+ * - The dev adapter (`react.dev.tsx`) calls `register(name, data)` at
246
+ * `loading=false` with the live `data` it is about to render, so the latest
247
+ * recordable input for each mounted Plate is always in scope for Record.
248
+ * - `record(name)` serializes that registered live `data` with devalue and POSTs
249
+ * it to the sidecar (the HUD button and the `first-ready`/`always` modes call
250
+ * it). `has(name)` reports whether a Fixture exists (HUD coverage marker), and
251
+ * `read(name)` returns the parsed Fixture `data` for Replay to substitute in
252
+ * front of the render prop.
253
+ *
254
+ * The store holds DECODED values (the dev client owns the devalue parse, since it
255
+ * has the live runtime); only the on-disk sidecar and the wire carry the opaque
256
+ * devalue string.
257
+ */
258
+ interface FixtureStore {
259
+ /**
260
+ * Record the latest live `data` the dev adapter is about to render for `name`,
261
+ * so Record always has the current recordable input in scope. A `register` with
262
+ * the same value is cheap; the adapter calls it on every ready render. Returns a
263
+ * disposer the adapter runs on unmount so an unmounted Skeleton's `data` never
264
+ * lingers in the store: an unmounted instance must never be recorded or shadow a
265
+ * still-mounted one (ADR 0015). With several Skeletons of the same plate name
266
+ * mounted, the live value is the most-recently-registered still-mounted instance.
267
+ */
268
+ register: (name: string, data: unknown) => () => void;
269
+ /** True once a recorded Fixture exists for `name` (drives the HUD coverage marker). */
270
+ has: (name: string) => boolean;
271
+ /** The parsed Fixture `data` for `name`, or `undefined` when none is recorded. */
272
+ read: (name: string) => {
273
+ data: unknown;
274
+ } | undefined;
275
+ /**
276
+ * Serialize the registered live `data` for `name` with devalue and POST it to
277
+ * the sidecar. Returns a promise that resolves once the write is
278
+ * server-confirmed (so the HUD can surface a failed Record). Throws loudly when
279
+ * the live `data` is unrecordable (devalue's contract) or no live `data` was
280
+ * registered (a plain-children `<Skeleton>` has none).
281
+ */
282
+ record: (name: string) => Promise<void>;
283
+ /**
284
+ * Record every Skeleton that currently has live `data` registered (the HUD's
285
+ * single "Record fixture" button — one click captures the inputs of all mounted
286
+ * render-prop Skeletons). Resolves once all writes are server-confirmed; a
287
+ * rejected write surfaces to the HUD.
288
+ */
289
+ recordAll: () => Promise<void>;
290
+ /**
291
+ * Seed the store from the on-disk sidecars on boot: a `{ <name>: devalueString }`
292
+ * map (the opaque strings the server serves). The client decodes each with
293
+ * devalue. Existing entries are not clobbered, so a Fixture recorded this
294
+ * session survives a re-seed.
295
+ */
296
+ seed: (encoded: Record<string, string>) => void;
297
+ /** React subscription so the HUD re-renders when a Fixture is recorded or seeded. */
298
+ subscribe: (onChange: () => void) => () => void;
299
+ }
300
+ /** A plate's detected width thresholds and the spans actually captured, from disk. */
301
+ interface PlateCoverage {
302
+ breakpoints: number[];
303
+ spans: Span[];
304
+ }
305
+ /**
306
+ * Dev-only per-plate coverage, sourced from the committed plate files (not the
307
+ * session): the breakpoints detected so far and which view spans are captured.
308
+ * The HUD renders every band — covered ones lit, the rest dim — and the
309
+ * capture-all sweep derives its target widths from the breakpoints.
310
+ */
311
+ interface CoverageStore {
312
+ get: () => ReadonlyMap<string, PlateCoverage>;
313
+ set: (name: string, coverage: PlateCoverage) => void;
314
+ subscribe: (onChange: () => void) => () => void;
315
+ }
316
+ /**
317
+ * Dev-only capture hook, installed by the dev client before the app boots.
318
+ * Absent in production builds, so `<Skeleton>` carries zero capture weight.
319
+ * `captured` returns a dispose callback (stop observing this site).
320
+ */
321
+ interface CaptureHook {
322
+ captured: (name: string, el: Element, opts?: {
323
+ delay?: number;
324
+ walker?: unknown;
325
+ mode?: 'auto' | 'manual';
326
+ }) => (() => void) | undefined;
327
+ /** Light Box toggle: force every `<Skeleton>` to its skeleton, read by `<Skeleton>`. */
328
+ lightbox?: ToggleStore;
329
+ /** Capture toggle: whether browsing re-captures plates, flipped from the HUD. */
330
+ capture?: ToggleStore;
331
+ /** Read side for `<Skeleton>`. */
332
+ plates?: PlateStore;
333
+ /** Write side for the dev client's HMR-event handler. Returns whether the plate changed. */
334
+ updatePlate?: (name: string, plate: Plate) => boolean;
335
+ /** Dev-only: run the HUD-triggered capture-all-views sweep via the extension (ADR 0010). */
336
+ captureAllViews?: () => Promise<void>;
337
+ /** Dev-only per-plate breakpoints + captured spans, from disk; fed by the bootstrap. */
338
+ coverage?: CoverageStore;
339
+ /**
340
+ * Replay toggle (ADR 0015): when active, the dev adapter substitutes a recorded
341
+ * Fixture's `data` for the live `data` at `loading=false`. The boolean is the
342
+ * coarse on/off the HUD owns per tab; the per-mode policy (`prefer` vs
343
+ * `missing-only`) is carried alongside in `replayMode`.
344
+ */
345
+ replay?: ToggleStore;
346
+ /** Dev-only Replay policy: how a Fixture substitutes for live `data` (ADR 0015). */
347
+ replayMode?: 'prefer' | 'missing-only';
348
+ /**
349
+ * Dev-only Record policy (ADR 0015): when the dev adapter records the live
350
+ * `data` it is about to render. `'manual'` (the default) records nothing
351
+ * automatically — only the HUD button does; `'first-ready'` records once when a
352
+ * Skeleton goes ready and has no Fixture yet; `'always'` records on every ready
353
+ * render.
354
+ */
355
+ recordMode?: 'manual' | 'first-ready' | 'always';
356
+ /** Dev-only Fixture record/replay store (ADR 0015); never present in production. */
357
+ fixtures?: FixtureStore;
358
+ }
359
+ declare global {
360
+ var __XRAY__: CaptureHook | undefined;
361
+ }
362
+ //#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 };
@@ -0,0 +1,156 @@
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-text-block-border-radius` / `--xr-bone-media-border-radius`),
46
+ * `--xr-bone-animation-duration`, the pulse
47
+ * range `--xr-bone-pulse-opacity-min`/`--xr-bone-pulse-opacity-max`,
48
+ * `--xr-bone-animation` (mode: `xr-pulse` | `none` | a custom keyframe), and
49
+ * `--xr-bone-fill` (override the leaf paint, e.g. a sheen gradient built from the
50
+ * two bone color tokens). The `--xr-skeleton-*` family governs whole-skeleton
51
+ * display timing (ADR 0016): `--xr-skeleton-delay` (grace before the skeleton
52
+ * becomes visible), `--xr-skeleton-min-duration` (minimum visible time once
53
+ * shown; read by the `useSkeletonTiming` hook, not used in CSS), and
54
+ * `--xr-skeleton-transition-duration` (the reveal/swap fade). The private
55
+ * channels `--xr-o` (pulse) and `--xr-reveal` (reveal) stay undocumented
56
+ * `@property` plumbing, not a theming surface.
57
+ */
58
+ const BASE_CSS = `
59
+ /* One timeline per skeleton, not per leaf: the root animates one inherited
60
+ opacity property and every leaf reads it. Hundreds of independent infinite
61
+ opacity animations would pin the compositor and cook the CPU. */
62
+ @property --xr-o { syntax: "<number>"; inherits: true; initial-value: 1 }
63
+ /* The reveal channel (ADR 0016): a second inherited opacity factor the leaf
64
+ multiplies into --xr-o. Transitioning THIS (not opacity directly) sidesteps
65
+ the animation-vs-transition collision ADR 0012 flagged. initial-value 1 (NOT
66
+ 0) is the degradation floor: without @starting-style the reveal can never be
67
+ driven 0->1, so it must REST at 1 (fully visible). The delay-hide below is
68
+ pure progressive enhancement layered on top. */
69
+ @property --xr-reveal { syntax: "<number>"; inherits: true; initial-value: 1 }
70
+ .${ROOT_CLASS} {
71
+ --xr-bone-highlight-color: #f4f4f5;
72
+ display: contents;
73
+ animation: var(--xr-bone-animation, xr-pulse) var(--xr-bone-animation-duration, 1.2s) ease-in-out infinite alternate;
74
+ }
75
+ /* No pointer-events:none — it blocks inspecting bones in devtools and buys
76
+ nothing (no real content to click). border-color: a node may capture
77
+ border-width for layout; its paint stays out. list-style: suppress a ::marker
78
+ a captured display:list-item would otherwise paint. */
79
+ .${NODE_CLASS} { border-color: transparent; list-style: none }
80
+ .${LEAF_CLASS} {
81
+ background: var(--xr-bone-fill, var(--xr-bone-color, #e4e4e7));
82
+ border-radius: var(--xr-bone-border-radius, 4px);
83
+ /* Composed opacity (ADR 0016): the pulse channel times the reveal channel.
84
+ Never transition opacity directly (the pulse animation owns it). */
85
+ opacity: calc(var(--xr-o) * var(--xr-reveal));
86
+ }
87
+ /* Per-kind defaults: a single text LINE reads as a rounded bar; a multi-line
88
+ text BLOCK as a soft rect (a tall pill misreads as a button); media carries
89
+ the author radius, defaulting to the same soft rect. Re-theme any kind via
90
+ [data-xr-root] .${LEAF_CLASS}-text { ... }. */
91
+ .${LEAF_CLASS}-text { border-radius: var(--xr-bone-text-border-radius, 999px) }
92
+ .${LEAF_CLASS}-text-block { border-radius: var(--xr-bone-text-block-border-radius, var(--xr-bone-border-radius, 4px)) }
93
+ .${LEAF_CLASS}-media { border-radius: var(--xr-bone-media-border-radius, var(--xr-bone-border-radius, 4px)) }
94
+ @keyframes xr-pulse {
95
+ from { --xr-o: var(--xr-bone-pulse-opacity-max, 1) }
96
+ to { --xr-o: var(--xr-bone-pulse-opacity-min, 0.5) }
97
+ }
98
+ /* Reveal delay-hide (ADR 0016), a progressive enhancement guarded so it can NEVER
99
+ strand a skeleton invisible (open Q3). @starting-style is the trigger: only a
100
+ browser that supports it enters this block at all, and such a browser also
101
+ honors @property + transitions, so the 0->1 reveal completes. The @supports
102
+ selector(...) probe is the broadest cross-engine @starting-style feature test
103
+ available. Without support the block is skipped entirely and --xr-reveal stays
104
+ at its initial-value 1 = fully visible instant skeleton. INSIDE the block we
105
+ start at 0 and transition to 1 after the delay: invisible for
106
+ --xr-skeleton-delay, then a --xr-skeleton-transition-duration fade-in; a fast
107
+ load unmounts before the delay elapses and the skeleton is never seen. */
108
+ @supports (selector(:has(*))) {
109
+ /* The delay-hide applies to every root EXCEPT a stitch continuation (ADR 0020).
110
+ Scoping with :not([data-xr-instant]) keeps an ordinary top-level skeleton
111
+ byte-identical to before: it still mounts at --xr-reveal 0 and fades in after
112
+ the delay. The transition/initial-1 rest state matches the original. */
113
+ .${ROOT_CLASS}:not([${ROOT_INSTANT_ATTR}]) {
114
+ --xr-reveal: 1;
115
+ transition: --xr-reveal var(--xr-skeleton-transition-duration, 150ms) linear var(--xr-skeleton-delay, 250ms);
116
+ }
117
+ @starting-style { .${ROOT_CLASS}:not([${ROOT_INSTANT_ATTR}]) { --xr-reveal: 0 } }
118
+ /* A stitch continuation (ADR 0020) reveals INSTANTLY: it was already visible as a
119
+ stitch under a still-showing parent, so re-paying the delay would flash it away
120
+ and back. No @starting-style and no transition — it rests at fully visible from
121
+ its first frame, matching how the stitch inherited the parent's reveal. */
122
+ .${ROOT_CLASS}[${ROOT_INSTANT_ATTR}] { --xr-reveal: 1 }
123
+ }
124
+ /* Reduced motion (open Q2): keep the anti-flash delay + min-duration (they are
125
+ not motion), drop only the FADE — collapse the reveal transition to instant and
126
+ kill the pulse animation (as before). */
127
+ @media (prefers-reduced-motion: reduce) {
128
+ .${ROOT_CLASS} { animation: none; --xr-skeleton-transition-duration: 0s }
129
+ }
130
+ /* Dark + high-contrast bone defaults. Guarded: light-dark() is an invalid value
131
+ without support and would drop the declaration — see the note above; remove
132
+ this @supports once widely available (2026-11-13). */
133
+ @supports (color: light-dark(#000, #fff)) {
134
+ .${ROOT_CLASS} { --xr-bone-highlight-color: light-dark(#f4f4f5, #52525b) }
135
+ .${LEAF_CLASS} { background: var(--xr-bone-fill, var(--xr-bone-color, light-dark(#e4e4e7, #3f3f46))) }
136
+ @media (prefers-contrast: more) {
137
+ .${LEAF_CLASS} { background: var(--xr-bone-fill, var(--xr-bone-color, light-dark(#d4d4d8, #52525b))) }
138
+ }
139
+ }
140
+ /* Derive the sheen highlight from --xr-bone-color so re-theming one token updates
141
+ both. Relative color isn't widely available yet; the static highlight above is
142
+ the fallback (sheen just looks flatter), so no hard guard is needed. */
143
+ @supports (color: oklch(from red l c h)) {
144
+ .${ROOT_CLASS} { --xr-bone-highlight-color: oklch(from var(--xr-bone-color, #e4e4e7) calc(l + 0.08) c h) }
145
+ }`.trim();
146
+ /** The (unserialized) plate an import resolves to before anything has been captured. */
147
+ function emptyPlate(name) {
148
+ return {
149
+ v: 2,
150
+ name,
151
+ tree: null,
152
+ css: ""
153
+ };
154
+ }
155
+ //#endregion
156
+ 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 };