@hueest/xray 0.5.0 → 0.7.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/index.js CHANGED
@@ -4,20 +4,52 @@ import * as v from "valibot";
4
4
  /** App code imports plates from here, e.g. `import plate from 'virtual:xray/plates/my-banner'`. */
5
5
  const VIRTUAL_PREFIX = "virtual:xray/plates/";
6
6
  /**
7
- * Marks an OUTERMOST skeleton root that is a stitch continuation (ADR 0020): a
8
- * standalone `<Skeleton>` replacing a stitch a still-showing parent skeleton was
9
- * just rendering. BASE_CSS reveals such a root instantly (no `--xr-skeleton-delay`,
10
- * no fade) so the bones do not flash away and back. Never present on a nested
11
- * stitch (which inherits the parent's reveal and has no `@starting-style` of its
12
- * own) nor on an ordinary top-level skeleton (which keeps the normal delay).
7
+ * Marks an OUTERMOST skeleton root that is a stitch continuation (ADR 0020): a standalone
8
+ * `<Skeleton>` replacing a stitch a still-showing parent skeleton was just rendering. BASE_CSS
9
+ * reveals such a root instantly (no `--xr-skeleton-delay`, no fade) so the bones do not flash away
10
+ * and back. Never present on a nested stitch (which inherits the parent's reveal and has no
11
+ * `@starting-style` of its own) nor on an ordinary top-level skeleton (which keeps the normal
12
+ * delay).
13
13
  */
14
14
  const ROOT_INSTANT_ATTR = "data-xr-instant";
15
15
  /** Fixed renderer-owned classes every rendered node carries. */
16
16
  const ROOT_CLASS = "xr-root";
17
17
  const NODE_CLASS = "xr-node";
18
18
  const LEAF_CLASS = "xr-leaf";
19
- `
20
- /* One timeline per skeleton, not per leaf: the root animates one inherited
19
+ /**
20
+ * Renderer-owned base styles, emitted once by the outermost view (ADR 0007), before a plate's node
21
+ * rules so a node's captured class wins by source order. Everything visual is driven by inheritable
22
+ * custom properties, so consumers theme by setting `--xr-*` in `:root` or on any ancestor — and
23
+ * per-plate via `[data-xr-root="name"]` (the root carries its plate name). See ADR 0011 and
24
+ * docs/architecture.md. Class-based and unlayered — `@layer` was rejected (unlayered app CSS would
25
+ * beat layered skeleton rules, and it inverts `!important`).
26
+ *
27
+ * Progressive enhancement (ADR 0011): the plain bone fill + opacity pulse use only
28
+ * universally-supported CSS and are the floor. `light-dark()` is guarded by `@supports` because an
29
+ * unsupported VALUE drops the whole declaration (an invisible bone), and it is not
30
+ * Baseline-widely-available until 2026-11-13 — drop the guard then. Features that merely no-op when
31
+ * absent (`@property`, `prefers-contrast`, relative-color-as-fallback) carry no hard guard.
32
+ *
33
+ * Tokens (ADR 0017 taxonomy). The `--xr-bone-*` family styles an individual Bone: `--xr-bone-color`
34
+ * (fill), `--xr-bone-highlight-color` (sheen), `--xr-bone-border-radius` (+
35
+ * `--xr-bone-text-line-border-radius` / `--xr-bone-text-block-border-radius` /
36
+ * `--xr-bone-media-border-radius`), `--xr-bone-animation-duration`, the pulse range
37
+ * `--xr-bone-pulse-opacity-min`/`--xr-bone-pulse-opacity-max`, `--xr-bone-animation` (mode:
38
+ * `xr-pulse` | `none` | a custom keyframe), and `--xr-bone-fill` (override the leaf paint, e.g. a
39
+ * sheen gradient built from the two bone color tokens). The `--xr-skeleton-*` family governs
40
+ * whole-skeleton display timing (ADR 0016): `--xr-skeleton-delay` (grace before the skeleton
41
+ * becomes visible), `--xr-skeleton-min-duration` (minimum visible time once shown; read by the
42
+ * `useSkeletonTiming` hook, not used in CSS), and `--xr-skeleton-transition-duration` (the
43
+ * reveal/swap fade). The private channels `--xr-o` (pulse) and `--xr-reveal` (reveal) stay
44
+ * undocumented `@property` plumbing, not a theming surface.
45
+ *
46
+ * Declared as a PLAIN template literal — no `.trim()` (or any other call) on the initializer. A
47
+ * method call is not provably pure to a conservative tree-shaker (rolldown/Rollup) and would pin
48
+ * this ~5 KB string into every consumer chunk that imports the React adapter, referenced or not
49
+ * (ADR 0026). The template starts immediately after the backtick, so there is no leading newline to
50
+ * strip.
51
+ */
52
+ const BASE_CSS = `/* One timeline per skeleton, not per leaf: the root animates one inherited
21
53
  opacity property and every leaf reads it. Hundreds of independent infinite
22
54
  opacity animations would pin the compositor and cook the CPU. */
23
55
  @property --xr-o { syntax: "<number>"; inherits: true; initial-value: 1 }
@@ -48,10 +80,14 @@ const LEAF_CLASS = "xr-leaf";
48
80
  /* Per-kind defaults: a single text LINE reads as a rounded bar; a multi-line
49
81
  text BLOCK as a soft rect (a tall pill misreads as a button); media carries
50
82
  the author radius, defaulting to the same soft rect. Re-theme any kind via
51
- [data-xr-root] .${LEAF_CLASS}-text { ... }. */
52
- .${LEAF_CLASS}-text { border-radius: var(--xr-bone-text-border-radius, 999px) }
83
+ [data-xr-root] .${LEAF_CLASS}-text-line { ... }. */
84
+ .${LEAF_CLASS}-text-line { border-radius: var(--xr-bone-text-line-border-radius, 999px) }
53
85
  .${LEAF_CLASS}-text-block { border-radius: var(--xr-bone-text-block-border-radius, var(--xr-bone-border-radius, 4px)) }
54
86
  .${LEAF_CLASS}-media { border-radius: var(--xr-bone-media-border-radius, var(--xr-bone-border-radius, 4px)) }
87
+ /* A hidden bone holds its measured box in the flow but paints nothing: the
88
+ space-preserving complement to data-xr-ignore. visibility (not display) so
89
+ the captured size rules still lay the box out. */
90
+ .${LEAF_CLASS}-hidden { visibility: hidden }
55
91
  @keyframes xr-pulse {
56
92
  from { --xr-o: var(--xr-bone-pulse-opacity-max, 1) }
57
93
  to { --xr-o: var(--xr-bone-pulse-opacity-min, 0.5) }
@@ -103,11 +139,11 @@ const LEAF_CLASS = "xr-leaf";
103
139
  the fallback (sheen just looks flatter), so no hard guard is needed. */
104
140
  @supports (color: oklch(from red l c h)) {
105
141
  .${ROOT_CLASS} { --xr-bone-highlight-color: oklch(from var(--xr-bone-color, #e4e4e7) calc(l + 0.08) c h) }
106
- }`.trim();
142
+ }`;
107
143
  /** The (unserialized) plate an import resolves to before anything has been captured. */
108
144
  function emptyPlate(name) {
109
145
  return {
110
- v: 2,
146
+ v: 1,
111
147
  name,
112
148
  tree: null,
113
149
  css: ""
@@ -116,22 +152,21 @@ function emptyPlate(name) {
116
152
  //#endregion
117
153
  //#region src/chunk.ts
118
154
  /**
119
- * Serialize a merged plate's RenderNode tree into the shipped `Plate` (ADR
120
- * 0008). This is the framework-neutral half of rendering: a skeleton is static,
121
- * text-free DOM, so the structure is flattened to HTML strings here, at build,
122
- * once instead of every adapter walking a tree on the latency-critical first
123
- * paint. The adapter only injects the strings and mounts a child plate at each
124
- * stitch (see the React adapters). No escaping risk: bones carry only the
125
- * content-addressed class tokens (ADR 0007), never user content.
155
+ * Serialize a merged plate's RenderNode tree into the shipped `Plate` (ADR 0008). This is the
156
+ * framework-neutral half of rendering: a skeleton is static, text-free DOM, so the structure is
157
+ * flattened to HTML strings here, at build, once — instead of every adapter walking a tree on the
158
+ * latency-critical first paint. The adapter only injects the strings and mounts a child plate at
159
+ * each stitch (see the React adapters). No escaping risk: bones carry only the content-addressed
160
+ * class tokens (ADR 0007), never user content.
126
161
  */
127
162
  function nodeClass(node) {
128
163
  return NODE_CLASS + (node.leaf ? ` ${LEAF_CLASS} ${LEAF_CLASS}-${node.leaf}` : "") + (node.cls ? ` ${node.cls}` : "");
129
164
  }
130
165
  /**
131
- * Whether this subtree holds a render-time DYNAMIC node anywhere — a stitch
132
- * (`ref`, a mount point resolved to a child plate) or a template (`count`, a cell
133
- * the adapter repeats). Either keeps the subtree from collapsing to one flat
134
- * string, since both are expanded at render rather than baked into the markup.
166
+ * Whether this subtree holds a render-time DYNAMIC node anywhere — a stitch (`ref`, a mount point
167
+ * resolved to a child plate) or a template (`count`, a cell the adapter repeats). Either keeps the
168
+ * subtree from collapsing to one flat string, since both are expanded at render rather than baked
169
+ * into the markup.
135
170
  */
136
171
  function hasDynamic(node) {
137
172
  return node.ref !== void 0 || node.count !== void 0 || (node.kids?.some(hasDynamic) ?? false);
@@ -142,13 +177,12 @@ function toHtml(node) {
142
177
  return `<div class="${nodeClass(node)}">${kids}</div>`;
143
178
  }
144
179
  /**
145
- * Serialize a node list into chunks: each maximal run with no render-time dynamic
146
- * node becomes one HTML `string`; a `ref` node becomes `{ r }`; a `count` node
147
- * becomes `{ t: html, n }` (its cell's HTML once + the repeat count); a node that
148
- * isn't itself dynamic but holds one deeper stays a real element (`{ c, k }`) and
149
- * recurses. Mirrors the element shape the runtime renderer would otherwise build
150
- * per node. A `count` cell is guaranteed stitch-free by the capture pass, so its
151
- * `toHtml` is a plain string the adapter repeats.
180
+ * Serialize a node list into chunks: each maximal run with no render-time dynamic node becomes one
181
+ * HTML `string`; a `ref` node becomes `{ r }`; a `count` node becomes `{ t: html, n }` (its cell's
182
+ * HTML once + the repeat count); a node that isn't itself dynamic but holds one deeper stays a real
183
+ * element (`{ c, k }`) and recurses. Mirrors the element shape the runtime renderer would otherwise
184
+ * build per node. A `count` cell is guaranteed stitch-free by the capture pass, so its `toHtml` is
185
+ * a plain string the adapter repeats.
152
186
  */
153
187
  function toChunks(nodes) {
154
188
  const out = [];
@@ -179,41 +213,41 @@ function toChunks(nodes) {
179
213
  return out;
180
214
  }
181
215
  /**
182
- * Turn a merged plate (RenderNode tree) into the shipped plate (chunks). The
183
- * root's own content classes travel as `rootCls` (the adapter puts them on the
184
- * root element); its children become `chunks`. A capture-less plate has null
185
- * chunks and renders the fallback.
216
+ * Turn a merged plate (RenderNode tree) into the shipped plate (chunks). The root's own content
217
+ * classes travel as `rootCls` (the adapter puts them on the root element); its children become
218
+ * `chunks`. A capture-less plate has null chunks and renders the fallback.
219
+ *
220
+ * Two-artifact contract (ADR 0026): the returned `Plate` is the TREE artifact only; the input's
221
+ * `css` is deliberately NOT copied onto it. The css string stays the producer's
222
+ * (`GatedPlate`/`StoredPlate`) field, and each lowering site decides independently how its css
223
+ * travels: the plugin's virtual `.css` modules + `export const css`, `captureElement`'s `{ plate,
224
+ * css }` pair, or the `xray:plate` dev event, which ships no css.
186
225
  */
187
226
  function serializePlate(merged) {
188
- const { v, name, tree, css } = merged;
227
+ const { v, name, tree } = merged;
189
228
  if (!tree) return {
190
229
  v,
191
230
  name,
192
- chunks: null,
193
- css
231
+ chunks: null
194
232
  };
195
233
  const chunks = toChunks(tree.kids ?? []);
196
234
  return tree.cls ? {
197
235
  v,
198
236
  name,
199
237
  chunks,
200
- rootCls: tree.cls,
201
- css
238
+ rootCls: tree.cls
202
239
  } : {
203
240
  v,
204
241
  name,
205
- chunks,
206
- css
242
+ chunks
207
243
  };
208
244
  }
209
245
  /**
210
- * Distinct plate names referenced by the tree's stitches, in first-seen order.
211
- * Walks a `RenderNode` tree (the stored, post-classify shape), so it drives the
212
- * gen-side stitch import graph straight off a `StoredPlate.tree`
213
- * (`renderPlateModule`, index.ts). Relocated here from the now-deleted `merge.ts`
214
- * in the ADR 0022 cutover — it is the one node-side primitive that survived the
215
- * `mergeViews`/`addCapture` removal (the multi-View merge/gate now lives in the
216
- * in-browser `serializePlateIR`, project.ts).
246
+ * Distinct plate names referenced by the tree's stitches, in first-seen order. Walks a `RenderNode`
247
+ * tree (the stored, post-classify shape), so it drives the gen-side stitch import graph straight
248
+ * off a `StoredPlate.tree` (`renderPlateModule`, index.ts). The one node-side primitive needed at
249
+ * build time; multi-View gating happens in the in-browser `serializePlateIR` (serialize.ts, ADR
250
+ * 0022).
217
251
  */
218
252
  function collectRefs(tree) {
219
253
  const names = [];
@@ -231,11 +265,10 @@ function collectRefs(tree) {
231
265
  //#endregion
232
266
  //#region src/diagnostics.ts
233
267
  /**
234
- * Build a single diagnostic record with its canonical message, for callers
235
- * outside the walk that surface a diagnostic directly (e.g. the client mapping
236
- * `CaptureTooLargeError`, or the server reporting an invalid committed plate).
237
- * Uses the same message table as the collector so the text is identical
238
- * everywhere. `detail` must never carry user CSS or captured text.
268
+ * Build a single diagnostic record with its canonical message, for callers outside the walk that
269
+ * surface a diagnostic directly (e.g. the client mapping `CaptureTooLargeError`, or the server
270
+ * reporting an invalid committed plate). Uses the same message table as the collector so the text
271
+ * is identical everywhere. `detail` must never carry user CSS or captured text.
239
272
  */
240
273
  function makeDiagnostic(code, options) {
241
274
  return {
@@ -246,10 +279,9 @@ function makeDiagnostic(code, options) {
246
279
  };
247
280
  }
248
281
  /**
249
- * The human-readable message per code, shared verbatim by browser and server so
250
- * their text cannot drift. Messages describe the omission and its fidelity
251
- * cost; none echoes user CSS or captured text. `formatDiagnostic` appends the
252
- * aggregate count and any short detail.
282
+ * The human-readable message per code, shared verbatim by browser and server so their text cannot
283
+ * drift. Messages describe the omission and its fidelity cost; none echoes user CSS or captured
284
+ * text. `formatDiagnostic` appends the aggregate count and any short detail.
253
285
  */
254
286
  const DIAGNOSTIC_MESSAGES = {
255
287
  "unreadable-stylesheet": "could not read a stylesheet (likely cross-origin); rules from it were not captured",
@@ -264,11 +296,10 @@ const DIAGNOSTIC_MESSAGES = {
264
296
  "invalid-plate-file": "a committed plate file could not be read (corrupt JSON, wrong shape, or a version mismatch) and was ignored"
265
297
  };
266
298
  /**
267
- * The single shared formatter. Renders one diagnostic to a stable one-line
268
- * string used IDENTICALLY by the browser console and the Vite server output, so
269
- * the two can never disagree. Shape: `<message> (<count>)[: <detail>]`. Count is
270
- * omitted when 1 or absent; detail is appended only when present and is never
271
- * user content.
299
+ * The single shared formatter. Renders one diagnostic to a stable one-line string used IDENTICALLY
300
+ * by the browser console and the Vite server output, so the two can never disagree. Shape:
301
+ * `<message> (<count>)[: <detail>]`. Count is omitted when 1 or absent; detail is appended only
302
+ * when present and is never user content.
272
303
  */
273
304
  function formatDiagnostic(diagnostic) {
274
305
  const count = diagnostic.count !== void 0 && diagnostic.count > 1 ? ` (${diagnostic.count})` : "";
@@ -282,28 +313,26 @@ function diagnosticsHeader(name) {
282
313
  //#endregion
283
314
  //#region src/name.ts
284
315
  /**
285
- * Plate-name validation, shared by the Vite plugin (`index.ts`) and the
286
- * Node-only POST validator (`validate.ts`).
316
+ * Plate-name validation, shared by the Vite plugin (`index.ts`) and the Node-only POST validator
317
+ * (`validate.ts`).
287
318
  *
288
- * Lives in its own dependency-free module so `validate.ts` can reuse it without
289
- * importing the plugin entry (which would be a cycle), and so the regex stays
290
- * the single source of truth for what a plate name may be — the same rule the
291
- * loader, the file watcher, the coverage reader, the POST endpoint, and the
292
- * ref-graph emitter all gate on.
319
+ * Lives in its own dependency-free module so `validate.ts` can reuse it without importing the
320
+ * plugin entry (which would be a cycle), and so the regex stays the single source of truth for what
321
+ * a plate name may be — the same rule the loader, the file watcher, the coverage reader, the POST
322
+ * endpoint, and the ref-graph emitter all gate on.
293
323
  *
294
324
  * @module
295
325
  */
296
326
  /**
297
327
  * Names come from import specifiers and POST bodies — keep them inside the plates dir.
298
328
  *
299
- * Each path segment is `\w+(-\w+)*` — word chars with single interior hyphens,
300
- * never doubled and never at a segment edge — and segments are joined by `/`.
301
- * The class-prefix encoder (`classify.ts`) renders `/` as `--` to keep nested
302
- * names readable, and that prefix is the isolation boundary across stitched
303
- * plates (ADR 0007), so the encoding must be injective. Banning a literal `--`
304
- * and any edge hyphen guarantees no `-` ever abuts a `/`, so every `--` in the
305
- * prefix came from exactly one `/` making distinct names always map to
306
- * distinct prefixes (else e.g. `a/-b` and `a-/b` would both encode to `a---b`).
329
+ * Each path segment is `\w+(-\w+)*` — word chars with single interior hyphens, never doubled and
330
+ * never at a segment edge — and segments are joined by `/`. The class-prefix encoder
331
+ * (`classify.ts`) renders `/` as `--` to keep nested names readable, and that prefix is the
332
+ * isolation boundary across stitched plates (ADR 0007), so the encoding must be injective. Banning
333
+ * a literal `--` and any edge hyphen guarantees no `-` ever abuts a `/`, so every `--` in the
334
+ * prefix came from exactly one `/` making distinct names always map to distinct prefixes (else
335
+ * e.g. `a/-b` and `a-/b` would both encode to `a---b`).
307
336
  *
308
337
  * @internal
309
338
  */
@@ -313,71 +342,66 @@ function isValidPlateName(name) {
313
342
  //#endregion
314
343
  //#region src/validate.ts
315
344
  /**
316
- * Structural validation of a posted/committed `StoredPlate`, plus the
317
- * single-CSS-declaration / media-condition validators, for the dev `/__xray`
318
- * endpoint and disk reads.
345
+ * Structural validation of a posted/committed `StoredPlate`, plus the single-CSS-declaration /
346
+ * media-condition validators, for the dev `/__xray` endpoint and disk reads.
319
347
  *
320
- * NODE-ONLY (see the capture-input-validation plan and ADR 0008). This module
321
- * imports `valibot`, which must NEVER reach the browser bundles: the dev capture
322
- * client, the HUD, the React adapters, and the framework-neutral `core` surface
323
- * all ship to a browser, and none of them needs to validate an inbound payload.
324
- * Only the Vite plugin (`index.ts`, the package `.` entry, built with
325
- * `platform: 'node'`) imports this file, which keeps valibot out of every
326
- * browser-facing dist chunk. Do NOT import this from `plate.ts`, `css.ts`, or
327
- * anything a render/browser path also imports, and do NOT re-export these
328
- * helpers from any public subpath — they stay private until a `doctor`-style
329
- * tool creates a concrete external need (the plan's decisions).
348
+ * NODE-ONLY (ADR 0023, ADR 0008). This module imports `valibot`, which must NEVER reach the browser
349
+ * bundles: the dev capture client, the HUD, the React adapters, and the framework-neutral `core`
350
+ * surface all ship to a browser, and none of them needs to validate an inbound payload. Only the
351
+ * Vite plugin (`index.ts`, the package `.` entry, built with `platform: 'node'`) imports this file,
352
+ * which keeps valibot out of every browser-facing dist chunk. Do NOT import this from `plate.ts`,
353
+ * `css.ts`, or anything a render/browser path also imports, and do NOT re-export these helpers from
354
+ * any public subpath they stay private until a `doctor`-style tool creates a concrete external
355
+ * need (ADR 0023).
330
356
  *
331
- * The endpoint accepts ONLY the artifact shape xray itself produces. After the
332
- * ADR 0022 cutover the dev client posts a finished `StoredPlate` the whole
333
- * Plate already projected in the browser (`serializePlateIR`) so `parseStoredPlate`
334
- * is the single validator: a post-classify `RenderNode` tree plus an
335
- * already-classified css blob, guarded for `<style>`-breakout only (the per-decl
336
- * allowlist no longer applies to a finished css string; see `isValidPlateCss`).
337
- * The per-declaration / per-condition validators (`isValidDeclaration` /
338
- * `isValidCondition`) remain exported helpers the css blob already passed them
339
- * AT CAPTURE TIME in the browser. Validation is necessary but not sufficient: a
340
- * committed `StoredPlate` is also untrusted (hand-edited or from git), so the
341
- * render path independently escapes raw-text `<style>` terminators (css-escape.ts).
357
+ * The endpoint accepts ONLY the artifact shape xray itself produces. The dev client posts a
358
+ * finished `StoredPlate` the whole Plate already projected in the browser (`serializePlateIR`,
359
+ * ADR 0022) so `parseStoredPlate` is the single validator: a post-classify `RenderNode` tree plus
360
+ * an already-classified css blob, guarded for `<style>`-breakout only (a per-decl allowlist cannot
361
+ * apply to a finished css string; see `isValidPlateCss`). The per-declaration / per-condition
362
+ * validators (`isValidDeclaration` / `isValidCondition`) remain exported helpers the css blob
363
+ * already passed them AT CAPTURE TIME in the browser. Validation is necessary but not sufficient: a
364
+ * committed `StoredPlate` is also untrusted (hand-edited or from git), so the render path
365
+ * independently escapes raw-text `<style>` terminators (css-escape.ts).
342
366
  *
343
367
  * @module
344
368
  */
345
369
  /**
346
- * Bounds chosen to be far above any real capture yet small enough that a hostile
347
- * payload cannot exhaust memory or wedge the merge. A `<Skeleton>` set too high
348
- * is already caught by the per-capture node guard and the serialized-size cap;
349
- * these are the structural ceilings the schema enforces before either runs.
370
+ * Bounds chosen to be far above any real capture yet small enough that a hostile payload cannot
371
+ * exhaust memory or wedge the merge. A `<Skeleton>` set too high is already caught by the
372
+ * per-capture node guard and the serialized-size cap; these are the structural ceilings the schema
373
+ * enforces before either runs.
350
374
  */
351
375
  const MAX_TREE_NODES = 5e4;
352
376
  const MAX_BREAKPOINTS = 256;
353
377
  const MAX_TREE_DEPTH = 1e3;
354
378
  /**
355
- * A template node's repeat count (Phase 2). Far above any real feed, yet bounded
356
- * so a hostile `count` cannot drive an unbounded render-time string `.repeat`.
379
+ * A template node's repeat count (Template, ADR 0022). Far above any real feed, yet bounded so a
380
+ * hostile `count` cannot drive an unbounded render-time string `.repeat`.
357
381
  */
358
382
  const MAX_TEMPLATE_COUNT = 1e4;
359
383
  /** A single declaration is `prop: value [!important]`; real ones are short. */
360
384
  const MAX_DECL_LENGTH = 2e3;
361
385
  /**
362
- * Raw-text / markup terminators that must never appear in generated CSS, even
363
- * when no bare `<`/`>` is present: HTML comment delimiters, a CDATA opener, and
364
- * a `</style` raw-text terminator. Shared by the declaration and condition
365
- * validators (case-insensitive).
386
+ * Raw-text / markup terminators that must never appear in generated CSS, even when no bare `<`/`>`
387
+ * is present: HTML comment delimiters, a CDATA opener, and a `</style` raw-text terminator. Shared
388
+ * by the declaration and condition validators (case-insensitive).
366
389
  */
367
390
  const RAW_TEXT_TERMINATORS = /<!--|-->|<!\[cdata\[|<\/style/iu;
368
391
  /** A finite number, no NaN/Infinity (valibot's `number()` already rejects NaN). */
369
392
  const finiteNumber = v.pipe(v.number(), v.finite());
370
393
  const leafSchema = v.picklist([
371
- "text",
394
+ "text-line",
372
395
  "text-block",
373
396
  "media",
374
- "box"
397
+ "box",
398
+ "hidden"
375
399
  ]);
376
400
  /**
377
- * Depth + total-node guard shared by the recursive node schema. The recursive
378
- * `array(lazy(...))` bounds breadth per level; this guards total depth so a long
379
- * thin chain cannot recurse without limit, and the running `count` bounds the
380
- * total node budget so a fanned-out payload cannot exhaust memory.
401
+ * Depth + total-node guard shared by the recursive node schema. The recursive `array(lazy(...))`
402
+ * bounds breadth per level; this guards total depth so a long thin chain cannot recurse without
403
+ * limit, and the running `count` bounds the total node budget so a fanned-out payload cannot
404
+ * exhaust memory.
381
405
  */
382
406
  function treeWithinLimits(root) {
383
407
  let count = 0;
@@ -395,27 +419,24 @@ function treeWithinLimits(root) {
395
419
  /** A finished plate's css blob, generously bounded (post-classify, whole-plate). */
396
420
  const MAX_PLATE_CSS_LENGTH = 4e6;
397
421
  /**
398
- * A NUL byte — the one control character that must never reach the rendered
399
- * `<style>`. Unlike the per-declaration validator, the whole-plate css blob
400
- * legitimately carries newlines/tabs (`classify` joins rules with `\n`), so the
401
- * broad `CONTROL_CHARS` class does NOT apply here.
422
+ * A NUL byte — the one control character that must never reach the rendered `<style>`. Unlike the
423
+ * per-declaration validator, the whole-plate css blob legitimately carries newlines/tabs
424
+ * (`classify` joins rules with `\n`), so the broad `CONTROL_CHARS` class does NOT apply here.
402
425
  */
403
426
  const NUL = /\u0000/u;
404
427
  /**
405
- * Validate a STORED, already-classified css blob (ADR 0022 cutover). The trust
406
- * model differs from a pre-classify `decls[]` array: a `StoredPlate.css` is the
407
- * WHOLE plate's content-addressed CSS — a string of selectors, `{ ... }` rule
408
- * blocks, and `@media`/`@container` at-rules so the per-declaration
409
- * `isValidDeclaration` allowlist (which forbids `{}` and re-checks `isLayoutProp`)
410
- * cannot apply: it would reject every real plate. Braces, newlines, and bare
411
- * `<`/`>` (Media Queries Level 4 range syntax, `@media (width >= 600px)`) are all
412
- * legitimate here. The css was produced by `filterLayoutDecls`-filtered decls at
413
- * capture time in the browser; on disk we guard only the security boundary the
414
- * render path also enforces: no `<style>` raw-text breakout. Reject the raw-text /
415
- * markup terminators (`</style`, `<!--`, `-->`, `<![CDATA[`) and a NUL byte, and
416
- * bound the length. The render path independently escapes these (`escapeStyleText`,
417
- * css-escape.ts) — that stays as the belt; this is the braces. A `StoredPlate` is
418
- * untrusted exactly as a `PlateFile` was (hand-edited / arriving via git).
428
+ * Validate a STORED, already-classified css blob (ADR 0022). The trust model differs from a
429
+ * pre-classify `decls[]` array: a `StoredPlate.css` is the WHOLE plate's content-addressed CSS — a
430
+ * string of selectors, `{ ... }` rule blocks, and `@media`/`@container` at-rules — so the
431
+ * per-declaration `isValidDeclaration` allowlist (which forbids `{}` and re-checks `isLayoutProp`)
432
+ * cannot apply: it would reject every real plate. Braces, newlines, and bare `<`/`>` (Media Queries
433
+ * Level 4 range syntax, `@media (width >= 600px)`) are all legitimate here. The css was produced by
434
+ * `filterLayoutDecls`-filtered decls at capture time in the browser; the disk guard covers only the
435
+ * security boundary the render path also enforces: no `<style>` raw-text breakout. Reject the
436
+ * raw-text / markup terminators (`</style`, `<!--`, `-->`, `<![CDATA[`) and a NUL byte, and bound
437
+ * the length. The render path independently escapes these (`escapeStyleText`, css-escape.ts) that
438
+ * stays as the belt; this is the braces. A `StoredPlate` is untrusted (hand-edited / arriving via
439
+ * git).
419
440
  */
420
441
  function isValidPlateCss(css) {
421
442
  if (typeof css !== "string") return false;
@@ -425,10 +446,10 @@ function isValidPlateCss(css) {
425
446
  return true;
426
447
  }
427
448
  /**
428
- * Recursive `RenderNode` — the POST-classify tree shape (plate.ts): no `id` (ids
429
- * are stripped at classify), an optional content-class string `cls`, and the same
430
- * `leaf`/`ref`/`count`/`kids` the renderer reads. Mirrors `plateNodeSchema` minus
431
- * `id`, plus a bounded `cls`. Depth/breadth bounded by the shared `treeWithinLimits`.
449
+ * Recursive `RenderNode` — the POST-classify tree shape (plate.ts): no `id` (ids are stripped at
450
+ * classify), an optional content-class string `cls`, and the same `leaf`/`ref`/`count`/`kids` the
451
+ * renderer reads. Mirrors `plateNodeSchema` minus `id`, plus a bounded `cls`. Depth/breadth bounded
452
+ * by the shared `treeWithinLimits`.
432
453
  */
433
454
  const renderNodeSchema = v.pipe(v.object({
434
455
  leaf: v.optional(leafSchema),
@@ -438,30 +459,27 @@ const renderNodeSchema = v.pipe(v.object({
438
459
  kids: v.optional(v.pipe(v.array(v.lazy(() => renderNodeSchema)), v.maxLength(MAX_TREE_NODES)))
439
460
  }), v.check((node) => treeWithinLimits(node), "tree exceeds size or depth limits"));
440
461
  /**
441
- * The committed/posted `StoredPlate` shape AFTER the ADR 0022 cutover (plate.ts):
442
- * the whole plate already projected in the browser. Validated to the SAME trust
443
- * boundary a `PlateFile` was a `StoredPlate` on disk is untrusted (hand-edited
444
- * or arriving via git). `tree` is a `RenderNode` (post-classify, nullable for a
445
- * capture-less plate); `css` is the already-classified blob, guarded for
446
- * `<style>`-breakout only (`isValidPlateCss`, above). `breakpoints` are finite
447
- * non-negative numbers, bounded but NOT renormalized (the file is the source of
448
- * truth). `v` must be the current `PLATE_VERSION`.
462
+ * The committed/posted `StoredPlate` shape (plate.ts): the whole plate already projected in the
463
+ * browser. Validated as untrusted input a `StoredPlate` on disk is untrusted (hand-edited or
464
+ * arriving via git). `tree` is a `RenderNode` (post-classify, nullable for a capture-less plate);
465
+ * `css` is the already-classified blob, guarded for `<style>`-breakout only (`isValidPlateCss`,
466
+ * above). `breakpoints` are finite non-negative numbers, bounded but NOT renormalized (the file is
467
+ * the source of truth). `v` must be the current `PLATE_VERSION`.
449
468
  */
450
469
  const storedPlateSchema = v.object({
451
- v: v.literal(2),
470
+ v: v.literal(1),
452
471
  name: v.pipe(v.string(), v.check(isValidPlateName, "invalid plate name")),
453
472
  breakpoints: v.pipe(v.array(v.pipe(finiteNumber, v.minValue(0))), v.maxLength(MAX_BREAKPOINTS)),
454
473
  tree: v.nullable(renderNodeSchema),
455
474
  css: v.pipe(v.string(), v.check(isValidPlateCss, "invalid plate css"))
456
475
  });
457
476
  /**
458
- * Parse a committed/posted `StoredPlate` against its schema (the ADR 0022 cutover
459
- * shape). Returns the typed `StoredPlate` on success or `null` on any structural
460
- * failure (corrupt object, wrong shape, version mismatch, an injected `<style>`
461
- * terminator in the css, an oversized/over-deep tree). The disk readers and the
462
- * POST handler map `null` to an empty plate / a generic 400 and never echo the
463
- * payload a malformed or hostile stored plate degrades to empty rather than
464
- * breaking the build or smuggling a `<style>`-escaping css blob through render.
477
+ * Parse a committed/posted `StoredPlate` against its schema (ADR 0022). Returns the typed
478
+ * `StoredPlate` on success or `null` on any structural failure (corrupt object, wrong shape,
479
+ * version mismatch, an injected `<style>` terminator in the css, an oversized/over-deep tree). The
480
+ * disk readers and the POST handler map `null` to an empty plate / a generic 400 and never echo the
481
+ * payload a malformed or hostile stored plate degrades to empty rather than breaking the build or
482
+ * smuggling a `<style>`-escaping css blob through render.
465
483
  */
466
484
  function parseStoredPlate(value) {
467
485
  const result = v.safeParse(storedPlateSchema, value);
@@ -472,9 +490,9 @@ function parseStoredPlate(value) {
472
490
  /**
473
491
  * Vite plugin entry point for xray.
474
492
  *
475
- * Add `xrayVitePlugin()` to a Vite config to resolve committed Plate imports in
476
- * dev and build. The dev capture client is injected only during `vite serve`,
477
- * and only when `capture` or the HUD needs it.
493
+ * Add `xrayVitePlugin()` to a Vite config to resolve committed Plate imports in dev and build. The
494
+ * dev capture client is injected only during `vite serve`, and only when `capture` or the HUD needs
495
+ * it.
478
496
  *
479
497
  * @module @hueest/xray
480
498
  */
@@ -483,8 +501,20 @@ const BOOT_ID = "virtual:xray/client";
483
501
  const RESOLVED_BOOT_ID = `\0${BOOT_ID}`;
484
502
  const ENDPOINT = "/__xray";
485
503
  /**
486
- * Create the Vite plugins that resolve committed Plates and, during dev,
487
- * capture new Plates from rendered `<Skeleton>` boundaries.
504
+ * The shared base-css virtual module every plate's `.css` module imports, so Vite dedupes BASE_CSS
505
+ * across plates instead of repeating it per plate. Deliberately NOT `\0`-prefixed: an unprefixed
506
+ * virtual id is tried first (the vanilla-extract pattern) because Vite's CSS plugins match on the
507
+ * module id, and this id must be routed through that pipeline (it ends in `.css`).
508
+ */
509
+ const BASE_CSS_ID = "virtual:xray/base.css";
510
+ /**
511
+ * A plate's per-name css virtual module id. Also un-`\0`-prefixed for the same reason as
512
+ * `BASE_CSS_ID` — see above.
513
+ */
514
+ const plateCssId = (name) => `${VIRTUAL_PREFIX}${name}.css`;
515
+ /**
516
+ * Create the Vite plugins that resolve committed Plates and, during dev, capture new Plates from
517
+ * rendered `<Skeleton>` boundaries.
488
518
  *
489
519
  * Typical setup:
490
520
  *
@@ -497,8 +527,10 @@ const ENDPOINT = "/__xray";
497
527
  * ```
498
528
  *
499
529
  * Returns two plugins:
530
+ *
500
531
  * - `xray:data` resolves `virtual:xray/plates/<name>` from committed Plate files in dev and build.
501
- * - `xray:dev` runs only during `vite serve`; it injects the capture client, receives Captures, writes Plate files, and hot-updates the page.
532
+ * - `xray:dev` runs only during `vite serve`; it injects the capture client, receives Captures,
533
+ * writes Plate files, and hot-updates the page.
502
534
  */
503
535
  function xrayVitePlugin(options = {}) {
504
536
  const platesDir = options.platesDir ?? "plates";
@@ -518,9 +550,17 @@ function xrayVitePlugin(options = {}) {
518
550
  root = config.root;
519
551
  },
520
552
  resolveId(id) {
553
+ if (id === BASE_CSS_ID) return id;
554
+ if (id.startsWith("virtual:xray/plates/") && id.endsWith(".css")) return id;
521
555
  if (id.startsWith("virtual:xray/plates/")) return `\0${id}`;
522
556
  },
523
557
  load(id) {
558
+ if (id === BASE_CSS_ID) return BASE_CSS;
559
+ if (id.startsWith("virtual:xray/plates/") && id.endsWith(".css")) {
560
+ const name = id.slice(20, -4);
561
+ if (!isValidPlateName(name)) return "";
562
+ return readStoredPlate(platePath(name))?.css ?? "";
563
+ }
524
564
  if (!id.startsWith(RESOLVED_PREFIX)) return void 0;
525
565
  const name = id.slice(RESOLVED_PREFIX.length);
526
566
  if (!isValidPlateName(name)) return renderPlateModule(emptyPlate(name));
@@ -564,6 +604,7 @@ function xrayVitePlugin(options = {}) {
564
604
  const name = relative(dir(), file).slice(0, -5);
565
605
  if (!isValidPlateName(name)) return;
566
606
  announcePlate(server.ws, name, readStoredPlate(file));
607
+ invalidatePlateModules(server, name);
567
608
  };
568
609
  server.watcher.on("add", onPlateFile);
569
610
  server.watcher.on("change", onPlateFile);
@@ -578,6 +619,12 @@ function xrayVitePlugin(options = {}) {
578
619
  res.end(JSON.stringify(readFixtures(dir())));
579
620
  return;
580
621
  }
622
+ if (req.method === "POST" && !isSameOriginWrite(req)) {
623
+ res.statusCode = 403;
624
+ res.end("forbidden");
625
+ console.warn("[xray] rejected a cross-origin POST to /__xray (origin/host mismatch).");
626
+ return;
627
+ }
581
628
  if (req.url === "/fixture") {
582
629
  handleFixturePost(req, res, fixturePath);
583
630
  return;
@@ -588,8 +635,8 @@ function xrayVitePlugin(options = {}) {
588
635
  }];
589
636
  }
590
637
  /**
591
- * The injected dev module: boot the client, POST captures back, optionally
592
- * mount the HUD. The client installs first so the HUD finds the light box store.
638
+ * The injected dev module: boot the client, POST captures back, optionally mount the HUD. The
639
+ * client installs first so the HUD finds the light box store.
593
640
  */
594
641
  function bootstrapCode(delay, settleCap, collect, hud, capture, recordMode, replayMode) {
595
642
  return [
@@ -649,10 +696,45 @@ function bootstrapCode(delay, settleCap, collect, hud, capture, recordMode, repl
649
696
  ].join("\n");
650
697
  }
651
698
  /**
652
- * Print a plate's diagnostics to the Vite server output using the SAME grouped
653
- * header + shared formatter the browser console uses (diagnostics.ts), so the
654
- * two can never drift. Prefers Vite's logger (it preserves the formatting);
655
- * falls back to `console.warn`. No-op when there are no diagnostics.
699
+ * Drop both of a plate's virtual modules from Vite's dev transform cache after its file changes,
700
+ * each through the channel appropriate to it:
701
+ *
702
+ * - The `.css` module (UN-prefixed id see `plateCssId`) is invalidated AND reloaded: standard Vite
703
+ * CSS HMR (`reloadModule` re-transforms and pushes the update over the ws), so fresh styles land
704
+ * in the open page with no reload. The css module has no capture boundary, so a live reload of it
705
+ * carries no remount-loop risk.
706
+ * - The JS (tree) module (`\0`-prefixed id) is invalidated ONLY — deliberately NO `reloadModule`/HMR
707
+ * push. A live re-import would remount the capture boundary and re-fire the capture (the exact
708
+ * infinite loop the "no addWatchFile" comment in `load` guards against; live tree updates keep
709
+ * flowing over the `xray:plate` announce into the dev store instead). Bare `invalidateModule` is
710
+ * server-side cache marking only — nothing reaches the client until the NEXT full page load,
711
+ * which then re-transforms from disk and serves a tree consistent with the css.
712
+ *
713
+ * WITHOUT the JS half, a page reload after a capture pairs Vite's CACHED stale tree transform
714
+ * (stale content-addressed class names) with the freshly reloaded css — class names no longer match
715
+ * rules and skeletons render garbled until a dev-server restart clears the transform cache. The two
716
+ * modules need distinct invalidation policies; this helper is the single place that keeps them in
717
+ * lockstep.
718
+ *
719
+ * Scope note (stitched plates): a parent's module embeds ONLY its own stored tree; a child's data
720
+ * arrives at runtime through the child module import (`refs: { "<child>": __xr0 }`), never inlined
721
+ * into the parent's transform. So invalidating the re-captured plate's OWN two modules is
722
+ * sufficient — parent modules hold nothing child-derived that could go stale.
723
+ *
724
+ * No-op per module when it was never resolved/imported yet (a fresh plate no page has loaded, or a
725
+ * server stub without the module-graph subset).
726
+ */
727
+ async function invalidatePlateModules(server, name) {
728
+ const jsMod = server.moduleGraph?.getModuleById(RESOLVED_PREFIX + name);
729
+ if (jsMod) server.moduleGraph?.invalidateModule(jsMod);
730
+ const cssMod = server.moduleGraph?.getModuleById(plateCssId(name));
731
+ if (cssMod) await server.reloadModule?.(cssMod);
732
+ }
733
+ /**
734
+ * Print a plate's diagnostics to the Vite server output using the SAME grouped header + shared
735
+ * formatter the browser console uses (diagnostics.ts), so the two can never drift. Prefers Vite's
736
+ * logger (it preserves the formatting); falls back to `console.warn`. No-op when there are no
737
+ * diagnostics.
656
738
  */
657
739
  function reportServerDiagnostics(server, name, diagnostics) {
658
740
  if (!diagnostics || diagnostics.length === 0) return;
@@ -664,14 +746,12 @@ function reportServerDiagnostics(server, name, diagnostics) {
664
746
  /** A committed plate file past this is almost always a `<Skeleton>` set too high. */
665
747
  const MAX_PLATE_BYTES = 2e6;
666
748
  /**
667
- * Hard ceiling on the POST body we will buffer. The posted StoredPlate is the
668
- * whole projected Plate the `MAX_PLATE_BYTES` cap guards, so this sits above the
669
- * plate cap with headroom (the JSON envelope + diagnostics add a little) and
670
- * exists only to stop an oversized or runaway body from being read into memory.
671
- * Enforced both
672
- * up front from `content-length` and while streaming, so a lying or absent
673
- * `content-length` cannot get past it. The dev endpoint is local and
674
- * unauthenticated, so this is the real backstop, not the header.
749
+ * Hard ceiling on the POST body the endpoint will buffer. The posted StoredPlate is the whole
750
+ * projected Plate the `MAX_PLATE_BYTES` cap guards, so this sits above the plate cap with headroom
751
+ * (the JSON envelope + diagnostics add a little) and exists only to stop an oversized or runaway
752
+ * body from being read into memory. Enforced both up front from `content-length` and while
753
+ * streaming, so a lying or absent `content-length` cannot get past it. The dev endpoint is local
754
+ * and unauthenticated, so this is the real backstop, not the header.
675
755
  */
676
756
  const MAX_BODY_BYTES = 4e6;
677
757
  /** The only content type the capture client posts; anything else is rejected up front. */
@@ -680,18 +760,33 @@ function isJsonContentType(value) {
680
760
  if (typeof header !== "string") return false;
681
761
  return /^application\/json\b/i.test(header.trim());
682
762
  }
763
+ /**
764
+ * Reject cross-origin browser writes. Browsers always attach `Origin` to POSTs; a request from the
765
+ * app's own page carries the dev server's origin, so its host must equal the `Host` the request
766
+ * arrived on. A request with NO Origin header is not from a browser page (curl, a node script) and
767
+ * is allowed — the gate targets drive-by browser writes to Plate and Fixture files, not local
768
+ * tooling.
769
+ */
770
+ function isSameOriginWrite(req) {
771
+ const origin = Array.isArray(req.headers?.origin) ? req.headers.origin[0] : req.headers?.origin;
772
+ if (origin === void 0 || origin === "null") return origin === void 0;
773
+ const host = Array.isArray(req.headers?.host) ? req.headers.host[0] : req.headers?.host;
774
+ try {
775
+ return new URL(origin).host === host;
776
+ } catch {
777
+ return false;
778
+ }
779
+ }
683
780
  function isRecord(value) {
684
781
  return typeof value === "object" && value !== null;
685
782
  }
686
783
  /**
687
- * Derive the HUD coverage bands from a StoredPlate's `breakpoints` (ADR 0022
688
- * cutover). Per-View spans no longer exist on disk a post-cutover StoredPlate
689
- * sweeps ALL widths in one session, so coverage is complete per-Plate and the
690
- * bands are just the breakpoint partition: each adjacent breakpoint pair is a
691
- * covered band, the first band open-min, the last open-max. `breakpoints=[b1,b2]`
692
- * `[{max:b1},{min:b1,max:b2},{min:b2}]`; `breakpoints=[]` one open band `[{}]`.
693
- * Keeps the `xray:plate`/coverage `spans` shape (`{min?, max?}[]`) identical — only
694
- * the SOURCE changes (derived from breakpoints, not read off per-View captures).
784
+ * Derive the HUD coverage bands from a StoredPlate's `breakpoints` (ADR 0022). Per-View spans do
785
+ * not exist on disk: a StoredPlate sweeps ALL widths in one session, so coverage is complete
786
+ * per-Plate and the bands are the breakpoint partition each adjacent breakpoint pair is a covered
787
+ * band, the first band open-min, the last open-max. `breakpoints=[b1,b2]`
788
+ * `[{max:b1},{min:b1,max:b2},{min:b2}]`; `breakpoints=[]` one open band `[{}]`. The
789
+ * `xray:plate`/coverage `spans` shape (`{min?, max?}[]`) is unchanged; only the SOURCE is derived.
695
790
  */
696
791
  function spansFromBreakpoints(breakpoints) {
697
792
  const sorted = [...breakpoints].toSorted((a, b) => a - b);
@@ -704,34 +799,41 @@ function spansFromBreakpoints(breakpoints) {
704
799
  spans.push({ min: sorted.at(-1) });
705
800
  return spans;
706
801
  }
707
- /** Send the stored plate (lowered to chunks) and its derived coverage bands over
708
- * the HMR event so the client hot-swaps it in place and the HUD updates its band
709
- * display. The gen-side runs `serializePlate(stored)` (the Bundle seam) here; the
710
- * `spans` derive from the stored breakpoints (the per-View list is gone). */
802
+ /**
803
+ * Send the stored plate (lowered to chunks) and its derived coverage bands over the HMR event so
804
+ * the client hot-swaps it in place and the HUD updates its band display. The gen-side runs
805
+ * `serializePlate(stored)` (the Bundle seam) here; the `spans` derive from the stored breakpoints.
806
+ *
807
+ * Two-artifact contract (ADR 0026): the event carries the TREE only — `serializePlate` returns a
808
+ * css-less `Plate`, so there is no ws css side-channel by construction. The css half of a capture
809
+ * travels exclusively over Vite's CSS-HMR (`invalidatePlateModules` re-pushes the `.css` virtual
810
+ * module after the write). The live-store plate that outranks the statically-imported module in
811
+ * `useLivePlate` cannot carry inline css, so no push can revert a mounted `<Skeleton>` to
812
+ * inline-`<style>` rendering.
813
+ */
711
814
  function announcePlate(ws, name, stored) {
712
815
  const breakpoints = stored?.breakpoints ?? [];
713
- const plate = stored ? serializePlate({
816
+ const merged = stored ? {
714
817
  v: stored.v,
715
818
  name,
716
819
  tree: stored.tree,
717
820
  css: stored.css
718
- }) : serializePlate(emptyPlate(name));
821
+ } : emptyPlate(name);
719
822
  ws.send({
720
823
  type: "custom",
721
824
  event: "xray:plate",
722
825
  data: {
723
826
  name,
724
- plate,
827
+ plate: serializePlate(merged),
725
828
  breakpoints,
726
829
  spans: spansFromBreakpoints(breakpoints)
727
830
  }
728
831
  });
729
832
  }
730
833
  /**
731
- * Per-plate breakpoints + derived coverage bands, read from the committed
732
- * StoredPlate files. The `spans` derive from each plate's `breakpoints` (ADR 0022
733
- * cutover per-View spans no longer exist on disk; a StoredPlate sweeps all
734
- * widths in one session, so its coverage is complete).
834
+ * Per-plate breakpoints + derived coverage bands, read from the committed StoredPlate files. The
835
+ * `spans` derive from each plate's `breakpoints` (ADR 0022: a StoredPlate sweeps all widths in one
836
+ * session, so its coverage is complete).
735
837
  *
736
838
  * @internal
737
839
  */
@@ -761,22 +863,25 @@ function readCoverage(dir) {
761
863
  }
762
864
  return out;
763
865
  }
764
- /** The on-disk Fixture sidecar shape (ADR 0015): version, plate name, and the
765
- * OPAQUE devalue string. The node side never parses `data` it is the browser
766
- * dev client's job (it has the live runtime). */
866
+ /**
867
+ * The on-disk Fixture sidecar shape (ADR 0015): version, plate name, and the OPAQUE devalue string.
868
+ * The node side never parses `data` — it is the browser dev client's job (it has the live
869
+ * runtime).
870
+ */
767
871
  const FIXTURE_VERSION = 1;
768
872
  const FIXTURE_SUFFIX = ".fixture.json";
769
- /** A recorded Fixture sidecar — `data` is the verbatim devalue string, treated
770
- * as opaque text server-side. */
873
+ /**
874
+ * A recorded Fixture sidecar — `data` is the verbatim devalue string, treated as opaque text
875
+ * server-side.
876
+ */
771
877
  function isFixtureFile(value) {
772
878
  return isRecord(value) && typeof value.v === "number" && typeof value.name === "string" && typeof value.data === "string";
773
879
  }
774
880
  /**
775
- * All recorded Fixtures as `{ <name>: <devalueString> }`, read from the
776
- * `<name>.fixture.json` sidecars (ADR 0015). The map values are the OPAQUE
777
- * devalue strings exactly as written the node side never parses them; the
778
- * browser dev client owns the devalue decode. The `.fixture.json` suffix keeps
779
- * these distinct from Plate files (`isValidPlateName` rejects the dotted name, so
881
+ * All recorded Fixtures as `{ <name>: <devalueString> }`, read from the `<name>.fixture.json`
882
+ * sidecars (ADR 0015). The map values are the OPAQUE devalue strings exactly as written — the node
883
+ * side never parses them; the browser dev client owns the devalue decode. The `.fixture.json`
884
+ * suffix keeps these distinct from Plate files (`isValidPlateName` rejects the dotted name, so
780
885
  * `readCoverage` never picks them up — Fixtures never leak into Plate coverage).
781
886
  *
782
887
  * @internal
@@ -811,12 +916,11 @@ function readFixtures(dir) {
811
916
  return out;
812
917
  }
813
918
  /**
814
- * Write a recorded Fixture sidecar (ADR 0015). Reuses the capture endpoint's
815
- * front-door checks (method, content type, declared/streamed size cap) and the
816
- * `isValidPlateName` gate, but treats the devalue `data` payload as OPAQUE TEXT:
817
- * it is validated only as a string and written verbatim, never eval'd or parsed
818
- * server-side. The sidecar lives NEXT TO the Plate but is never merged into it,
819
- * so the persisted Plate stays purely structural.
919
+ * Write a recorded Fixture sidecar (ADR 0015). Reuses the capture endpoint's front-door checks
920
+ * (method, content type, declared/streamed size cap) and the `isValidPlateName` gate, but treats
921
+ * the devalue `data` payload as OPAQUE TEXT: it is validated only as a string and written verbatim,
922
+ * never eval'd or parsed server-side. The sidecar lives NEXT TO the Plate but is never merged into
923
+ * it, so the persisted Plate stays purely structural.
820
924
  *
821
925
  * @internal
822
926
  */
@@ -879,12 +983,11 @@ function handleFixturePost(req, res, fixturePath) {
879
983
  });
880
984
  }
881
985
  /**
882
- * Validate a posted `StoredPlate` envelope, write it AS-IS, and hot-swap the
883
- * plate in place. After the ADR 0022 cutover the dev client posts a finished
884
- * StoredPlate (the whole Plate already projected in the browser) inside a
885
- * `{ plate, diagnostics? }` envelope there is NO server-side accumulation/merge
886
- * anymore. `diagnostics` is a SIBLING field the server logs and then drops; it
887
- * never reaches the stored artifact (ADR 0008).
986
+ * Validate a posted `StoredPlate` envelope, write it AS-IS, and hot-swap the plate in place. The
987
+ * dev client posts a finished StoredPlate (the whole Plate already projected in the browser, ADR
988
+ * 0022) inside a `{ plate, diagnostics? }` envelope; there is NO server-side accumulation/merge.
989
+ * `diagnostics` is a SIBLING field the server logs and then drops; it never reaches the stored
990
+ * artifact (ADR 0008).
888
991
  *
889
992
  * @internal
890
993
  */
@@ -951,6 +1054,7 @@ function handleCapturePost(req, res, server, platePath) {
951
1054
  mkdirSync(dirname(file), { recursive: true });
952
1055
  writeFileSync(file, serialized);
953
1056
  announcePlate(server.ws, stored.name, stored);
1057
+ invalidatePlateModules(server, stored.name);
954
1058
  res.statusCode = 204;
955
1059
  res.end();
956
1060
  } catch {
@@ -960,12 +1064,11 @@ function handleCapturePost(req, res, server, platePath) {
960
1064
  });
961
1065
  }
962
1066
  /**
963
- * Loosely validate the envelope's SIBLING `diagnostics` (never stored, just
964
- * logged). The StoredPlate validator does not carry diagnostics — they are a
965
- * transient dev-only field on the POST envelope — so this filters the array to
966
- * the known-code shape `reportServerDiagnostics` formats, dropping anything that
967
- * is not a `{ code, message, ... }` record. A malformed diagnostics field never
968
- * fails the post (the plate already validated); it just logs nothing.
1067
+ * Loosely validate the envelope's SIBLING `diagnostics` (never stored, just logged). The
1068
+ * StoredPlate validator does not carry diagnostics — they are a transient dev-only field on the
1069
+ * POST envelope — so this filters the array to the known-code shape `reportServerDiagnostics`
1070
+ * formats, dropping anything that is not a `{ code, message, ... }` record. A malformed diagnostics
1071
+ * field never fails the post (the plate already validated); it just logs nothing.
969
1072
  */
970
1073
  function validEnvelopeDiagnostics(value) {
971
1074
  if (!Array.isArray(value)) return void 0;
@@ -982,11 +1085,10 @@ function validEnvelopeDiagnostics(value) {
982
1085
  return out.length > 0 ? out : void 0;
983
1086
  }
984
1087
  /**
985
- * Read and validate a committed StoredPlate, distinguishing "no file" from "file
986
- * present but unreadable". `invalid` is true ONLY when the file exists but is
987
- * corrupt JSON, the wrong shape, or a version mismatch — a missing file is the
988
- * normal pre-capture state and is not flagged. Callers use `invalid` to warn
989
- * (same diagnostics vocabulary) that an existing plate was ignored.
1088
+ * Read and validate a committed StoredPlate, distinguishing "no file" from "file present but
1089
+ * unreadable". `invalid` is true ONLY when the file exists but is corrupt JSON, the wrong shape, or
1090
+ * a version mismatch — a missing file is the normal pre-capture state and is not flagged. Callers
1091
+ * use `invalid` to warn (same diagnostics vocabulary) that an existing plate was ignored.
990
1092
  */
991
1093
  function loadStoredPlate(file) {
992
1094
  let raw;
@@ -1019,18 +1121,41 @@ function readStoredPlate(file) {
1019
1121
  return loadStoredPlate(file).stored;
1020
1122
  }
1021
1123
  /**
1022
- * Render a plate as the virtual module's source. Each stitch (`ref` node) becomes
1023
- * a real import of that child's virtual module, wired into a `refs` map on the
1024
- * default export so the bundler builds the actual reference graph and chunks
1025
- * the transitive closure a page uses, with no runtime registry (ADR 0006). A
1026
- * stitchless plate is just its JSON. Self-references are dropped (a module can't
1027
- * import itself); an unknown child resolves to its own empty plate, never a
1028
- * build break.
1124
+ * Render a plate as the virtual module's source. Each stitch (`ref` node) becomes a real import of
1125
+ * that child's virtual module, wired into a `refs` map on the default export — so the bundler
1126
+ * builds the actual reference graph and chunks the transitive closure a page uses, with no runtime
1127
+ * registry (ADR 0006). A stitchless plate is just its JSON. Self-references are dropped (a module
1128
+ * can't import itself); an unknown child resolves to its own empty plate, never a build break.
1129
+ *
1130
+ * `name` defaults to the plate's own `name`, but the loader passes the PATH-derived name so a
1131
+ * hand-edited StoredPlate whose `name` field drifted from its filename still serializes and
1132
+ * self-references under the canonical name (`collectRefs` reads `stored.tree`, a RenderNode).
1133
+ *
1134
+ * Two-artifact module shape (ADR 0026):
1135
+ *
1136
+ * ```js
1137
+ * import "virtual:xray/base.css"
1138
+ * import "virtual:xray/plates/<name>.css"
1139
+ * export default { tree-only plate JSON }
1140
+ * export const css = "<the plate's scoped css string>"
1141
+ * ```
1142
+ *
1143
+ * The default export is the TREE artifact (`Plate` carries no css field); the page's styling flows
1144
+ * through Vite's own CSS pipeline via the two side-effect imports (dev inject + HMR, build
1145
+ * extraction into the chunk's `.css` asset). `export const css` is the plate's css as data, for dev
1146
+ * tooling / programmatic consumers that want the string itself — production adapters import only
1147
+ * the default, so Rollup TREE-SHAKES the unused named export out of built chunks (verified
1148
+ * empirically; the side-effect css imports do not pin it).
1029
1149
  *
1030
- * `name` defaults to the plate's own `name`, but the loader passes the
1031
- * PATH-derived name so a hand-edited StoredPlate whose `name` field drifted from
1032
- * its filename still serializes and self-references under the canonical name
1033
- * (`collectRefs` reads `stored.tree`, a RenderNode, unchanged by the cutover).
1150
+ * BASE_CSS rides as a JS-level import of the SHARED `virtual:xray/base.css` module (not an
1151
+ * `@import` inside each plate's css): Vite dedupes JS module imports by id, so the base rules reach
1152
+ * the page exactly once no matter how many plates (nested/stitched included — every plate module
1153
+ * emits this import, module identity collapses them) a page mounts, and at build a multi-chunk app
1154
+ * gets it hoisted/shared rather than copied per chunk. An `@import` inside each plate's `.css`
1155
+ * would instead be INLINED per importer by Vite's postcss-import at build, duplicating BASE_CSS
1156
+ * into every plate's asset — the opposite of the dedupe this design locks in. Base rides before the
1157
+ * plate's css module so a plate rule still beats a base rule at equal specificity by source order
1158
+ * (the `${BASE_CSS}\n${css}` composition order).
1034
1159
  *
1035
1160
  * @internal
1036
1161
  */
@@ -1040,8 +1165,12 @@ function renderPlateModule(merged, name = merged.name) {
1040
1165
  ...merged,
1041
1166
  name
1042
1167
  }));
1043
- if (refs.length === 0) return `export default ${json}`;
1044
- return `${refs.map((ref, i) => `import __xr${i} from ${JSON.stringify(VIRTUAL_PREFIX + ref)}`).join("\n")}\nexport default Object.assign(${json}, { refs: { ${refs.map((ref, i) => `${JSON.stringify(ref)}: __xr${i}`).join(", ")} } })`;
1168
+ const cssImports = `import ${JSON.stringify(BASE_CSS_ID)}\nimport ${JSON.stringify(plateCssId(name))}`;
1169
+ const cssExport = `export const css = ${JSON.stringify(merged.css)}`;
1170
+ if (refs.length === 0) return `${cssImports}\nexport default ${json}\n${cssExport}`;
1171
+ const imports = refs.map((ref, i) => `import __xr${i} from ${JSON.stringify(VIRTUAL_PREFIX + ref)}`).join("\n");
1172
+ const map = refs.map((ref, i) => `${JSON.stringify(ref)}: __xr${i}`).join(", ");
1173
+ return `${[cssImports, imports].join("\n")}\nexport default Object.assign(${json}, { refs: { ${map} } })\n${cssExport}`;
1045
1174
  }
1046
1175
  //#endregion
1047
1176
  export { collectRefs, handleCapturePost, handleFixturePost, isValidPlateName, readCoverage, readFixtures, renderPlateModule, xrayVitePlugin };