@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.
package/dist/index.js CHANGED
@@ -1,10 +1,17 @@
1
1
  import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
2
  import { dirname, relative, resolve } from "node:path";
3
+ import * as v from "valibot";
3
4
  /** App code imports plates from here, e.g. `import plate from 'virtual:xray/plates/my-banner'`. */
4
5
  const VIRTUAL_PREFIX = "virtual:xray/plates/";
5
- /** Marks the rendered skeleton root; carries the plate name. Also a dev capture marker. */
6
- const ROOT_ATTR = "data-xr-root";
7
- const SPEC_GATE = 2e7;
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).
13
+ */
14
+ const ROOT_INSTANT_ATTR = "data-xr-instant";
8
15
  /** Fixed renderer-owned classes every rendered node carries. */
9
16
  const ROOT_CLASS = "xr-root";
10
17
  const NODE_CLASS = "xr-node";
@@ -14,10 +21,17 @@ const LEAF_CLASS = "xr-leaf";
14
21
  opacity property and every leaf reads it. Hundreds of independent infinite
15
22
  opacity animations would pin the compositor and cook the CPU. */
16
23
  @property --xr-o { syntax: "<number>"; inherits: true; initial-value: 1 }
24
+ /* The reveal channel (ADR 0016): a second inherited opacity factor the leaf
25
+ multiplies into --xr-o. Transitioning THIS (not opacity directly) sidesteps
26
+ the animation-vs-transition collision ADR 0012 flagged. initial-value 1 (NOT
27
+ 0) is the degradation floor: without @starting-style the reveal can never be
28
+ driven 0->1, so it must REST at 1 (fully visible). The delay-hide below is
29
+ pure progressive enhancement layered on top. */
30
+ @property --xr-reveal { syntax: "<number>"; inherits: true; initial-value: 1 }
17
31
  .${ROOT_CLASS} {
18
- --xr-bone-highlight: #f4f4f5;
32
+ --xr-bone-highlight-color: #f4f4f5;
19
33
  display: contents;
20
- animation: var(--xr-anim, xr-pulse) var(--xr-duration, 1.2s) ease-in-out infinite alternate;
34
+ animation: var(--xr-bone-animation, xr-pulse) var(--xr-bone-animation-duration, 1.2s) ease-in-out infinite alternate;
21
35
  }
22
36
  /* No pointer-events:none — it blocks inspecting bones in devtools and buys
23
37
  nothing (no real content to click). border-color: a node may capture
@@ -25,39 +39,75 @@ const LEAF_CLASS = "xr-leaf";
25
39
  a captured display:list-item would otherwise paint. */
26
40
  .${NODE_CLASS} { border-color: transparent; list-style: none }
27
41
  .${LEAF_CLASS} {
28
- background: var(--xr-fill, var(--xr-bone, #e4e4e7));
29
- border-radius: var(--xr-radius, 4px);
30
- opacity: var(--xr-o);
31
- }
32
- /* Per-kind defaults: text reads as rounded bars, media as blocks. Re-theme any
33
- kind via [data-xr-root] .${LEAF_CLASS}-text { ... }. */
34
- .${LEAF_CLASS}-text { border-radius: var(--xr-radius-text, 999px) }
35
- .${LEAF_CLASS}-media { border-radius: var(--xr-radius-media, var(--xr-radius, 4px)) }
42
+ background: var(--xr-bone-fill, var(--xr-bone-color, #e4e4e7));
43
+ border-radius: var(--xr-bone-border-radius, 4px);
44
+ /* Composed opacity (ADR 0016): the pulse channel times the reveal channel.
45
+ Never transition opacity directly (the pulse animation owns it). */
46
+ opacity: calc(var(--xr-o) * var(--xr-reveal));
47
+ }
48
+ /* Per-kind defaults: a single text LINE reads as a rounded bar; a multi-line
49
+ text BLOCK as a soft rect (a tall pill misreads as a button); media carries
50
+ 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) }
53
+ .${LEAF_CLASS}-text-block { border-radius: var(--xr-bone-text-block-border-radius, var(--xr-bone-border-radius, 4px)) }
54
+ .${LEAF_CLASS}-media { border-radius: var(--xr-bone-media-border-radius, var(--xr-bone-border-radius, 4px)) }
36
55
  @keyframes xr-pulse {
37
- from { --xr-o: var(--xr-pulse-max, 1) }
38
- to { --xr-o: var(--xr-pulse-min, 0.5) }
56
+ from { --xr-o: var(--xr-bone-pulse-opacity-max, 1) }
57
+ to { --xr-o: var(--xr-bone-pulse-opacity-min, 0.5) }
58
+ }
59
+ /* Reveal delay-hide (ADR 0016), a progressive enhancement guarded so it can NEVER
60
+ strand a skeleton invisible (open Q3). @starting-style is the trigger: only a
61
+ browser that supports it enters this block at all, and such a browser also
62
+ honors @property + transitions, so the 0->1 reveal completes. The @supports
63
+ selector(...) probe is the broadest cross-engine @starting-style feature test
64
+ available. Without support the block is skipped entirely and --xr-reveal stays
65
+ at its initial-value 1 = fully visible instant skeleton. INSIDE the block we
66
+ start at 0 and transition to 1 after the delay: invisible for
67
+ --xr-skeleton-delay, then a --xr-skeleton-transition-duration fade-in; a fast
68
+ load unmounts before the delay elapses and the skeleton is never seen. */
69
+ @supports (selector(:has(*))) {
70
+ /* The delay-hide applies to every root EXCEPT a stitch continuation (ADR 0020).
71
+ Scoping with :not([data-xr-instant]) keeps an ordinary top-level skeleton
72
+ byte-identical to before: it still mounts at --xr-reveal 0 and fades in after
73
+ the delay. The transition/initial-1 rest state matches the original. */
74
+ .${ROOT_CLASS}:not([${ROOT_INSTANT_ATTR}]) {
75
+ --xr-reveal: 1;
76
+ transition: --xr-reveal var(--xr-skeleton-transition-duration, 150ms) linear var(--xr-skeleton-delay, 250ms);
77
+ }
78
+ @starting-style { .${ROOT_CLASS}:not([${ROOT_INSTANT_ATTR}]) { --xr-reveal: 0 } }
79
+ /* A stitch continuation (ADR 0020) reveals INSTANTLY: it was already visible as a
80
+ stitch under a still-showing parent, so re-paying the delay would flash it away
81
+ and back. No @starting-style and no transition — it rests at fully visible from
82
+ its first frame, matching how the stitch inherited the parent's reveal. */
83
+ .${ROOT_CLASS}[${ROOT_INSTANT_ATTR}] { --xr-reveal: 1 }
84
+ }
85
+ /* Reduced motion (open Q2): keep the anti-flash delay + min-duration (they are
86
+ not motion), drop only the FADE — collapse the reveal transition to instant and
87
+ kill the pulse animation (as before). */
88
+ @media (prefers-reduced-motion: reduce) {
89
+ .${ROOT_CLASS} { animation: none; --xr-skeleton-transition-duration: 0s }
39
90
  }
40
- @media (prefers-reduced-motion: reduce) { .${ROOT_CLASS} { animation: none } }
41
91
  /* Dark + high-contrast bone defaults. Guarded: light-dark() is an invalid value
42
92
  without support and would drop the declaration — see the note above; remove
43
93
  this @supports once widely available (2026-11-13). */
44
94
  @supports (color: light-dark(#000, #fff)) {
45
- .${ROOT_CLASS} { --xr-bone-highlight: light-dark(#f4f4f5, #52525b) }
46
- .${LEAF_CLASS} { background: var(--xr-fill, var(--xr-bone, light-dark(#e4e4e7, #3f3f46))) }
95
+ .${ROOT_CLASS} { --xr-bone-highlight-color: light-dark(#f4f4f5, #52525b) }
96
+ .${LEAF_CLASS} { background: var(--xr-bone-fill, var(--xr-bone-color, light-dark(#e4e4e7, #3f3f46))) }
47
97
  @media (prefers-contrast: more) {
48
- .${LEAF_CLASS} { background: var(--xr-fill, var(--xr-bone, light-dark(#d4d4d8, #52525b))) }
98
+ .${LEAF_CLASS} { background: var(--xr-bone-fill, var(--xr-bone-color, light-dark(#d4d4d8, #52525b))) }
49
99
  }
50
100
  }
51
- /* Derive the sheen highlight from --xr-bone so re-theming one token updates both.
52
- Relative color isn't widely available yet; the static highlight above is the
53
- fallback (sheen just looks flatter), so no hard guard is needed. */
101
+ /* Derive the sheen highlight from --xr-bone-color so re-theming one token updates
102
+ both. Relative color isn't widely available yet; the static highlight above is
103
+ the fallback (sheen just looks flatter), so no hard guard is needed. */
54
104
  @supports (color: oklch(from red l c h)) {
55
- .${ROOT_CLASS} { --xr-bone-highlight: oklch(from var(--xr-bone, #e4e4e7) calc(l + 0.08) c h) }
105
+ .${ROOT_CLASS} { --xr-bone-highlight-color: oklch(from var(--xr-bone-color, #e4e4e7) calc(l + 0.08) c h) }
56
106
  }`.trim();
57
107
  /** The (unserialized) plate an import resolves to before anything has been captured. */
58
108
  function emptyPlate(name) {
59
109
  return {
60
- v: 1,
110
+ v: 2,
61
111
  name,
62
112
  tree: null,
63
113
  css: ""
@@ -78,12 +128,13 @@ function nodeClass(node) {
78
128
  return NODE_CLASS + (node.leaf ? ` ${LEAF_CLASS} ${LEAF_CLASS}-${node.leaf}` : "") + (node.cls ? ` ${node.cls}` : "");
79
129
  }
80
130
  /**
81
- * Whether this subtree holds a stitch (`ref`) anywhere — a stitched subtree
82
- * can't collapse to a flat string, since a `ref` resolves to a child plate at
83
- * render time and must stay a mount point.
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.
84
135
  */
85
- function hasStitch(node) {
86
- return node.ref !== void 0 || (node.kids?.some(hasStitch) ?? false);
136
+ function hasDynamic(node) {
137
+ return node.ref !== void 0 || node.count !== void 0 || (node.kids?.some(hasDynamic) ?? false);
87
138
  }
88
139
  /** Serialize a stitch-free subtree to one HTML string (caller guarantees no `ref` below). */
89
140
  function toHtml(node) {
@@ -91,10 +142,13 @@ function toHtml(node) {
91
142
  return `<div class="${nodeClass(node)}">${kids}</div>`;
92
143
  }
93
144
  /**
94
- * Serialize a node list into chunks: each maximal stitch-free run becomes one
95
- * HTML `string`; a `ref` node becomes `{ r }`; a node that isn't itself a stitch
96
- * but holds one deeper stays a real element (`{ c, k }`) and recurses. Mirrors
97
- * the element shape the runtime renderer would otherwise build per node.
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.
98
152
  */
99
153
  function toChunks(nodes) {
100
154
  const out = [];
@@ -106,12 +160,16 @@ function toChunks(nodes) {
106
160
  }
107
161
  };
108
162
  for (const node of nodes) {
109
- if (!hasStitch(node)) {
163
+ if (!hasDynamic(node)) {
110
164
  buf += toHtml(node);
111
165
  continue;
112
166
  }
113
167
  flush();
114
168
  if (node.ref !== void 0) out.push({ r: node.ref });
169
+ else if (node.count !== void 0) out.push({
170
+ t: toHtml(node),
171
+ n: node.count
172
+ });
115
173
  else out.push({
116
174
  c: nodeClass(node),
117
175
  k: toChunks(node.kids ?? [])
@@ -148,217 +206,15 @@ function serializePlate(merged) {
148
206
  css
149
207
  };
150
208
  }
151
- //#endregion
152
- //#region src/breakpoints.ts
153
- /** The view interval `[min, max)` a given viewport width falls into. */
154
- function regimeFor(width, breakpoints) {
155
- let min;
156
- let max;
157
- for (const bp of breakpoints) if (bp <= width) min = bp;
158
- else {
159
- max = bp;
160
- break;
161
- }
162
- return {
163
- ...min === void 0 ? {} : { min },
164
- ...max === void 0 ? {} : { max }
165
- };
166
- }
167
- //#endregion
168
- //#region src/classify.ts
169
- /**
170
- * Turn id-form rules + an id-tree into the shipped plate: each distinct
171
- * `(tier, media, declarations)` becomes one class, named by a per-plate
172
- * sequential id (`[data-xr-root] .xr-<plate>-<n>`), and every node carries the
173
- * class tokens it needs instead of an id (ADR 0007). The id is a plain counter,
174
- * not a content hash: the plate prefix already keeps tokens disjoint across plates
175
- * (the stitch-isolation guarantee), so a per-plate counter is collision-free by
176
- * construction — and low-entropy ids compress far better than random hashes
177
- * (~31% smaller brotli on a real plate; see the ADR 0007 amendment). Rules stay
178
- * unlayered; the existing cascade is reproduced by emission order (losers
179
- * first), so the classes are emitted in `(layered, spec, order)` order exactly
180
- * as `renderRules` did.
181
- *
182
- * Pure data-in/data-out — runs at plugin load (node) and in tests.
183
- */
184
- function classify(rules, tree, plateName) {
185
- const classBase = `${CLASS_PREFIX}${cssSafe(plateName)}-`;
186
- const sorted = rules.toSorted((a, b) => Number(b.layered ?? false) - Number(a.layered ?? false) || a.spec - b.spec || a.order - b.order);
187
- const nameForKey = /* @__PURE__ */ new Map();
188
- const body = /* @__PURE__ */ new Map();
189
- const usage = /* @__PURE__ */ new Map();
190
- const emitOrder = [];
191
- const nodeClasses = /* @__PURE__ */ new Map();
192
- for (const rule of sorted) {
193
- if (rule.decls.length === 0) continue;
194
- const decls = rule.decls.join("; ");
195
- const media = rule.media ?? [];
196
- const container = rule.container ?? [];
197
- const tier = SPEC_FOLD === "full" ? String(rule.spec) : tierLabel(rule.spec);
198
- const key = `${rule.layered ? "l" : ""} ${tier} ${media.join(" && ")} ${container.join(" && ")} ${decls}`;
199
- let name = nameForKey.get(key);
200
- if (name === void 0) {
201
- name = classBase + nameForKey.size.toString(36);
202
- nameForKey.set(key, name);
203
- body.set(name, {
204
- media,
205
- container,
206
- decls
207
- });
208
- usage.set(name, {
209
- root: false,
210
- desc: false
211
- });
212
- emitOrder.push(name);
213
- }
214
- const ids = rule.ids.length === 0 ? [0] : rule.ids;
215
- for (const id of ids) {
216
- const u = usage.get(name);
217
- if (!u) throw new Error(`missing usage entry for ${name}`);
218
- if (id === 0) u.root = true;
219
- else u.desc = true;
220
- let list = nodeClasses.get(id);
221
- if (!list) {
222
- list = [];
223
- nodeClasses.set(id, list);
224
- }
225
- if (!list.includes(name)) list.push(name);
226
- }
227
- }
228
- const lines = [];
229
- for (const name of emitOrder) {
230
- const item = body.get(name);
231
- const u = usage.get(name);
232
- if (!item || !u) throw new Error(`missing class body for ${name}`);
233
- const { media, container, decls } = item;
234
- const sels = [];
235
- if (u.root) sels.push(`[${ROOT_ATTR}].${name}`);
236
- if (u.desc) sels.push(`[${ROOT_ATTR}] .${name}`);
237
- let text = `${sels.join(", ")} { ${decls} }`;
238
- for (const condition of media.toReversed()) text = `@media ${condition} { ${text} }`;
239
- for (const condition of container.toReversed()) text = `@container ${condition} { ${text} }`;
240
- lines.push(text);
241
- }
242
- return {
243
- tree: render(tree, nodeClasses),
244
- css: lines.join("\n")
245
- };
246
- }
247
- /** Strip ids, attach content-class tokens — the shipped tree shape (ADR 0007). */
248
- function render(node, classes) {
249
- const out = {};
250
- if (node.leaf) out.leaf = node.leaf;
251
- if (node.ref !== void 0) out.ref = node.ref;
252
- const cls = classes.get(node.id);
253
- if (cls && cls.length > 0) out.cls = cls.join(" ");
254
- if (node.kids) out.kids = node.kids.map((kid) => render(kid, classes));
255
- return out;
256
- }
257
- /** Content-class prefix; distinct from the fixed `xr-root/node/leaf` base words. */
258
- const CLASS_PREFIX = "xr-";
259
- /** Make a plate name safe to embed in a class token (plate names are already constrained). */
260
- function cssSafe(name) {
261
- return name.replace(/[^\w-]/g, "_");
262
- }
263
- /**
264
- * What the class identity keys on, alongside media + declarations:
265
- * - `'tier'`: the discrete cascade tier — smaller (more bodies dedupe), but a
266
- * rare same-tier inversion within the continuous `author` band can mis-order
267
- * one property on one node (ADR 0007). The default.
268
- * - `'full'`: the exact `spec` — provably no inversion, marginally larger.
269
- * One-line flip if QA ever surfaces a mis-order.
270
- */
271
- const SPEC_FOLD = "full";
272
- function tierLabel(spec) {
273
- if (spec <= -2) return "ctx";
274
- if (spec === -1) return "fb";
275
- if (spec >= 2e7) return "gate";
276
- if (spec >= 1e7) return "inl";
277
- if (spec >= 9e6) return "leaf";
278
- return "au";
279
- }
280
- //#endregion
281
- //#region src/merge.ts
282
209
  /**
283
- * Turn the per-view captures in a plate file into the one plate a Skeleton
284
- * renders.
285
- *
286
- * Each view is kept whole its own subtree, its own ids, its own rules —
287
- * and shown only across the viewport range it owns, via `@media display:none`
288
- * gates. We do NOT merge the views' trees into one. An earlier design (ADR
289
- * 0004) did, sharing common structure and forking the rest, but aligning two
290
- * genuinely-different DOM trees (mobile stacked vs desktop two-column) without
291
- * stable keys proved unreliable: every heuristic mis-paired some nodes and
292
- * remapped one view's rules onto another's elements, corrupting both. A lone
293
- * capture is pixel-faithful; keeping each view lone preserves that. The cost
294
- * is a larger plate (roughly the sum of the views) — bounded by the plate
295
- * size cap — in exchange for correctness.
296
- *
297
- * Pure data-in/data-out — runs in node (plugin load) and in tests.
298
- */
299
- function mergeViews(file) {
300
- const views = file.views;
301
- if (views.length === 0) return emptyPlate(file.name);
302
- if (views.length === 1) {
303
- const [only] = views;
304
- if (!only) return emptyPlate(file.name);
305
- const { tree, css } = classify(only.rules, only.tree, file.name);
306
- return {
307
- v: 1,
308
- name: file.name,
309
- tree,
310
- css
311
- };
312
- }
313
- const starts = views.map((r) => regimeFor(r.width, file.breakpoints).min ?? 0);
314
- let nextId = 1;
315
- const rootKids = [];
316
- const rules = [];
317
- views.forEach((view, i) => {
318
- const idMap = /* @__PURE__ */ new Map();
319
- const kids = (view.tree.kids ?? []).map((kid) => cloneWithIds(kid, idMap, () => nextId++));
320
- rootKids.push(...kids);
321
- rules.push(...remapRules(view.rules, idMap));
322
- const lo = i === 0 ? 0 : starts[i] ?? 0;
323
- const hi = i === views.length - 1 ? Number.POSITIVE_INFINITY : starts[i + 1] ?? 0;
324
- for (const kid of kids) {
325
- if (lo > 0) rules.push(gate(kid.id, `(max-width: ${lo - .02}px)`));
326
- if (hi !== Number.POSITIVE_INFINITY) rules.push(gate(kid.id, `(min-width: ${hi}px)`));
327
- }
328
- });
329
- const { tree, css } = classify(rules, {
330
- id: 0,
331
- kids: rootKids
332
- }, file.name);
333
- return {
334
- v: 1,
335
- name: file.name,
336
- tree,
337
- css
338
- };
339
- }
340
- function gate(id, condition) {
341
- return {
342
- ids: [id],
343
- decls: ["display: none !important"],
344
- media: [condition],
345
- spec: SPEC_GATE,
346
- order: id
347
- };
348
- }
349
- /** Deep-clone a captured subtree into fresh ids, recording the mapping. */
350
- function cloneWithIds(node, idMap, allocate) {
351
- const id = allocate();
352
- idMap.set(node.id, id);
353
- const kids = node.kids?.map((kid) => cloneWithIds(kid, idMap, allocate));
354
- return {
355
- id,
356
- ...node.leaf ? { leaf: node.leaf } : {},
357
- ...node.ref !== void 0 ? { ref: node.ref } : {},
358
- ...kids ? { kids } : {}
359
- };
360
- }
361
- /** Distinct plate names referenced by the tree's stitches, in first-seen order. */
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).
217
+ */
362
218
  function collectRefs(tree) {
363
219
  const names = [];
364
220
  const seen = /* @__PURE__ */ new Set();
@@ -372,26 +228,245 @@ function collectRefs(tree) {
372
228
  if (tree) visit(tree);
373
229
  return names;
374
230
  }
375
- function remapRules(rules, idMap) {
376
- return rules.map((rule) => ({
377
- ...rule,
378
- ids: rule.ids.map((id) => idMap.get(id) ?? id)
379
- }));
380
- }
381
- /** Fold a fresh capture into the plate file: union breakpoints, one capture per view. */
382
- function addCapture(file, name, capture, breakpoints) {
383
- const allBreakpoints = [...new Set([...file?.breakpoints ?? [], ...breakpoints])].toSorted((a, b) => a - b);
384
- const spanKey = (width) => JSON.stringify(regimeFor(width, allBreakpoints));
385
- const views = [...file?.views ?? [], capture];
386
- const byView = /* @__PURE__ */ new Map();
387
- for (const view of views) byView.set(spanKey(view.width), view);
231
+ //#endregion
232
+ //#region src/diagnostics.ts
233
+ /**
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.
239
+ */
240
+ function makeDiagnostic(code, options) {
388
241
  return {
389
- v: 1,
390
- name,
391
- breakpoints: allBreakpoints,
392
- views: [...byView.values()].toSorted((a, b) => a.width - b.width)
242
+ code,
243
+ message: DIAGNOSTIC_MESSAGES[code],
244
+ ...options?.count !== void 0 ? { count: options.count } : {},
245
+ ...options?.detail !== void 0 ? { detail: options.detail } : {}
393
246
  };
394
247
  }
248
+ /**
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.
253
+ */
254
+ const DIAGNOSTIC_MESSAGES = {
255
+ "unreadable-stylesheet": "could not read a stylesheet (likely cross-origin); rules from it were not captured",
256
+ "unreadable-import": "could not read an @import (likely cross-origin); its rules were not captured",
257
+ "skipped-dynamic-pseudo": "skipped interaction pseudo-class selectors (:hover, :focus, etc.) — a static skeleton never enters those states",
258
+ "skipped-pseudo-element": "skipped pseudo-element selectors (::before, ::after, etc.) — v1 has no box to map them onto",
259
+ "unsupported-rule": "skipped CSS rules of a kind the serializer does not lift",
260
+ "dropped-tag": "dropped hidden or non-visual elements (display:none, visibility:hidden, opacity:0, script/style/etc.)",
261
+ "dropped-subset": "dropped bones wholly inside a larger solid bone — the superset renders the same area (overlapping bones collapse to their union)",
262
+ "dropped-cropped": "dropped bones below the fold (off-screen at capture, in the viewport or a scroll container); real content fills in below without shifting the visible skeleton",
263
+ "too-large": "capture exceeded the node limit and was skipped; the <Skeleton> likely sits too high in the tree",
264
+ "invalid-plate-file": "a committed plate file could not be read (corrupt JSON, wrong shape, or a version mismatch) and was ignored"
265
+ };
266
+ /**
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.
272
+ */
273
+ function formatDiagnostic(diagnostic) {
274
+ const count = diagnostic.count !== void 0 && diagnostic.count > 1 ? ` (${diagnostic.count})` : "";
275
+ const detail = diagnostic.detail !== void 0 ? `: ${diagnostic.detail}` : "";
276
+ return `${diagnostic.message}${count}${detail}`;
277
+ }
278
+ /** The grouped-warning header for a plate's diagnostics, shared by browser and server. */
279
+ function diagnosticsHeader(name) {
280
+ return `[xray] "${name}" captured with reduced fidelity:`;
281
+ }
282
+ //#endregion
283
+ //#region src/name.ts
284
+ /**
285
+ * Plate-name validation, shared by the Vite plugin (`index.ts`) and the
286
+ * Node-only POST validator (`validate.ts`).
287
+ *
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.
293
+ *
294
+ * @module
295
+ */
296
+ /**
297
+ * Names come from import specifiers and POST bodies — keep them inside the plates dir.
298
+ *
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`).
307
+ *
308
+ * @internal
309
+ */
310
+ function isValidPlateName(name) {
311
+ return /^\w+(?:-\w+)*(?:\/\w+(?:-\w+)*)*$/.test(name);
312
+ }
313
+ //#endregion
314
+ //#region src/validate.ts
315
+ /**
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.
319
+ *
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).
330
+ *
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).
342
+ *
343
+ * @module
344
+ */
345
+ /**
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.
350
+ */
351
+ const MAX_TREE_NODES = 5e4;
352
+ const MAX_BREAKPOINTS = 256;
353
+ const MAX_TREE_DEPTH = 1e3;
354
+ /**
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`.
357
+ */
358
+ const MAX_TEMPLATE_COUNT = 1e4;
359
+ /** A single declaration is `prop: value [!important]`; real ones are short. */
360
+ const MAX_DECL_LENGTH = 2e3;
361
+ /**
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).
366
+ */
367
+ const RAW_TEXT_TERMINATORS = /<!--|-->|<!\[cdata\[|<\/style/iu;
368
+ /** A finite number, no NaN/Infinity (valibot's `number()` already rejects NaN). */
369
+ const finiteNumber = v.pipe(v.number(), v.finite());
370
+ const leafSchema = v.picklist([
371
+ "text",
372
+ "text-block",
373
+ "media",
374
+ "box"
375
+ ]);
376
+ /**
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.
381
+ */
382
+ function treeWithinLimits(root) {
383
+ let count = 0;
384
+ const walk = (node, depth) => {
385
+ if (depth > MAX_TREE_DEPTH) return false;
386
+ if (++count > MAX_TREE_NODES) return false;
387
+ if (typeof node !== "object" || node === null) return true;
388
+ const kids = node.kids;
389
+ if (!Array.isArray(kids)) return true;
390
+ for (const kid of kids) if (!walk(kid, depth + 1)) return false;
391
+ return true;
392
+ };
393
+ return walk(root, 0);
394
+ }
395
+ /** A finished plate's css blob, generously bounded (post-classify, whole-plate). */
396
+ const MAX_PLATE_CSS_LENGTH = 4e6;
397
+ /**
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.
402
+ */
403
+ const NUL = /\u0000/u;
404
+ /**
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).
419
+ */
420
+ function isValidPlateCss(css) {
421
+ if (typeof css !== "string") return false;
422
+ if (css.length > MAX_PLATE_CSS_LENGTH) return false;
423
+ if (NUL.test(css)) return false;
424
+ if (RAW_TEXT_TERMINATORS.test(css)) return false;
425
+ return true;
426
+ }
427
+ /**
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`.
432
+ */
433
+ const renderNodeSchema = v.pipe(v.object({
434
+ leaf: v.optional(leafSchema),
435
+ ref: v.optional(v.pipe(v.string(), v.check(isValidPlateName, "invalid stitch ref"))),
436
+ cls: v.optional(v.pipe(v.string(), v.maxLength(MAX_DECL_LENGTH))),
437
+ count: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(MAX_TEMPLATE_COUNT))),
438
+ kids: v.optional(v.pipe(v.array(v.lazy(() => renderNodeSchema)), v.maxLength(MAX_TREE_NODES)))
439
+ }), v.check((node) => treeWithinLimits(node), "tree exceeds size or depth limits"));
440
+ /**
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`.
449
+ */
450
+ const storedPlateSchema = v.object({
451
+ v: v.literal(2),
452
+ name: v.pipe(v.string(), v.check(isValidPlateName, "invalid plate name")),
453
+ breakpoints: v.pipe(v.array(v.pipe(finiteNumber, v.minValue(0))), v.maxLength(MAX_BREAKPOINTS)),
454
+ tree: v.nullable(renderNodeSchema),
455
+ css: v.pipe(v.string(), v.check(isValidPlateCss, "invalid plate css"))
456
+ });
457
+ /**
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.
465
+ */
466
+ function parseStoredPlate(value) {
467
+ const result = v.safeParse(storedPlateSchema, value);
468
+ return result.success ? result.output : null;
469
+ }
395
470
  //#endregion
396
471
  //#region src/index.ts
397
472
  /**
@@ -432,8 +507,11 @@ function xrayVitePlugin(options = {}) {
432
507
  const settleCap = options.settleCap ?? 5e3;
433
508
  const maxNodes = options.maxNodes ?? 4e3;
434
509
  const hud = options.hud ?? false;
510
+ const recordMode = options.fixtures?.record ?? "manual";
511
+ const replayMode = options.fixtures?.replay ?? false;
435
512
  let root = process.cwd();
436
513
  const platePath = (name) => resolve(root, platesDir, `${name}.json`);
514
+ const fixturePath = (name) => resolve(root, platesDir, `${name}.fixture.json`);
437
515
  return [{
438
516
  name: "xray:data",
439
517
  configResolved(config) {
@@ -446,7 +524,7 @@ function xrayVitePlugin(options = {}) {
446
524
  if (!id.startsWith(RESOLVED_PREFIX)) return void 0;
447
525
  const name = id.slice(RESOLVED_PREFIX.length);
448
526
  if (!isValidPlateName(name)) return renderPlateModule(emptyPlate(name));
449
- return renderPlateModule(readPlate(platePath(name), name));
527
+ return renderPlateModule(readStoredPlate(platePath(name)) ?? emptyPlate(name), name);
450
528
  }
451
529
  }, {
452
530
  name: "xray:dev",
@@ -460,7 +538,7 @@ function xrayVitePlugin(options = {}) {
460
538
  transformIndexHtml: {
461
539
  order: "pre",
462
540
  handler() {
463
- if (!capture && !hud) return void 0;
541
+ if (!capture && !hud && replayMode === false && recordMode === "manual") return;
464
542
  return [{
465
543
  tag: "script",
466
544
  attrs: {
@@ -476,7 +554,7 @@ function xrayVitePlugin(options = {}) {
476
554
  },
477
555
  load(id) {
478
556
  if (id !== RESOLVED_BOOT_ID) return void 0;
479
- return bootstrapCode(delay, settleCap, maxNodes, hud, capture);
557
+ return bootstrapCode(delay, settleCap, maxNodes, hud, capture, recordMode, replayMode);
480
558
  },
481
559
  configureServer(server) {
482
560
  const dir = () => resolve(root, platesDir);
@@ -485,7 +563,7 @@ function xrayVitePlugin(options = {}) {
485
563
  if (!file.startsWith(dir()) || !file.endsWith(".json")) return;
486
564
  const name = relative(dir(), file).slice(0, -5);
487
565
  if (!isValidPlateName(name)) return;
488
- announcePlate(server.ws, name, readPlateFile(file));
566
+ announcePlate(server.ws, name, readStoredPlate(file));
489
567
  };
490
568
  server.watcher.on("add", onPlateFile);
491
569
  server.watcher.on("change", onPlateFile);
@@ -495,6 +573,15 @@ function xrayVitePlugin(options = {}) {
495
573
  res.end(JSON.stringify(readCoverage(dir())));
496
574
  return;
497
575
  }
576
+ if (req.method === "GET" && req.url === "/fixtures") {
577
+ res.setHeader("content-type", "application/json");
578
+ res.end(JSON.stringify(readFixtures(dir())));
579
+ return;
580
+ }
581
+ if (req.url === "/fixture") {
582
+ handleFixturePost(req, res, fixturePath);
583
+ return;
584
+ }
498
585
  handleCapturePost(req, res, server, platePath);
499
586
  });
500
587
  }
@@ -504,22 +591,42 @@ function xrayVitePlugin(options = {}) {
504
591
  * The injected dev module: boot the client, POST captures back, optionally
505
592
  * mount the HUD. The client installs first so the HUD finds the light box store.
506
593
  */
507
- function bootstrapCode(delay, settleCap, maxNodes, hud, capture) {
594
+ function bootstrapCode(delay, settleCap, maxNodes, hud, capture, recordMode, replayMode) {
508
595
  return [
509
- `import { installXrayClient } from '@hueest/xray/client'`,
510
- ...hud ? [`import { installXrayHud } from '@hueest/xray/hud'`] : [],
596
+ `import { installXrayClient } from '@hueest/xray/internal/client'`,
597
+ ...hud ? [`import { installXrayHud } from '@hueest/xray/internal/hud'`] : [],
511
598
  "installXrayClient({",
512
599
  ` delay: ${delay},`,
513
600
  ` settleCap: ${settleCap},`,
514
601
  ` maxNodes: ${maxNodes},`,
515
602
  ` capture: ${capture},`,
603
+ ` record: ${JSON.stringify(recordMode)},`,
604
+ ` replay: ${JSON.stringify(replayMode)},`,
605
+ " recordFixtureSink(payload) {",
606
+ ` return fetch('${ENDPOINT}/fixture', {`,
607
+ " method: 'POST',",
608
+ " headers: { 'content-type': 'application/json' },",
609
+ " body: JSON.stringify(payload),",
610
+ " }).then((response) => {",
611
+ " if (!response.ok) throw new Error('xray fixture POST failed: ' + response.status)",
612
+ " })",
613
+ " },",
516
614
  " sink(payload) {",
517
- ` void fetch('${ENDPOINT}', {`,
615
+ ` return fetch('${ENDPOINT}', {`,
518
616
  " method: 'POST',",
519
617
  " headers: { 'content-type': 'application/json' },",
520
618
  " body: JSON.stringify(payload),",
619
+ " }).then((response) => {",
620
+ " if (!response.ok) throw new Error('xray sink POST failed: ' + response.status)",
521
621
  " })",
522
622
  " },",
623
+ " refreshCoverage() {",
624
+ ` return fetch('${ENDPOINT}/coverage')`,
625
+ " .then((response) => response.json())",
626
+ " .then((coverage) => {",
627
+ " for (const [name, c] of Object.entries(coverage)) globalThis.__XRAY__?.coverage?.set(name, c)",
628
+ " })",
629
+ " },",
523
630
  "})",
524
631
  ...hud ? ["installXrayHud()"] : [],
525
632
  `void fetch('${ENDPOINT}/coverage')`,
@@ -528,6 +635,10 @@ function bootstrapCode(delay, settleCap, maxNodes, hud, capture) {
528
635
  " for (const [name, c] of Object.entries(coverage)) globalThis.__XRAY__?.coverage?.set(name, c)",
529
636
  " })",
530
637
  " .catch(() => {})",
638
+ `void fetch('${ENDPOINT}/fixtures')`,
639
+ " .then((response) => response.json())",
640
+ " .then((fixtures) => { globalThis.__XRAY__?.fixtures?.seed?.(fixtures) })",
641
+ " .catch(() => {})",
531
642
  "if (import.meta.hot) {",
532
643
  " import.meta.hot.on('xray:plate', (data) => {",
533
644
  " globalThis.__XRAY__?.updatePlate?.(data.name, data.plate)",
@@ -537,58 +648,90 @@ function bootstrapCode(delay, settleCap, maxNodes, hud, capture) {
537
648
  ""
538
649
  ].join("\n");
539
650
  }
651
+ /**
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.
656
+ */
657
+ function reportServerDiagnostics(server, name, diagnostics) {
658
+ if (!diagnostics || diagnostics.length === 0) return;
659
+ const text = [diagnosticsHeader(name), ...diagnostics.map((d) => formatDiagnostic(d))].join("\n");
660
+ const logger = server.config?.logger;
661
+ if (logger) logger.warn(text);
662
+ else console.warn(text);
663
+ }
540
664
  /** A committed plate file past this is almost always a `<Skeleton>` set too high. */
541
665
  const MAX_PLATE_BYTES = 2e6;
666
+ /**
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.
675
+ */
676
+ const MAX_BODY_BYTES = 4e6;
677
+ /** The only content type the capture client posts; anything else is rejected up front. */
678
+ function isJsonContentType(value) {
679
+ const header = Array.isArray(value) ? value[0] : value;
680
+ if (typeof header !== "string") return false;
681
+ return /^application\/json\b/i.test(header.trim());
682
+ }
542
683
  function isRecord(value) {
543
684
  return typeof value === "object" && value !== null;
544
685
  }
545
- function isNumberArray(value) {
546
- return Array.isArray(value) && value.every((item) => typeof item === "number");
547
- }
548
- function isStringArray(value) {
549
- return Array.isArray(value) && value.every((item) => typeof item === "string");
550
- }
551
- function isLeafKind(value) {
552
- return value === "text" || value === "media" || value === "box";
553
- }
554
- function isPlateNode(value) {
555
- return isRecord(value) && typeof value.id === "number" && (!("leaf" in value) || isLeafKind(value.leaf)) && (!("ref" in value) || typeof value.ref === "string") && (!("kids" in value) || Array.isArray(value.kids) && value.kids.every(isPlateNode));
556
- }
557
- function isPlateRule(value) {
558
- return isRecord(value) && isNumberArray(value.ids) && isStringArray(value.decls) && typeof value.spec === "number" && typeof value.order === "number" && (!("media" in value) || isStringArray(value.media)) && (!("container" in value) || isStringArray(value.container)) && (!("layered" in value) || typeof value.layered === "boolean");
559
- }
560
- function isViewCapture(value) {
561
- return isRecord(value) && typeof value.width === "number" && (!("min" in value) || typeof value.min === "number") && (!("max" in value) || typeof value.max === "number") && isPlateNode(value.tree) && Array.isArray(value.rules) && value.rules.every(isPlateRule);
562
- }
563
- function isPlateFile(value) {
564
- return isRecord(value) && typeof value.v === "number" && typeof value.name === "string" && isNumberArray(value.breakpoints) && Array.isArray(value.views) && value.views.every(isViewCapture);
565
- }
566
- function isIncomingCapture(value) {
567
- return isRecord(value) && typeof value.name === "string" && isViewCapture(value.capture) && (!("breakpoints" in value) || isNumberArray(value.breakpoints));
686
+ /**
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).
695
+ */
696
+ function spansFromBreakpoints(breakpoints) {
697
+ const sorted = [...breakpoints].toSorted((a, b) => a - b);
698
+ if (sorted.length === 0) return [{}];
699
+ const spans = [{ max: sorted[0] }];
700
+ for (let i = 0; i < sorted.length - 1; i++) spans.push({
701
+ min: sorted[i],
702
+ max: sorted[i + 1]
703
+ });
704
+ spans.push({ min: sorted.at(-1) });
705
+ return spans;
568
706
  }
569
- /** Send the merged plate (and its disk coverage) over the HMR event so the
570
- * client hot-swaps it in place and the HUD updates its band display. */
571
- function announcePlate(ws, name, plateFile) {
572
- const merged = plateFile ? mergeViews({
573
- ...plateFile,
574
- name
575
- }) : emptyPlate(name);
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). */
711
+ function announcePlate(ws, name, stored) {
712
+ const breakpoints = stored?.breakpoints ?? [];
713
+ const plate = stored ? serializePlate({
714
+ v: stored.v,
715
+ name,
716
+ tree: stored.tree,
717
+ css: stored.css
718
+ }) : serializePlate(emptyPlate(name));
576
719
  ws.send({
577
720
  type: "custom",
578
721
  event: "xray:plate",
579
722
  data: {
580
723
  name,
581
- plate: serializePlate(merged),
582
- breakpoints: plateFile?.breakpoints ?? [],
583
- spans: (plateFile?.views ?? []).map((view) => ({
584
- min: view.min,
585
- max: view.max
586
- }))
724
+ plate,
725
+ breakpoints,
726
+ spans: spansFromBreakpoints(breakpoints)
587
727
  }
588
728
  });
589
729
  }
590
730
  /**
591
- * Per-plate breakpoints + captured spans, read from the committed plate files.
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).
592
735
  *
593
736
  * @internal
594
737
  */
@@ -608,20 +751,140 @@ function readCoverage(dir) {
608
751
  if (!rel.endsWith(".json")) continue;
609
752
  const name = rel.slice(0, -5);
610
753
  if (!isValidPlateName(name)) continue;
611
- const plateFile = readPlateFile(resolve(dir, rel));
612
- if (!plateFile) continue;
754
+ const stored = readStoredPlate(resolve(dir, rel));
755
+ if (!stored) continue;
756
+ const breakpoints = stored.breakpoints ?? [];
613
757
  out[name] = {
614
- breakpoints: plateFile.breakpoints ?? [],
615
- spans: plateFile.views.map((view) => ({
616
- min: view.min,
617
- max: view.max
618
- }))
758
+ breakpoints,
759
+ spans: spansFromBreakpoints(breakpoints)
619
760
  };
620
761
  }
621
762
  return out;
622
763
  }
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). */
767
+ const FIXTURE_VERSION = 1;
768
+ const FIXTURE_SUFFIX = ".fixture.json";
769
+ /** A recorded Fixture sidecar — `data` is the verbatim devalue string, treated
770
+ * as opaque text server-side. */
771
+ function isFixtureFile(value) {
772
+ return isRecord(value) && typeof value.v === "number" && typeof value.name === "string" && typeof value.data === "string";
773
+ }
774
+ /**
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
780
+ * `readCoverage` never picks them up — Fixtures never leak into Plate coverage).
781
+ *
782
+ * @internal
783
+ */
784
+ function readFixtures(dir) {
785
+ const out = {};
786
+ let entries;
787
+ try {
788
+ entries = readdirSync(dir, {
789
+ recursive: true,
790
+ encoding: "utf8"
791
+ });
792
+ } catch {
793
+ return out;
794
+ }
795
+ for (const entry of entries) {
796
+ const rel = entry.replaceAll("\\", "/");
797
+ if (!rel.endsWith(FIXTURE_SUFFIX)) continue;
798
+ const name = rel.slice(0, -13);
799
+ if (!isValidPlateName(name)) continue;
800
+ let raw;
801
+ try {
802
+ raw = readFileSync(resolve(dir, rel), "utf8");
803
+ } catch {
804
+ continue;
805
+ }
806
+ try {
807
+ const parsed = JSON.parse(raw);
808
+ if (isFixtureFile(parsed) && parsed.v === FIXTURE_VERSION) out[name] = parsed.data;
809
+ } catch {}
810
+ }
811
+ return out;
812
+ }
623
813
  /**
624
- * Fold a posted capture into its plate file and hot-swap the plate in place.
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.
820
+ *
821
+ * @internal
822
+ */
823
+ function handleFixturePost(req, res, fixturePath) {
824
+ if (req.method !== "POST") {
825
+ res.statusCode = 405;
826
+ res.end();
827
+ return;
828
+ }
829
+ if (!isJsonContentType(req.headers?.["content-type"])) {
830
+ res.statusCode = 415;
831
+ res.end("unsupported media type");
832
+ return;
833
+ }
834
+ const declared = Number(Array.isArray(req.headers?.["content-length"]) ? req.headers["content-length"][0] : req.headers?.["content-length"]);
835
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
836
+ res.statusCode = 413;
837
+ res.end("payload too large");
838
+ return;
839
+ }
840
+ let body = "";
841
+ let bytes = 0;
842
+ let aborted = false;
843
+ req.on("data", (chunk) => {
844
+ if (aborted) return;
845
+ const text = typeof chunk === "string" ? chunk : chunk?.toString() ?? "";
846
+ bytes += Buffer.byteLength(text);
847
+ if (bytes > MAX_BODY_BYTES) {
848
+ aborted = true;
849
+ res.statusCode = 413;
850
+ res.end("payload too large");
851
+ req.destroy?.();
852
+ return;
853
+ }
854
+ body += text;
855
+ });
856
+ req.on("end", () => {
857
+ if (aborted) return;
858
+ try {
859
+ const raw = JSON.parse(body);
860
+ if (!isRecord(raw) || typeof raw.name !== "string" || !isValidPlateName(raw.name) || typeof raw.data !== "string") {
861
+ res.statusCode = 400;
862
+ res.end("invalid fixture payload");
863
+ return;
864
+ }
865
+ const file = fixturePath(raw.name);
866
+ const sidecar = {
867
+ v: FIXTURE_VERSION,
868
+ name: raw.name,
869
+ data: raw.data
870
+ };
871
+ mkdirSync(dirname(file), { recursive: true });
872
+ writeFileSync(file, `${JSON.stringify(sidecar, null, 1)}\n`);
873
+ res.statusCode = 204;
874
+ res.end();
875
+ } catch {
876
+ res.statusCode = 400;
877
+ res.end("invalid fixture payload");
878
+ }
879
+ });
880
+ }
881
+ /**
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).
625
888
  *
626
889
  * @internal
627
890
  */
@@ -631,79 +894,129 @@ function handleCapturePost(req, res, server, platePath) {
631
894
  res.end();
632
895
  return;
633
896
  }
897
+ if (!isJsonContentType(req.headers?.["content-type"])) {
898
+ res.statusCode = 415;
899
+ res.end("unsupported media type");
900
+ return;
901
+ }
902
+ const declared = Number(Array.isArray(req.headers?.["content-length"]) ? req.headers["content-length"][0] : req.headers?.["content-length"]);
903
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
904
+ res.statusCode = 413;
905
+ res.end("payload too large");
906
+ return;
907
+ }
634
908
  let body = "";
909
+ let bytes = 0;
910
+ let aborted = false;
635
911
  req.on("data", (chunk) => {
636
- body += typeof chunk === "string" ? chunk : chunk?.toString() ?? "";
912
+ if (aborted) return;
913
+ const text = typeof chunk === "string" ? chunk : chunk?.toString() ?? "";
914
+ bytes += Buffer.byteLength(text);
915
+ if (bytes > MAX_BODY_BYTES) {
916
+ aborted = true;
917
+ res.statusCode = 413;
918
+ res.end("payload too large");
919
+ req.destroy?.();
920
+ return;
921
+ }
922
+ body += text;
637
923
  });
638
924
  req.on("end", () => {
925
+ if (aborted) return;
639
926
  try {
640
- const payload = JSON.parse(body);
641
- if (!isIncomingCapture(payload) || !isValidPlateName(payload.name)) {
927
+ const raw = JSON.parse(body);
928
+ const envelope = isRecord(raw) ? raw : void 0;
929
+ const stored = parseStoredPlate(envelope?.plate);
930
+ if (!stored) {
642
931
  res.statusCode = 400;
643
932
  res.end("invalid capture payload");
644
933
  return;
645
934
  }
646
- const file = platePath(payload.name);
647
- const existing = readPlateFile(file);
648
- const next = addCapture(existing, payload.name, payload.capture, payload.breakpoints ?? []);
649
- const serialized = `${JSON.stringify(next, null, 1)}\n`;
935
+ reportServerDiagnostics(server, stored.name, validEnvelopeDiagnostics(envelope?.diagnostics));
936
+ const file = platePath(stored.name);
937
+ const { stored: existing, invalid } = loadStoredPlate(file);
938
+ if (invalid) reportServerDiagnostics(server, stored.name, [makeDiagnostic("invalid-plate-file", { detail: `${stored.name}.json` })]);
939
+ const serialized = `${JSON.stringify(stored, null, 1)}\n`;
650
940
  if (existing !== null && `${JSON.stringify(existing, null, 1)}\n` === serialized) {
651
941
  res.statusCode = 204;
652
942
  res.end();
653
943
  return;
654
944
  }
655
945
  if (serialized.length > MAX_PLATE_BYTES) {
656
- console.warn(`[xray] not writing "${payload.name}": ${Math.round(serialized.length / 1e3)}KB exceeds the cap.`, "The <Skeleton> likely sits too high in the tree.");
946
+ console.warn(`[xray] not writing "${stored.name}": ${Math.round(serialized.length / 1e3)}KB exceeds the cap.`, "The <Skeleton> likely sits too high in the tree.");
657
947
  res.statusCode = 413;
658
948
  res.end("plate too large");
659
949
  return;
660
950
  }
661
951
  mkdirSync(dirname(file), { recursive: true });
662
952
  writeFileSync(file, serialized);
663
- announcePlate(server.ws, payload.name, next);
953
+ announcePlate(server.ws, stored.name, stored);
664
954
  res.statusCode = 204;
665
955
  res.end();
666
- } catch (error) {
956
+ } catch {
667
957
  res.statusCode = 400;
668
- res.end(error instanceof Error ? error.message : "invalid capture payload");
958
+ res.end("invalid capture payload");
669
959
  }
670
960
  });
671
961
  }
672
962
  /**
673
- * Names come from import specifiers and POST bodies — keep them inside the plates dir.
674
- *
675
- * @internal
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.
676
969
  */
677
- function isValidPlateName(name) {
678
- return /^[\w-]+(\/[\w-]+)*$/.test(name);
970
+ function validEnvelopeDiagnostics(value) {
971
+ if (!Array.isArray(value)) return void 0;
972
+ const out = [];
973
+ for (const item of value) {
974
+ if (!isRecord(item) || typeof item.code !== "string" || typeof item.message !== "string") continue;
975
+ out.push({
976
+ code: item.code,
977
+ message: item.message,
978
+ ...typeof item.count === "number" ? { count: item.count } : {},
979
+ ...typeof item.detail === "string" ? { detail: item.detail } : {}
980
+ });
981
+ }
982
+ return out.length > 0 ? out : void 0;
679
983
  }
680
- function readPlateFile(file) {
984
+ /**
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.
990
+ */
991
+ function loadStoredPlate(file) {
681
992
  let raw;
682
993
  try {
683
994
  raw = readFileSync(file, "utf8");
684
995
  } catch {
685
- return null;
996
+ return {
997
+ stored: null,
998
+ invalid: false
999
+ };
686
1000
  }
687
1001
  try {
688
- const plateFile = JSON.parse(raw);
689
- if (!isPlateFile(plateFile) || plateFile.v !== 1) return null;
690
- return plateFile;
1002
+ const stored = parseStoredPlate(JSON.parse(raw));
1003
+ if (!stored) return {
1004
+ stored: null,
1005
+ invalid: true
1006
+ };
1007
+ return {
1008
+ stored,
1009
+ invalid: false
1010
+ };
691
1011
  } catch {
692
- return null;
1012
+ return {
1013
+ stored: null,
1014
+ invalid: true
1015
+ };
693
1016
  }
694
1017
  }
695
- /**
696
- * Read a plate file and merge its views down to the plate the import
697
- * resolves to. Missing, unreadable, or version-mismatched files resolve to an
698
- * empty plate, so imports never break.
699
- */
700
- function readPlate(file, name) {
701
- const plateFile = readPlateFile(file);
702
- if (!plateFile) return emptyPlate(name);
703
- return mergeViews({
704
- ...plateFile,
705
- name
706
- });
1018
+ function readStoredPlate(file) {
1019
+ return loadStoredPlate(file).stored;
707
1020
  }
708
1021
  /**
709
1022
  * Render a plate as the virtual module's source. Each stitch (`ref` node) becomes
@@ -714,13 +1027,21 @@ function readPlate(file, name) {
714
1027
  * import itself); an unknown child resolves to its own empty plate, never a
715
1028
  * build break.
716
1029
  *
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).
1034
+ *
717
1035
  * @internal
718
1036
  */
719
- function renderPlateModule(merged) {
720
- const refs = collectRefs(merged.tree).filter((name) => name !== merged.name && isValidPlateName(name));
721
- const json = JSON.stringify(serializePlate(merged));
1037
+ function renderPlateModule(merged, name = merged.name) {
1038
+ const refs = collectRefs(merged.tree).filter((ref) => ref !== name && isValidPlateName(ref));
1039
+ const json = JSON.stringify(serializePlate({
1040
+ ...merged,
1041
+ name
1042
+ }));
722
1043
  if (refs.length === 0) return `export default ${json}`;
723
- return `${refs.map((name, i) => `import __xr${i} from ${JSON.stringify(VIRTUAL_PREFIX + name)}`).join("\n")}\nexport default Object.assign(${json}, { refs: { ${refs.map((name, i) => `${JSON.stringify(name)}: __xr${i}`).join(", ")} } })`;
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(", ")} } })`;
724
1045
  }
725
1046
  //#endregion
726
- export { addCapture, collectRefs, handleCapturePost, isValidPlateName, mergeViews, readCoverage, renderPlateModule, xrayVitePlugin };
1047
+ export { collectRefs, handleCapturePost, handleFixturePost, isValidPlateName, readCoverage, readFixtures, renderPlateModule, xrayVitePlugin };