@hueest/xray 0.2.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
@@ -3,8 +3,6 @@ import { dirname, relative, resolve } from "node:path";
3
3
  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
- /** Marks the rendered skeleton root; carries the plate name. Also a dev capture marker. */
7
- const ROOT_ATTR = "data-xr-root";
8
6
  /**
9
7
  * Marks an OUTERMOST skeleton root that is a stitch continuation (ADR 0020): a
10
8
  * standalone `<Skeleton>` replacing a stitch a still-showing parent skeleton was
@@ -14,7 +12,6 @@ const ROOT_ATTR = "data-xr-root";
14
12
  * own) nor on an ordinary top-level skeleton (which keeps the normal delay).
15
13
  */
16
14
  const ROOT_INSTANT_ATTR = "data-xr-instant";
17
- const SPEC_GATE = 2e7;
18
15
  /** Fixed renderer-owned classes every rendered node carries. */
19
16
  const ROOT_CLASS = "xr-root";
20
17
  const NODE_CLASS = "xr-node";
@@ -48,9 +45,12 @@ const LEAF_CLASS = "xr-leaf";
48
45
  Never transition opacity directly (the pulse animation owns it). */
49
46
  opacity: calc(var(--xr-o) * var(--xr-reveal));
50
47
  }
51
- /* Per-kind defaults: text reads as rounded bars, media as blocks. Re-theme any
52
- kind via [data-xr-root] .${LEAF_CLASS}-text { ... }. */
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 { ... }. */
53
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
54
  .${LEAF_CLASS}-media { border-radius: var(--xr-bone-media-border-radius, var(--xr-bone-border-radius, 4px)) }
55
55
  @keyframes xr-pulse {
56
56
  from { --xr-o: var(--xr-bone-pulse-opacity-max, 1) }
@@ -107,7 +107,7 @@ const LEAF_CLASS = "xr-leaf";
107
107
  /** The (unserialized) plate an import resolves to before anything has been captured. */
108
108
  function emptyPlate(name) {
109
109
  return {
110
- v: 1,
110
+ v: 2,
111
111
  name,
112
112
  tree: null,
113
113
  css: ""
@@ -128,12 +128,13 @@ function nodeClass(node) {
128
128
  return NODE_CLASS + (node.leaf ? ` ${LEAF_CLASS} ${LEAF_CLASS}-${node.leaf}` : "") + (node.cls ? ` ${node.cls}` : "");
129
129
  }
130
130
  /**
131
- * Whether this subtree holds a stitch (`ref`) anywhere — a stitched subtree
132
- * can't collapse to a flat string, since a `ref` resolves to a child plate at
133
- * 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.
134
135
  */
135
- function hasStitch(node) {
136
- 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);
137
138
  }
138
139
  /** Serialize a stitch-free subtree to one HTML string (caller guarantees no `ref` below). */
139
140
  function toHtml(node) {
@@ -141,10 +142,13 @@ function toHtml(node) {
141
142
  return `<div class="${nodeClass(node)}">${kids}</div>`;
142
143
  }
143
144
  /**
144
- * Serialize a node list into chunks: each maximal stitch-free run becomes one
145
- * HTML `string`; a `ref` node becomes `{ r }`; a node that isn't itself a stitch
146
- * but holds one deeper stays a real element (`{ c, k }`) and recurses. Mirrors
147
- * 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.
148
152
  */
149
153
  function toChunks(nodes) {
150
154
  const out = [];
@@ -156,12 +160,16 @@ function toChunks(nodes) {
156
160
  }
157
161
  };
158
162
  for (const node of nodes) {
159
- if (!hasStitch(node)) {
163
+ if (!hasDynamic(node)) {
160
164
  buf += toHtml(node);
161
165
  continue;
162
166
  }
163
167
  flush();
164
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
+ });
165
173
  else out.push({
166
174
  c: nodeClass(node),
167
175
  k: toChunks(node.kids ?? [])
@@ -198,231 +206,15 @@ function serializePlate(merged) {
198
206
  css
199
207
  };
200
208
  }
201
- //#endregion
202
- //#region src/breakpoints.ts
203
- /** The view interval `[min, max)` a given viewport width falls into. */
204
- function regimeFor(width, breakpoints) {
205
- let min;
206
- let max;
207
- for (const bp of breakpoints) if (bp <= width) min = bp;
208
- else {
209
- max = bp;
210
- break;
211
- }
212
- return {
213
- ...min === void 0 ? {} : { min },
214
- ...max === void 0 ? {} : { max }
215
- };
216
- }
217
- //#endregion
218
- //#region src/classify.ts
219
- /**
220
- * Turn id-form rules + an id-tree into the shipped plate: each distinct
221
- * `(tier, media, declarations)` becomes one class, named by a per-plate
222
- * sequential id (`[data-xr-root] .xr-<plate>-<n>`), and every node carries the
223
- * class tokens it needs instead of an id (ADR 0007). The id is a plain counter,
224
- * not a content hash: the plate prefix already keeps tokens disjoint across plates
225
- * (the stitch-isolation guarantee), so a per-plate counter is collision-free by
226
- * construction — and low-entropy ids compress far better than random hashes
227
- * (~31% smaller brotli on a real plate; see the ADR 0007 amendment). Rules stay
228
- * unlayered; the existing cascade is reproduced by emission order (losers
229
- * first), so the classes are emitted in `(layered, spec, order)` order exactly
230
- * as `renderRules` did.
231
- *
232
- * Pure data-in/data-out — runs at plugin load (node) and in tests.
233
- */
234
- function classify(rules, tree, plateName) {
235
- const classBase = `${CLASS_PREFIX}${encodePlateName(plateName)}-`;
236
- const sorted = rules.toSorted((a, b) => Number(b.layered ?? false) - Number(a.layered ?? false) || a.spec - b.spec || a.order - b.order);
237
- const nameForKey = /* @__PURE__ */ new Map();
238
- const body = /* @__PURE__ */ new Map();
239
- const usage = /* @__PURE__ */ new Map();
240
- const emitOrder = [];
241
- const nodeClasses = /* @__PURE__ */ new Map();
242
- for (const rule of sorted) {
243
- if (rule.decls.length === 0) continue;
244
- const decls = rule.decls.join("; ");
245
- const media = rule.media ?? [];
246
- const container = rule.container ?? [];
247
- const tier = SPEC_FOLD === "full" ? String(rule.spec) : tierLabel(rule.spec);
248
- const key = `${rule.layered ? "l" : ""} ${tier} ${media.join(" && ")} ${container.join(" && ")} ${decls}`;
249
- let name = nameForKey.get(key);
250
- if (name === void 0) {
251
- name = classBase + nameForKey.size.toString(36);
252
- nameForKey.set(key, name);
253
- body.set(name, {
254
- media,
255
- container,
256
- decls
257
- });
258
- usage.set(name, {
259
- root: false,
260
- desc: false
261
- });
262
- emitOrder.push(name);
263
- }
264
- const ids = rule.ids.length === 0 ? [0] : rule.ids;
265
- for (const id of ids) {
266
- const u = usage.get(name);
267
- if (!u) throw new Error(`missing usage entry for ${name}`);
268
- if (id === 0) u.root = true;
269
- else u.desc = true;
270
- let list = nodeClasses.get(id);
271
- if (!list) {
272
- list = [];
273
- nodeClasses.set(id, list);
274
- }
275
- if (!list.includes(name)) list.push(name);
276
- }
277
- }
278
- const lines = [];
279
- for (const name of emitOrder) {
280
- const item = body.get(name);
281
- const u = usage.get(name);
282
- if (!item || !u) throw new Error(`missing class body for ${name}`);
283
- const { media, container, decls } = item;
284
- const sels = [];
285
- if (u.root) sels.push(`[${ROOT_ATTR}].${name}`);
286
- if (u.desc) sels.push(`[${ROOT_ATTR}] .${name}`);
287
- let text = `${sels.join(", ")} { ${decls} }`;
288
- for (const condition of media.toReversed()) text = `@media ${condition} { ${text} }`;
289
- for (const condition of container.toReversed()) text = `@container ${condition} { ${text} }`;
290
- lines.push(text);
291
- }
292
- return {
293
- tree: render(tree, nodeClasses),
294
- css: lines.join("\n")
295
- };
296
- }
297
- /** Strip ids, attach content-class tokens — the shipped tree shape (ADR 0007). */
298
- function render(node, classes) {
299
- const out = {};
300
- if (node.leaf) out.leaf = node.leaf;
301
- if (node.ref !== void 0) out.ref = node.ref;
302
- const cls = classes.get(node.id);
303
- if (cls && cls.length > 0) out.cls = cls.join(" ");
304
- if (node.kids) out.kids = node.kids.map((kid) => render(kid, classes));
305
- return out;
306
- }
307
- /** Content-class prefix; distinct from the fixed `xr-root/node/leaf` base words. */
308
- const CLASS_PREFIX = "xr-";
309
- /**
310
- * Encode a plate name into a class-token prefix, injectively. This prefix *is*
311
- * the isolation boundary across stitched plates (ADR 0007), so distinct plate
312
- * names must never produce the same prefix — a collision would let one plate's
313
- * `<style>` block reorder another's classes.
314
- *
315
- * Plate names are constrained by `isValidPlateName`: each path segment is
316
- * `\w+(-\w+)*` (word chars with single interior hyphens, no doubled or edge
317
- * hyphen) and segments are joined by `/`. The slash is the only char that is
318
- * not class-safe, so rendering it as `--` is enough — and because no segment
319
- * holds a literal `--` or an edge hyphen, no `-` ever abuts a `/`, so every
320
- * `--` in the prefix unambiguously came from a `/`, making the map injective.
321
- * Keeping `/` visually as `--` also leaves nested names readable in the
322
- * generated CSS (`product--card`).
323
- */
324
- function encodePlateName(name) {
325
- return name.replace(/\//g, "--");
326
- }
327
209
  /**
328
- * What the class identity keys on, alongside media + declarations:
329
- * - `'tier'`: the discrete cascade tier smaller (more bodies dedupe), but a
330
- * rare same-tier inversion within the continuous `author` band can mis-order
331
- * one property on one node (ADR 0007). The default.
332
- * - `'full'`: the exact `spec`provably no inversion, marginally larger.
333
- * One-line flip if QA ever surfaces a mis-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).
334
217
  */
335
- const SPEC_FOLD = "full";
336
- function tierLabel(spec) {
337
- if (spec <= -2) return "ctx";
338
- if (spec === -1) return "fb";
339
- if (spec >= 2e7) return "gate";
340
- if (spec >= 1e7) return "inl";
341
- if (spec >= 9e6) return "leaf";
342
- return "au";
343
- }
344
- //#endregion
345
- //#region src/merge.ts
346
- /**
347
- * Turn the per-view captures in a plate file into the one plate a Skeleton
348
- * renders.
349
- *
350
- * Each view is kept whole — its own subtree, its own ids, its own rules —
351
- * and shown only across the viewport range it owns, via `@media display:none`
352
- * gates. We do NOT merge the views' trees into one. An earlier design (ADR
353
- * 0004) did, sharing common structure and forking the rest, but aligning two
354
- * genuinely-different DOM trees (mobile stacked vs desktop two-column) without
355
- * stable keys proved unreliable: every heuristic mis-paired some nodes and
356
- * remapped one view's rules onto another's elements, corrupting both. A lone
357
- * capture is pixel-faithful; keeping each view lone preserves that. The cost
358
- * is a larger plate (roughly the sum of the views) — bounded by the plate
359
- * size cap — in exchange for correctness.
360
- *
361
- * Pure data-in/data-out — runs in node (plugin load) and in tests.
362
- */
363
- function mergeViews(file) {
364
- const views = file.views;
365
- if (views.length === 0) return emptyPlate(file.name);
366
- if (views.length === 1) {
367
- const [only] = views;
368
- if (!only) return emptyPlate(file.name);
369
- const { tree, css } = classify(only.rules, only.tree, file.name);
370
- return {
371
- v: 1,
372
- name: file.name,
373
- tree,
374
- css
375
- };
376
- }
377
- const starts = views.map((r) => regimeFor(r.width, file.breakpoints).min ?? 0);
378
- let nextId = 1;
379
- const rootKids = [];
380
- const rules = [];
381
- views.forEach((view, i) => {
382
- const idMap = /* @__PURE__ */ new Map();
383
- const kids = (view.tree.kids ?? []).map((kid) => cloneWithIds(kid, idMap, () => nextId++));
384
- rootKids.push(...kids);
385
- rules.push(...remapRules(view.rules, idMap));
386
- const lo = i === 0 ? 0 : starts[i] ?? 0;
387
- const hi = i === views.length - 1 ? Number.POSITIVE_INFINITY : starts[i + 1] ?? 0;
388
- for (const kid of kids) {
389
- if (lo > 0) rules.push(gate(kid.id, `(max-width: ${lo - .02}px)`));
390
- if (hi !== Number.POSITIVE_INFINITY) rules.push(gate(kid.id, `(min-width: ${hi}px)`));
391
- }
392
- });
393
- const { tree, css } = classify(rules, {
394
- id: 0,
395
- kids: rootKids
396
- }, file.name);
397
- return {
398
- v: 1,
399
- name: file.name,
400
- tree,
401
- css
402
- };
403
- }
404
- function gate(id, condition) {
405
- return {
406
- ids: [id],
407
- decls: ["display: none !important"],
408
- media: [condition],
409
- spec: SPEC_GATE,
410
- order: id
411
- };
412
- }
413
- /** Deep-clone a captured subtree into fresh ids, recording the mapping. */
414
- function cloneWithIds(node, idMap, allocate) {
415
- const id = allocate();
416
- idMap.set(node.id, id);
417
- const kids = node.kids?.map((kid) => cloneWithIds(kid, idMap, allocate));
418
- return {
419
- id,
420
- ...node.leaf ? { leaf: node.leaf } : {},
421
- ...node.ref !== void 0 ? { ref: node.ref } : {},
422
- ...kids ? { kids } : {}
423
- };
424
- }
425
- /** Distinct plate names referenced by the tree's stitches, in first-seen order. */
426
218
  function collectRefs(tree) {
427
219
  const names = [];
428
220
  const seen = /* @__PURE__ */ new Set();
@@ -436,27 +228,6 @@ function collectRefs(tree) {
436
228
  if (tree) visit(tree);
437
229
  return names;
438
230
  }
439
- function remapRules(rules, idMap) {
440
- return rules.map((rule) => ({
441
- ...rule,
442
- ids: rule.ids.map((id) => idMap.get(id) ?? id)
443
- }));
444
- }
445
- /** Fold a fresh capture into the plate file: union breakpoints, one capture per view. */
446
- function addCapture(file, name, capture, breakpoints) {
447
- const allBreakpoints = [...new Set([...file?.breakpoints ?? [], ...breakpoints])].toSorted((a, b) => a - b);
448
- const spanKey = (width) => JSON.stringify(regimeFor(width, allBreakpoints));
449
- const { diagnostics: _diagnostics, ...persisted } = capture;
450
- const views = [...file?.views ?? [], persisted];
451
- const byView = /* @__PURE__ */ new Map();
452
- for (const view of views) byView.set(spanKey(view.width), view);
453
- return {
454
- v: 1,
455
- name,
456
- breakpoints: allBreakpoints,
457
- views: [...byView.values()].toSorted((a, b) => a.width - b.width)
458
- };
459
- }
460
231
  //#endregion
461
232
  //#region src/diagnostics.ts
462
233
  /**
@@ -486,8 +257,9 @@ const DIAGNOSTIC_MESSAGES = {
486
257
  "skipped-dynamic-pseudo": "skipped interaction pseudo-class selectors (:hover, :focus, etc.) — a static skeleton never enters those states",
487
258
  "skipped-pseudo-element": "skipped pseudo-element selectors (::before, ::after, etc.) — v1 has no box to map them onto",
488
259
  "unsupported-rule": "skipped CSS rules of a kind the serializer does not lift",
489
- "dropped-tag": "dropped hidden or non-visual elements (display:none, visibility:hidden, script/style/etc.)",
490
- "pruned-run": "collapsed long runs of similar siblings (a list/grid) to their first few items to keep the plate small",
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",
491
263
  "too-large": "capture exceeded the node limit and was skipped; the <Skeleton> likely sits too high in the tree",
492
264
  "invalid-plate-file": "a committed plate file could not be read (corrupt JSON, wrong shape, or a version mismatch) and was ignored"
493
265
  };
@@ -539,93 +311,11 @@ function isValidPlateName(name) {
539
311
  return /^\w+(?:-\w+)*(?:\/\w+(?:-\w+)*)*$/.test(name);
540
312
  }
541
313
  //#endregion
542
- //#region src/css.ts
543
- /**
544
- * Pure CSS plumbing shared by both extraction strategies: which properties are
545
- * layout (vs paint), approximate selector specificity for cascade-preserving
546
- * ordering, and rendering scoped rules back to a single plate CSS string.
547
- */
548
- const EXACT_PROPS = new Set([
549
- "display",
550
- "position",
551
- "float",
552
- "clear",
553
- "box-sizing",
554
- "top",
555
- "right",
556
- "bottom",
557
- "left",
558
- "width",
559
- "height",
560
- "block-size",
561
- "inline-size",
562
- "aspect-ratio",
563
- "order",
564
- "flex",
565
- "gap",
566
- "row-gap",
567
- "column-gap",
568
- "line-height",
569
- "font",
570
- "font-size",
571
- "font-family",
572
- "font-weight",
573
- "font-style",
574
- "white-space",
575
- "text-align",
576
- "text-indent",
577
- "vertical-align",
578
- "letter-spacing",
579
- "word-spacing",
580
- "word-break",
581
- "overflow-wrap",
582
- "text-wrap",
583
- "transform",
584
- "transform-origin",
585
- "translate",
586
- "scale",
587
- "rotate",
588
- "table-layout",
589
- "border-collapse",
590
- "border-spacing",
591
- "caption-side",
592
- "column-count",
593
- "column-width",
594
- "columns",
595
- "column-span",
596
- "column-fill",
597
- "writing-mode",
598
- "direction",
599
- "contain",
600
- "content-visibility",
601
- "container",
602
- "container-type",
603
- "container-name",
604
- "visibility"
605
- ]);
606
- const PATTERN_PROPS = [
607
- /^(margin|padding)(-(top|right|bottom|left|block|inline))?(-(start|end))?$/,
608
- /^inset(-(block|inline))?(-(start|end))?$/,
609
- /^border(-(top|right|bottom|left|block|inline))?(-(start|end))?-(width|style)$/,
610
- /^border(-(top|bottom)-(left|right))?-radius$/,
611
- /^border-(start|end)-(start|end)-radius$/,
612
- /^grid(-|$)/,
613
- /^flex-/,
614
- /^(min|max)-(width|height|block-size|inline-size)$/,
615
- /^(align|justify|place)-(items|content|self)$/,
616
- /^overflow(-[xy])?$/
617
- ];
618
- /** Layout properties are the only declarations a plate carries; paint stays out (ADR 0003). */
619
- function isLayoutProp(prop) {
620
- if (prop.startsWith("--")) return false;
621
- if (EXACT_PROPS.has(prop)) return true;
622
- return PATTERN_PROPS.some((re) => re.test(prop));
623
- }
624
- //#endregion
625
314
  //#region src/validate.ts
626
315
  /**
627
- * Structural validation of a posted capture, plus a single-CSS-declaration
628
- * validator, for the dev `/__xray` endpoint.
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.
629
319
  *
630
320
  * NODE-ONLY (see the capture-input-validation plan and ADR 0008). This module
631
321
  * imports `valibot`, which must NEVER reach the browser bundles: the dev capture
@@ -638,14 +328,17 @@ function isLayoutProp(prop) {
638
328
  * helpers from any public subpath — they stay private until a `doctor`-style
639
329
  * tool creates a concrete external need (the plan's decisions).
640
330
  *
641
- * The endpoint accepts ONLY the payload shape xray itself produces. The dev
642
- * client always posts a `captureRegime` result, so this schema is calibrated to
643
- * what that emits finite ids, the layout declarations `filterLayoutDecls`
644
- * produces, the gate/fallback/leaf rule tiers, media/container condition stacks
645
- * and rejects everything else with a generic error. POST-time validation is
646
- * necessary but not sufficient: a committed `plates/<name>.json` is also
647
- * untrusted (hand-edited or from git), so the render path independently escapes
648
- * raw-text `<style>` terminators (css-escape.ts).
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).
649
342
  *
650
343
  * @module
651
344
  */
@@ -656,26 +349,15 @@ function isLayoutProp(prop) {
656
349
  * these are the structural ceilings the schema enforces before either runs.
657
350
  */
658
351
  const MAX_TREE_NODES = 5e4;
659
- const MAX_RULES = 5e4;
660
- const MAX_RULE_IDS = 5e4;
661
- const MAX_DECLS_PER_RULE = 1e3;
662
- const MAX_CONDITIONS = 64;
663
352
  const MAX_BREAKPOINTS = 256;
664
353
  const MAX_TREE_DEPTH = 1e3;
665
- /** A single declaration is `prop: value [!important]`; real ones are short. */
666
- const MAX_DECL_LENGTH = 2e3;
667
- /** A media/container condition string, generously bounded. */
668
- const MAX_CONDITION_LENGTH = 1e3;
669
- /** Diagnostic strings are dev console text, not plate content; keep them small. */
670
- const MAX_DIAGNOSTIC_MESSAGE = 1e3;
671
- const MAX_DIAGNOSTIC_DETAIL = 1e3;
672
354
  /**
673
- * ASCII control characters (C0 range + DEL). Written with escaped code points so
674
- * this module stays a TEXT file: a literal control byte makes git treat it as
675
- * binary and hides its lines from `grep`. The class is identical in meaning to
676
- * the previous literal-byte one (NUL through 0x1F, plus 0x7F).
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`.
677
357
  */
678
- const CONTROL_CHARS = /[\u0000-\u001f\u007f]/u;
358
+ const MAX_TEMPLATE_COUNT = 1e4;
359
+ /** A single declaration is `prop: value [!important]`; real ones are short. */
360
+ const MAX_DECL_LENGTH = 2e3;
679
361
  /**
680
362
  * Raw-text / markup terminators that must never appear in generated CSS, even
681
363
  * when no bare `<`/`>` is present: HTML comment delimiters, a CDATA opener, and
@@ -683,87 +365,20 @@ const CONTROL_CHARS = /[\u0000-\u001f\u007f]/u;
683
365
  * validators (case-insensitive).
684
366
  */
685
367
  const RAW_TEXT_TERMINATORS = /<!--|-->|<!\[cdata\[|<\/style/iu;
686
- /**
687
- * Accept only a SINGLE CSS declaration of the form `property: value` with an
688
- * optional trailing `!important`, and only for a layout property the capture
689
- * path would emit. This is the same allowlist `filterLayoutDecls` applies
690
- * (`isLayoutProp`), so a posted payload cannot persist paint- or
691
- * script-adjacent junk the real capture would never produce.
692
- *
693
- * Rejects anything that could escape the single-declaration context or the
694
- * `<style>` raw-text context downstream: rule braces (`{`/`}`), markup angle
695
- * brackets (`<`/`>`), HTML comment / CDATA delimiters, a `</style` terminator,
696
- * ASCII control characters, and any extra semicolon-separated declaration. A
697
- * lone trailing `;` is tolerated (some serializers add one); a second
698
- * declaration after it is not.
699
- */
700
- function isValidDeclaration(decl) {
701
- if (typeof decl !== "string") return false;
702
- if (decl.length === 0 || decl.length > MAX_DECL_LENGTH) return false;
703
- if (CONTROL_CHARS.test(decl)) return false;
704
- if (/[{}<>]/.test(decl)) return false;
705
- if (RAW_TEXT_TERMINATORS.test(decl)) return false;
706
- const trimmed = decl.trim().replace(/;\s*$/, "");
707
- if (trimmed.includes(";")) return false;
708
- const colon = trimmed.indexOf(":");
709
- if (colon <= 0) return false;
710
- const prop = trimmed.slice(0, colon).trim().toLowerCase();
711
- const value = trimmed.slice(colon + 1).trim();
712
- if (value.length === 0) return false;
713
- if (value.replace(/\s*!\s*important\s*$/i, "").trim().length === 0) return false;
714
- return isLayoutProp(prop);
715
- }
716
- /**
717
- * Accept only a media/container condition (prelude) of the shape xray actually
718
- * emits, and reject anything that could break OUT of the `@media`/`@container`
719
- * prelude into a top-level rule when `classify` interpolates it RAW into the
720
- * generated CSS (`@media <condition> { ... }`). The capture path produces:
721
- * synthesized span conditions `(min-width: 768px)` / `(max-width: 767.98px)`,
722
- * author media text such as `screen and (min-width: 768px)`,
723
- * `(orientation: landscape)`, comma media lists `screen, print`, and container
724
- * queries like `(min-width: 400px)`, a named `sidebar (min-width: 400px)`, or a
725
- * style query `style(--x: 1)`. Authors may also use Media Queries Level 4 range
726
- * syntax, which browsers serialize into `mediaText`/`containerQuery` verbatim
727
- * (`(width >= 600px)`, `(400px < width <= 700px)`), so a condition can legitimately
728
- * carry a bare `<`/`>` — those are allowed.
729
- *
730
- * Rejects: rule braces (`{`/`}`) and a `;` — the CSS-rule breakout vectors —
731
- * CSS comment delimiters (`/*` / `*\/`), the raw-text/markup terminators
732
- * (`</style`, `<!--`, `-->`, `<![CDATA[`), ASCII control characters, and any
733
- * over-length string.
734
- */
735
- function isValidCondition(condition) {
736
- if (typeof condition !== "string") return false;
737
- if (condition.length === 0 || condition.length > MAX_CONDITION_LENGTH) return false;
738
- if (CONTROL_CHARS.test(condition)) return false;
739
- if (/[{};]/.test(condition)) return false;
740
- if (condition.includes("/*") || condition.includes("*/")) return false;
741
- if (RAW_TEXT_TERMINATORS.test(condition)) return false;
742
- return true;
743
- }
744
- /** A finite integer id (plate-local node id), bounded to a sane range. */
745
- const idSchema = v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(Number.MAX_SAFE_INTEGER));
746
368
  /** A finite number, no NaN/Infinity (valibot's `number()` already rejects NaN). */
747
369
  const finiteNumber = v.pipe(v.number(), v.finite());
748
- const conditionList = v.pipe(v.array(v.pipe(v.string(), v.check(isValidCondition, "invalid condition"))), v.maxLength(MAX_CONDITIONS));
749
370
  const leafSchema = v.picklist([
750
371
  "text",
372
+ "text-block",
751
373
  "media",
752
374
  "box"
753
375
  ]);
754
376
  /**
755
- * Recursive `PlateNode`. `id` is required (every captured node has one);
756
- * `leaf`, `ref`, and `kids` are optional and mutually constrained by the tree
757
- * shape the capture emits. `ref` (a stitch) must name a valid plate. Depth and
758
- * child-count are bounded so a deeply nested or fanned-out payload cannot blow
759
- * the stack or memory before the node guard runs.
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.
760
381
  */
761
- const plateNodeSchema = v.pipe(v.object({
762
- id: idSchema,
763
- leaf: v.optional(leafSchema),
764
- ref: v.optional(v.pipe(v.string(), v.check(isValidPlateName, "invalid stitch ref"))),
765
- kids: v.optional(v.pipe(v.array(v.lazy(() => plateNodeSchema)), v.maxLength(MAX_TREE_NODES)))
766
- }), v.check((node) => treeWithinLimits(node), "tree exceeds size or depth limits"));
767
382
  function treeWithinLimits(root) {
768
383
  let count = 0;
769
384
  const walk = (node, depth) => {
@@ -777,103 +392,79 @@ function treeWithinLimits(root) {
777
392
  };
778
393
  return walk(root, 0);
779
394
  }
780
- const plateRuleSchema = v.object({
781
- ids: v.pipe(v.array(idSchema), v.maxLength(MAX_RULE_IDS)),
782
- decls: v.pipe(v.array(v.pipe(v.string(), v.check(isValidDeclaration, "invalid declaration"))), v.maxLength(MAX_DECLS_PER_RULE)),
783
- media: v.optional(conditionList),
784
- container: v.optional(conditionList),
785
- layered: v.optional(v.boolean()),
786
- spec: finiteNumber,
787
- order: finiteNumber
788
- });
789
- /**
790
- * The TRANSIENT, dev-only diagnostics that ride on a posted capture
791
- * (plate.ts / diagnostics.ts). Validated LOOSELY and kept as a KNOWN key so a
792
- * valid post is not rejected AND the field survives parsing for the server to
793
- * log before `addCapture` strips it. `code` is checked against the closed
794
- * DiagnosticCode set; message/detail are bounded strings, never plate content.
795
- */
796
- const diagnosticCode = v.picklist([
797
- "unreadable-stylesheet",
798
- "unreadable-import",
799
- "skipped-dynamic-pseudo",
800
- "skipped-pseudo-element",
801
- "unsupported-rule",
802
- "dropped-tag",
803
- "pruned-run",
804
- "too-large",
805
- "invalid-plate-file"
806
- ]);
807
- const diagnosticSchema = v.object({
808
- code: diagnosticCode,
809
- message: v.pipe(v.string(), v.maxLength(MAX_DIAGNOSTIC_MESSAGE)),
810
- count: v.optional(finiteNumber),
811
- detail: v.optional(v.pipe(v.string(), v.maxLength(MAX_DIAGNOSTIC_DETAIL)))
812
- });
395
+ /** A finished plate's css blob, generously bounded (post-classify, whole-plate). */
396
+ const MAX_PLATE_CSS_LENGTH = 4e6;
813
397
  /**
814
- * A `ViewCapture`: a measured width plus an optional `[min, max)` span, the
815
- * recursive tree, and the rule list. `min < max` when both are present (a
816
- * zero-width or inverted span is never captured). `diagnostics` is an optional,
817
- * loosely-validated, transient array preserved through parsing (see above).
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.
818
402
  */
819
- const viewCaptureSchema = v.pipe(v.object({
820
- width: finiteNumber,
821
- min: v.optional(finiteNumber),
822
- max: v.optional(finiteNumber),
823
- tree: plateNodeSchema,
824
- rules: v.pipe(v.array(plateRuleSchema), v.maxLength(MAX_RULES)),
825
- diagnostics: v.optional(v.pipe(v.array(diagnosticSchema), v.maxLength(MAX_CONDITIONS)))
826
- }), v.check((c) => c.min === void 0 || c.max === void 0 || c.min < c.max, "capture span min must be below max"));
403
+ const NUL = /\u0000/u;
827
404
  /**
828
- * The full `/__xray` POST body: a valid plate name, the view capture, and the
829
- * optional union of detected breakpoints (finite positive numbers, deduped and
830
- * sortedthe same normalization `addCapture` performs, so an unsorted or
831
- * duplicated post is accepted and normalized rather than rejected).
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).
832
419
  */
833
- const incomingCaptureSchema = v.object({
834
- name: v.pipe(v.string(), v.check(isValidPlateName, "invalid plate name")),
835
- capture: viewCaptureSchema,
836
- breakpoints: v.optional(v.pipe(v.array(v.pipe(finiteNumber, v.minValue(0))), v.maxLength(MAX_BREAKPOINTS), v.transform((bps) => [...new Set(bps)].toSorted((a, b) => a - b))))
837
- });
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
+ }
838
427
  /**
839
- * Parse a posted body against the schema. Returns the normalized payload on
840
- * success or `null` on any structural failure the caller maps that to a
841
- * generic `400` and NEVER echoes the payload or its CSS (the plan's error-
842
- * response decision). Validation throws nothing the caller must format.
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`.
843
432
  */
844
- function parseIncomingCapture(value) {
845
- const result = v.safeParse(incomingCaptureSchema, value);
846
- return result.success ? result.output : null;
847
- }
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"));
848
440
  /**
849
- * The committed `plates/<name>.json` shape (plate.ts). REUSES `viewCaptureSchema`
850
- * and therefore the same strict declaration and condition validators so a
851
- * file on disk is held to exactly the trust boundary a POST is: committed plates
852
- * are untrusted (hand-edited or arriving via git), and `classify` interpolates
853
- * their `decls`/`media`/`container` into generated CSS just the same. `v` must be
854
- * the current `PLATE_VERSION` and `name` a valid plate name. `breakpoints` are
855
- * finite non-negative numbers, bounded but NOT renormalized (the file is the
856
- * source of truth; the merge layer owns ordering). The persisted `ViewCapture`
857
- * never carries `diagnostics` (stripped before write), and the schema already
858
- * makes that field optional, so a file without it is accepted.
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`.
859
449
  */
860
- const plateFileSchema = v.object({
861
- v: v.literal(1),
450
+ const storedPlateSchema = v.object({
451
+ v: v.literal(2),
862
452
  name: v.pipe(v.string(), v.check(isValidPlateName, "invalid plate name")),
863
453
  breakpoints: v.pipe(v.array(v.pipe(finiteNumber, v.minValue(0))), v.maxLength(MAX_BREAKPOINTS)),
864
- views: v.pipe(v.array(viewCaptureSchema), v.maxLength(MAX_RULES))
454
+ tree: v.nullable(renderNodeSchema),
455
+ css: v.pipe(v.string(), v.check(isValidPlateCss, "invalid plate css"))
865
456
  });
866
457
  /**
867
- * Parse a committed plate file against the SAME schema a POST goes through.
868
- * Returns the typed `PlateFile` on success or `null` on any structural failure
869
- * (corrupt JSON object, wrong shape, version mismatch, an injected declaration
870
- * or media/container condition). The disk readers map `null` to an empty plate
871
- * and the `invalid-plate-file` diagnostic they never throw, so a malformed or
872
- * hostile committed plate degrades to empty rather than breaking the build or
873
- * smuggling raw CSS through `classify`.
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.
874
465
  */
875
- function parsePlateFile(value) {
876
- const result = v.safeParse(plateFileSchema, value);
466
+ function parseStoredPlate(value) {
467
+ const result = v.safeParse(storedPlateSchema, value);
877
468
  return result.success ? result.output : null;
878
469
  }
879
470
  //#endregion
@@ -933,7 +524,7 @@ function xrayVitePlugin(options = {}) {
933
524
  if (!id.startsWith(RESOLVED_PREFIX)) return void 0;
934
525
  const name = id.slice(RESOLVED_PREFIX.length);
935
526
  if (!isValidPlateName(name)) return renderPlateModule(emptyPlate(name));
936
- return renderPlateModule(readPlate(platePath(name), name));
527
+ return renderPlateModule(readStoredPlate(platePath(name)) ?? emptyPlate(name), name);
937
528
  }
938
529
  }, {
939
530
  name: "xray:dev",
@@ -972,7 +563,7 @@ function xrayVitePlugin(options = {}) {
972
563
  if (!file.startsWith(dir()) || !file.endsWith(".json")) return;
973
564
  const name = relative(dir(), file).slice(0, -5);
974
565
  if (!isValidPlateName(name)) return;
975
- announcePlate(server.ws, name, readPlateFile(file));
566
+ announcePlate(server.ws, name, readStoredPlate(file));
976
567
  };
977
568
  server.watcher.on("add", onPlateFile);
978
569
  server.watcher.on("change", onPlateFile);
@@ -1073,10 +664,11 @@ function reportServerDiagnostics(server, name, diagnostics) {
1073
664
  /** A committed plate file past this is almost always a `<Skeleton>` set too high. */
1074
665
  const MAX_PLATE_BYTES = 2e6;
1075
666
  /**
1076
- * Hard ceiling on the POST body we will buffer. A single posted capture is one
1077
- * view (smaller than the accumulated plate file the `MAX_PLATE_BYTES` cap
1078
- * guards), so this sits above the plate cap with headroom and exists only to
1079
- * stop an oversized or runaway body from being read into memory. Enforced both
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
1080
672
  * up front from `content-length` and while streaming, so a lying or absent
1081
673
  * `content-length` cannot get past it. The dev endpoint is local and
1082
674
  * unauthenticated, so this is the real backstop, not the header.
@@ -1091,29 +683,55 @@ function isJsonContentType(value) {
1091
683
  function isRecord(value) {
1092
684
  return typeof value === "object" && value !== null;
1093
685
  }
1094
- /** Send the merged plate (and its disk coverage) over the HMR event so the
1095
- * client hot-swaps it in place and the HUD updates its band display. */
1096
- function announcePlate(ws, name, plateFile) {
1097
- const merged = plateFile ? mergeViews({
1098
- ...plateFile,
1099
- name
1100
- }) : emptyPlate(name);
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;
706
+ }
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));
1101
719
  ws.send({
1102
720
  type: "custom",
1103
721
  event: "xray:plate",
1104
722
  data: {
1105
723
  name,
1106
- plate: serializePlate(merged),
1107
- breakpoints: plateFile?.breakpoints ?? [],
1108
- spans: (plateFile?.views ?? []).map((view) => ({
1109
- min: view.min,
1110
- max: view.max
1111
- }))
724
+ plate,
725
+ breakpoints,
726
+ spans: spansFromBreakpoints(breakpoints)
1112
727
  }
1113
728
  });
1114
729
  }
1115
730
  /**
1116
- * 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).
1117
735
  *
1118
736
  * @internal
1119
737
  */
@@ -1133,14 +751,12 @@ function readCoverage(dir) {
1133
751
  if (!rel.endsWith(".json")) continue;
1134
752
  const name = rel.slice(0, -5);
1135
753
  if (!isValidPlateName(name)) continue;
1136
- const plateFile = readPlateFile(resolve(dir, rel));
1137
- if (!plateFile) continue;
754
+ const stored = readStoredPlate(resolve(dir, rel));
755
+ if (!stored) continue;
756
+ const breakpoints = stored.breakpoints ?? [];
1138
757
  out[name] = {
1139
- breakpoints: plateFile.breakpoints ?? [],
1140
- spans: plateFile.views.map((view) => ({
1141
- min: view.min,
1142
- max: view.max
1143
- }))
758
+ breakpoints,
759
+ spans: spansFromBreakpoints(breakpoints)
1144
760
  };
1145
761
  }
1146
762
  return out;
@@ -1263,7 +879,12 @@ function handleFixturePost(req, res, fixturePath) {
1263
879
  });
1264
880
  }
1265
881
  /**
1266
- * Fold a posted capture into its plate file and hot-swap the plate in place.
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).
1267
888
  *
1268
889
  * @internal
1269
890
  */
@@ -1303,32 +924,33 @@ function handleCapturePost(req, res, server, platePath) {
1303
924
  req.on("end", () => {
1304
925
  if (aborted) return;
1305
926
  try {
1306
- const payload = parseIncomingCapture(JSON.parse(body));
1307
- if (!payload) {
927
+ const raw = JSON.parse(body);
928
+ const envelope = isRecord(raw) ? raw : void 0;
929
+ const stored = parseStoredPlate(envelope?.plate);
930
+ if (!stored) {
1308
931
  res.statusCode = 400;
1309
932
  res.end("invalid capture payload");
1310
933
  return;
1311
934
  }
1312
- reportServerDiagnostics(server, payload.name, payload.capture.diagnostics);
1313
- const file = platePath(payload.name);
1314
- const { plateFile: existing, invalid } = loadPlateFile(file);
1315
- if (invalid) reportServerDiagnostics(server, payload.name, [makeDiagnostic("invalid-plate-file", { detail: `${payload.name}.json` })]);
1316
- const next = addCapture(existing, payload.name, payload.capture, payload.breakpoints ?? []);
1317
- 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`;
1318
940
  if (existing !== null && `${JSON.stringify(existing, null, 1)}\n` === serialized) {
1319
941
  res.statusCode = 204;
1320
942
  res.end();
1321
943
  return;
1322
944
  }
1323
945
  if (serialized.length > MAX_PLATE_BYTES) {
1324
- 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.");
1325
947
  res.statusCode = 413;
1326
948
  res.end("plate too large");
1327
949
  return;
1328
950
  }
1329
951
  mkdirSync(dirname(file), { recursive: true });
1330
952
  writeFileSync(file, serialized);
1331
- announcePlate(server.ws, payload.name, next);
953
+ announcePlate(server.ws, stored.name, stored);
1332
954
  res.statusCode = 204;
1333
955
  res.end();
1334
956
  } catch {
@@ -1337,55 +959,64 @@ function handleCapturePost(req, res, server, platePath) {
1337
959
  }
1338
960
  });
1339
961
  }
1340
- function readPlateFile(file) {
1341
- return loadPlateFile(file).plateFile;
962
+ /**
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.
969
+ */
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;
1342
983
  }
1343
984
  /**
1344
- * Read and validate a committed plate file, distinguishing "no file" from
1345
- * "file present but unreadable". `invalid` is true ONLY when the file exists
1346
- * but is corrupt JSON, the wrong shape, or a version mismatch — a missing file
1347
- * is the normal pre-capture state and is not flagged. Callers use `invalid` to
1348
- * warn (same diagnostics vocabulary) that an existing plate was ignored.
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.
1349
990
  */
1350
- function loadPlateFile(file) {
991
+ function loadStoredPlate(file) {
1351
992
  let raw;
1352
993
  try {
1353
994
  raw = readFileSync(file, "utf8");
1354
995
  } catch {
1355
996
  return {
1356
- plateFile: null,
997
+ stored: null,
1357
998
  invalid: false
1358
999
  };
1359
1000
  }
1360
1001
  try {
1361
- const plateFile = parsePlateFile(JSON.parse(raw));
1362
- if (!plateFile) return {
1363
- plateFile: null,
1002
+ const stored = parseStoredPlate(JSON.parse(raw));
1003
+ if (!stored) return {
1004
+ stored: null,
1364
1005
  invalid: true
1365
1006
  };
1366
1007
  return {
1367
- plateFile,
1008
+ stored,
1368
1009
  invalid: false
1369
1010
  };
1370
1011
  } catch {
1371
1012
  return {
1372
- plateFile: null,
1013
+ stored: null,
1373
1014
  invalid: true
1374
1015
  };
1375
1016
  }
1376
1017
  }
1377
- /**
1378
- * Read a plate file and merge its views down to the plate the import
1379
- * resolves to. Missing, unreadable, or version-mismatched files resolve to an
1380
- * empty plate, so imports never break.
1381
- */
1382
- function readPlate(file, name) {
1383
- const plateFile = readPlateFile(file);
1384
- if (!plateFile) return emptyPlate(name);
1385
- return mergeViews({
1386
- ...plateFile,
1387
- name
1388
- });
1018
+ function readStoredPlate(file) {
1019
+ return loadStoredPlate(file).stored;
1389
1020
  }
1390
1021
  /**
1391
1022
  * Render a plate as the virtual module's source. Each stitch (`ref` node) becomes
@@ -1396,13 +1027,21 @@ function readPlate(file, name) {
1396
1027
  * import itself); an unknown child resolves to its own empty plate, never a
1397
1028
  * build break.
1398
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
+ *
1399
1035
  * @internal
1400
1036
  */
1401
- function renderPlateModule(merged) {
1402
- const refs = collectRefs(merged.tree).filter((name) => name !== merged.name && isValidPlateName(name));
1403
- 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
+ }));
1404
1043
  if (refs.length === 0) return `export default ${json}`;
1405
- 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(", ")} } })`;
1406
1045
  }
1407
1046
  //#endregion
1408
- export { addCapture, collectRefs, handleCapturePost, handleFixturePost, isValidPlateName, mergeViews, readCoverage, readFixtures, renderPlateModule, xrayVitePlugin };
1047
+ export { collectRefs, handleCapturePost, handleFixturePost, isValidPlateName, readCoverage, readFixtures, renderPlateModule, xrayVitePlugin };