@hueest/xray 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,9 +1,19 @@
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
6
  /** Marks the rendered skeleton root; carries the plate name. Also a dev capture marker. */
6
7
  const ROOT_ATTR = "data-xr-root";
8
+ /**
9
+ * Marks an OUTERMOST skeleton root that is a stitch continuation (ADR 0020): a
10
+ * standalone `<Skeleton>` replacing a stitch a still-showing parent skeleton was
11
+ * just rendering. BASE_CSS reveals such a root instantly (no `--xr-skeleton-delay`,
12
+ * no fade) so the bones do not flash away and back. Never present on a nested
13
+ * stitch (which inherits the parent's reveal and has no `@starting-style` of its
14
+ * own) nor on an ordinary top-level skeleton (which keeps the normal delay).
15
+ */
16
+ const ROOT_INSTANT_ATTR = "data-xr-instant";
7
17
  const SPEC_GATE = 2e7;
8
18
  /** Fixed renderer-owned classes every rendered node carries. */
9
19
  const ROOT_CLASS = "xr-root";
@@ -14,10 +24,17 @@ const LEAF_CLASS = "xr-leaf";
14
24
  opacity property and every leaf reads it. Hundreds of independent infinite
15
25
  opacity animations would pin the compositor and cook the CPU. */
16
26
  @property --xr-o { syntax: "<number>"; inherits: true; initial-value: 1 }
27
+ /* The reveal channel (ADR 0016): a second inherited opacity factor the leaf
28
+ multiplies into --xr-o. Transitioning THIS (not opacity directly) sidesteps
29
+ the animation-vs-transition collision ADR 0012 flagged. initial-value 1 (NOT
30
+ 0) is the degradation floor: without @starting-style the reveal can never be
31
+ driven 0->1, so it must REST at 1 (fully visible). The delay-hide below is
32
+ pure progressive enhancement layered on top. */
33
+ @property --xr-reveal { syntax: "<number>"; inherits: true; initial-value: 1 }
17
34
  .${ROOT_CLASS} {
18
- --xr-bone-highlight: #f4f4f5;
35
+ --xr-bone-highlight-color: #f4f4f5;
19
36
  display: contents;
20
- animation: var(--xr-anim, xr-pulse) var(--xr-duration, 1.2s) ease-in-out infinite alternate;
37
+ animation: var(--xr-bone-animation, xr-pulse) var(--xr-bone-animation-duration, 1.2s) ease-in-out infinite alternate;
21
38
  }
22
39
  /* No pointer-events:none — it blocks inspecting bones in devtools and buys
23
40
  nothing (no real content to click). border-color: a node may capture
@@ -25,34 +42,67 @@ const LEAF_CLASS = "xr-leaf";
25
42
  a captured display:list-item would otherwise paint. */
26
43
  .${NODE_CLASS} { border-color: transparent; list-style: none }
27
44
  .${LEAF_CLASS} {
28
- background: var(--xr-fill, var(--xr-bone, #e4e4e7));
29
- border-radius: var(--xr-radius, 4px);
30
- opacity: var(--xr-o);
45
+ background: var(--xr-bone-fill, var(--xr-bone-color, #e4e4e7));
46
+ border-radius: var(--xr-bone-border-radius, 4px);
47
+ /* Composed opacity (ADR 0016): the pulse channel times the reveal channel.
48
+ Never transition opacity directly (the pulse animation owns it). */
49
+ opacity: calc(var(--xr-o) * var(--xr-reveal));
31
50
  }
32
51
  /* Per-kind defaults: text reads as rounded bars, media as blocks. Re-theme any
33
52
  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)) }
53
+ .${LEAF_CLASS}-text { border-radius: var(--xr-bone-text-border-radius, 999px) }
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) {
@@ -182,7 +232,7 @@ function regimeFor(width, breakpoints) {
182
232
  * Pure data-in/data-out — runs at plugin load (node) and in tests.
183
233
  */
184
234
  function classify(rules, tree, plateName) {
185
- const classBase = `${CLASS_PREFIX}${cssSafe(plateName)}-`;
235
+ const classBase = `${CLASS_PREFIX}${encodePlateName(plateName)}-`;
186
236
  const sorted = rules.toSorted((a, b) => Number(b.layered ?? false) - Number(a.layered ?? false) || a.spec - b.spec || a.order - b.order);
187
237
  const nameForKey = /* @__PURE__ */ new Map();
188
238
  const body = /* @__PURE__ */ new Map();
@@ -256,9 +306,23 @@ function render(node, classes) {
256
306
  }
257
307
  /** Content-class prefix; distinct from the fixed `xr-root/node/leaf` base words. */
258
308
  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, "_");
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, "--");
262
326
  }
263
327
  /**
264
328
  * What the class identity keys on, alongside media + declarations:
@@ -382,7 +446,8 @@ function remapRules(rules, idMap) {
382
446
  function addCapture(file, name, capture, breakpoints) {
383
447
  const allBreakpoints = [...new Set([...file?.breakpoints ?? [], ...breakpoints])].toSorted((a, b) => a - b);
384
448
  const spanKey = (width) => JSON.stringify(regimeFor(width, allBreakpoints));
385
- const views = [...file?.views ?? [], capture];
449
+ const { diagnostics: _diagnostics, ...persisted } = capture;
450
+ const views = [...file?.views ?? [], persisted];
386
451
  const byView = /* @__PURE__ */ new Map();
387
452
  for (const view of views) byView.set(spanKey(view.width), view);
388
453
  return {
@@ -393,6 +458,425 @@ function addCapture(file, name, capture, breakpoints) {
393
458
  };
394
459
  }
395
460
  //#endregion
461
+ //#region src/diagnostics.ts
462
+ /**
463
+ * Build a single diagnostic record with its canonical message, for callers
464
+ * outside the walk that surface a diagnostic directly (e.g. the client mapping
465
+ * `CaptureTooLargeError`, or the server reporting an invalid committed plate).
466
+ * Uses the same message table as the collector so the text is identical
467
+ * everywhere. `detail` must never carry user CSS or captured text.
468
+ */
469
+ function makeDiagnostic(code, options) {
470
+ return {
471
+ code,
472
+ message: DIAGNOSTIC_MESSAGES[code],
473
+ ...options?.count !== void 0 ? { count: options.count } : {},
474
+ ...options?.detail !== void 0 ? { detail: options.detail } : {}
475
+ };
476
+ }
477
+ /**
478
+ * The human-readable message per code, shared verbatim by browser and server so
479
+ * their text cannot drift. Messages describe the omission and its fidelity
480
+ * cost; none echoes user CSS or captured text. `formatDiagnostic` appends the
481
+ * aggregate count and any short detail.
482
+ */
483
+ const DIAGNOSTIC_MESSAGES = {
484
+ "unreadable-stylesheet": "could not read a stylesheet (likely cross-origin); rules from it were not captured",
485
+ "unreadable-import": "could not read an @import (likely cross-origin); its rules were not captured",
486
+ "skipped-dynamic-pseudo": "skipped interaction pseudo-class selectors (:hover, :focus, etc.) — a static skeleton never enters those states",
487
+ "skipped-pseudo-element": "skipped pseudo-element selectors (::before, ::after, etc.) — v1 has no box to map them onto",
488
+ "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",
491
+ "too-large": "capture exceeded the node limit and was skipped; the <Skeleton> likely sits too high in the tree",
492
+ "invalid-plate-file": "a committed plate file could not be read (corrupt JSON, wrong shape, or a version mismatch) and was ignored"
493
+ };
494
+ /**
495
+ * The single shared formatter. Renders one diagnostic to a stable one-line
496
+ * string used IDENTICALLY by the browser console and the Vite server output, so
497
+ * the two can never disagree. Shape: `<message> (<count>)[: <detail>]`. Count is
498
+ * omitted when 1 or absent; detail is appended only when present and is never
499
+ * user content.
500
+ */
501
+ function formatDiagnostic(diagnostic) {
502
+ const count = diagnostic.count !== void 0 && diagnostic.count > 1 ? ` (${diagnostic.count})` : "";
503
+ const detail = diagnostic.detail !== void 0 ? `: ${diagnostic.detail}` : "";
504
+ return `${diagnostic.message}${count}${detail}`;
505
+ }
506
+ /** The grouped-warning header for a plate's diagnostics, shared by browser and server. */
507
+ function diagnosticsHeader(name) {
508
+ return `[xray] "${name}" captured with reduced fidelity:`;
509
+ }
510
+ //#endregion
511
+ //#region src/name.ts
512
+ /**
513
+ * Plate-name validation, shared by the Vite plugin (`index.ts`) and the
514
+ * Node-only POST validator (`validate.ts`).
515
+ *
516
+ * Lives in its own dependency-free module so `validate.ts` can reuse it without
517
+ * importing the plugin entry (which would be a cycle), and so the regex stays
518
+ * the single source of truth for what a plate name may be — the same rule the
519
+ * loader, the file watcher, the coverage reader, the POST endpoint, and the
520
+ * ref-graph emitter all gate on.
521
+ *
522
+ * @module
523
+ */
524
+ /**
525
+ * Names come from import specifiers and POST bodies — keep them inside the plates dir.
526
+ *
527
+ * Each path segment is `\w+(-\w+)*` — word chars with single interior hyphens,
528
+ * never doubled and never at a segment edge — and segments are joined by `/`.
529
+ * The class-prefix encoder (`classify.ts`) renders `/` as `--` to keep nested
530
+ * names readable, and that prefix is the isolation boundary across stitched
531
+ * plates (ADR 0007), so the encoding must be injective. Banning a literal `--`
532
+ * and any edge hyphen guarantees no `-` ever abuts a `/`, so every `--` in the
533
+ * prefix came from exactly one `/` — making distinct names always map to
534
+ * distinct prefixes (else e.g. `a/-b` and `a-/b` would both encode to `a---b`).
535
+ *
536
+ * @internal
537
+ */
538
+ function isValidPlateName(name) {
539
+ return /^\w+(?:-\w+)*(?:\/\w+(?:-\w+)*)*$/.test(name);
540
+ }
541
+ //#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
+ //#region src/validate.ts
626
+ /**
627
+ * Structural validation of a posted capture, plus a single-CSS-declaration
628
+ * validator, for the dev `/__xray` endpoint.
629
+ *
630
+ * NODE-ONLY (see the capture-input-validation plan and ADR 0008). This module
631
+ * imports `valibot`, which must NEVER reach the browser bundles: the dev capture
632
+ * client, the HUD, the React adapters, and the framework-neutral `core` surface
633
+ * all ship to a browser, and none of them needs to validate an inbound payload.
634
+ * Only the Vite plugin (`index.ts`, the package `.` entry, built with
635
+ * `platform: 'node'`) imports this file, which keeps valibot out of every
636
+ * browser-facing dist chunk. Do NOT import this from `plate.ts`, `css.ts`, or
637
+ * anything a render/browser path also imports, and do NOT re-export these
638
+ * helpers from any public subpath — they stay private until a `doctor`-style
639
+ * tool creates a concrete external need (the plan's decisions).
640
+ *
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).
649
+ *
650
+ * @module
651
+ */
652
+ /**
653
+ * Bounds chosen to be far above any real capture yet small enough that a hostile
654
+ * payload cannot exhaust memory or wedge the merge. A `<Skeleton>` set too high
655
+ * is already caught by the per-capture node guard and the serialized-size cap;
656
+ * these are the structural ceilings the schema enforces before either runs.
657
+ */
658
+ 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
+ const MAX_BREAKPOINTS = 256;
664
+ 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
+ /**
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).
677
+ */
678
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f]/u;
679
+ /**
680
+ * Raw-text / markup terminators that must never appear in generated CSS, even
681
+ * when no bare `<`/`>` is present: HTML comment delimiters, a CDATA opener, and
682
+ * a `</style` raw-text terminator. Shared by the declaration and condition
683
+ * validators (case-insensitive).
684
+ */
685
+ 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
+ /** A finite number, no NaN/Infinity (valibot's `number()` already rejects NaN). */
747
+ 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
+ const leafSchema = v.picklist([
750
+ "text",
751
+ "media",
752
+ "box"
753
+ ]);
754
+ /**
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.
760
+ */
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
+ function treeWithinLimits(root) {
768
+ let count = 0;
769
+ const walk = (node, depth) => {
770
+ if (depth > MAX_TREE_DEPTH) return false;
771
+ if (++count > MAX_TREE_NODES) return false;
772
+ if (typeof node !== "object" || node === null) return true;
773
+ const kids = node.kids;
774
+ if (!Array.isArray(kids)) return true;
775
+ for (const kid of kids) if (!walk(kid, depth + 1)) return false;
776
+ return true;
777
+ };
778
+ return walk(root, 0);
779
+ }
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
+ });
813
+ /**
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).
818
+ */
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"));
827
+ /**
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
+ * sorted — the same normalization `addCapture` performs, so an unsorted or
831
+ * duplicated post is accepted and normalized rather than rejected).
832
+ */
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
+ });
838
+ /**
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.
843
+ */
844
+ function parseIncomingCapture(value) {
845
+ const result = v.safeParse(incomingCaptureSchema, value);
846
+ return result.success ? result.output : null;
847
+ }
848
+ /**
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.
859
+ */
860
+ const plateFileSchema = v.object({
861
+ v: v.literal(1),
862
+ name: v.pipe(v.string(), v.check(isValidPlateName, "invalid plate name")),
863
+ 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))
865
+ });
866
+ /**
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`.
874
+ */
875
+ function parsePlateFile(value) {
876
+ const result = v.safeParse(plateFileSchema, value);
877
+ return result.success ? result.output : null;
878
+ }
879
+ //#endregion
396
880
  //#region src/index.ts
397
881
  /**
398
882
  * Vite plugin entry point for xray.
@@ -432,8 +916,11 @@ function xrayVitePlugin(options = {}) {
432
916
  const settleCap = options.settleCap ?? 5e3;
433
917
  const maxNodes = options.maxNodes ?? 4e3;
434
918
  const hud = options.hud ?? false;
919
+ const recordMode = options.fixtures?.record ?? "manual";
920
+ const replayMode = options.fixtures?.replay ?? false;
435
921
  let root = process.cwd();
436
922
  const platePath = (name) => resolve(root, platesDir, `${name}.json`);
923
+ const fixturePath = (name) => resolve(root, platesDir, `${name}.fixture.json`);
437
924
  return [{
438
925
  name: "xray:data",
439
926
  configResolved(config) {
@@ -460,7 +947,7 @@ function xrayVitePlugin(options = {}) {
460
947
  transformIndexHtml: {
461
948
  order: "pre",
462
949
  handler() {
463
- if (!capture && !hud) return void 0;
950
+ if (!capture && !hud && replayMode === false && recordMode === "manual") return;
464
951
  return [{
465
952
  tag: "script",
466
953
  attrs: {
@@ -476,7 +963,7 @@ function xrayVitePlugin(options = {}) {
476
963
  },
477
964
  load(id) {
478
965
  if (id !== RESOLVED_BOOT_ID) return void 0;
479
- return bootstrapCode(delay, settleCap, maxNodes, hud, capture);
966
+ return bootstrapCode(delay, settleCap, maxNodes, hud, capture, recordMode, replayMode);
480
967
  },
481
968
  configureServer(server) {
482
969
  const dir = () => resolve(root, platesDir);
@@ -495,6 +982,15 @@ function xrayVitePlugin(options = {}) {
495
982
  res.end(JSON.stringify(readCoverage(dir())));
496
983
  return;
497
984
  }
985
+ if (req.method === "GET" && req.url === "/fixtures") {
986
+ res.setHeader("content-type", "application/json");
987
+ res.end(JSON.stringify(readFixtures(dir())));
988
+ return;
989
+ }
990
+ if (req.url === "/fixture") {
991
+ handleFixturePost(req, res, fixturePath);
992
+ return;
993
+ }
498
994
  handleCapturePost(req, res, server, platePath);
499
995
  });
500
996
  }
@@ -504,22 +1000,42 @@ function xrayVitePlugin(options = {}) {
504
1000
  * The injected dev module: boot the client, POST captures back, optionally
505
1001
  * mount the HUD. The client installs first so the HUD finds the light box store.
506
1002
  */
507
- function bootstrapCode(delay, settleCap, maxNodes, hud, capture) {
1003
+ function bootstrapCode(delay, settleCap, maxNodes, hud, capture, recordMode, replayMode) {
508
1004
  return [
509
- `import { installXrayClient } from '@hueest/xray/client'`,
510
- ...hud ? [`import { installXrayHud } from '@hueest/xray/hud'`] : [],
1005
+ `import { installXrayClient } from '@hueest/xray/internal/client'`,
1006
+ ...hud ? [`import { installXrayHud } from '@hueest/xray/internal/hud'`] : [],
511
1007
  "installXrayClient({",
512
1008
  ` delay: ${delay},`,
513
1009
  ` settleCap: ${settleCap},`,
514
1010
  ` maxNodes: ${maxNodes},`,
515
1011
  ` capture: ${capture},`,
1012
+ ` record: ${JSON.stringify(recordMode)},`,
1013
+ ` replay: ${JSON.stringify(replayMode)},`,
1014
+ " recordFixtureSink(payload) {",
1015
+ ` return fetch('${ENDPOINT}/fixture', {`,
1016
+ " method: 'POST',",
1017
+ " headers: { 'content-type': 'application/json' },",
1018
+ " body: JSON.stringify(payload),",
1019
+ " }).then((response) => {",
1020
+ " if (!response.ok) throw new Error('xray fixture POST failed: ' + response.status)",
1021
+ " })",
1022
+ " },",
516
1023
  " sink(payload) {",
517
- ` void fetch('${ENDPOINT}', {`,
1024
+ ` return fetch('${ENDPOINT}', {`,
518
1025
  " method: 'POST',",
519
1026
  " headers: { 'content-type': 'application/json' },",
520
1027
  " body: JSON.stringify(payload),",
1028
+ " }).then((response) => {",
1029
+ " if (!response.ok) throw new Error('xray sink POST failed: ' + response.status)",
521
1030
  " })",
522
1031
  " },",
1032
+ " refreshCoverage() {",
1033
+ ` return fetch('${ENDPOINT}/coverage')`,
1034
+ " .then((response) => response.json())",
1035
+ " .then((coverage) => {",
1036
+ " for (const [name, c] of Object.entries(coverage)) globalThis.__XRAY__?.coverage?.set(name, c)",
1037
+ " })",
1038
+ " },",
523
1039
  "})",
524
1040
  ...hud ? ["installXrayHud()"] : [],
525
1041
  `void fetch('${ENDPOINT}/coverage')`,
@@ -528,6 +1044,10 @@ function bootstrapCode(delay, settleCap, maxNodes, hud, capture) {
528
1044
  " for (const [name, c] of Object.entries(coverage)) globalThis.__XRAY__?.coverage?.set(name, c)",
529
1045
  " })",
530
1046
  " .catch(() => {})",
1047
+ `void fetch('${ENDPOINT}/fixtures')`,
1048
+ " .then((response) => response.json())",
1049
+ " .then((fixtures) => { globalThis.__XRAY__?.fixtures?.seed?.(fixtures) })",
1050
+ " .catch(() => {})",
531
1051
  "if (import.meta.hot) {",
532
1052
  " import.meta.hot.on('xray:plate', (data) => {",
533
1053
  " globalThis.__XRAY__?.updatePlate?.(data.name, data.plate)",
@@ -537,35 +1057,40 @@ function bootstrapCode(delay, settleCap, maxNodes, hud, capture) {
537
1057
  ""
538
1058
  ].join("\n");
539
1059
  }
1060
+ /**
1061
+ * Print a plate's diagnostics to the Vite server output using the SAME grouped
1062
+ * header + shared formatter the browser console uses (diagnostics.ts), so the
1063
+ * two can never drift. Prefers Vite's logger (it preserves the formatting);
1064
+ * falls back to `console.warn`. No-op when there are no diagnostics.
1065
+ */
1066
+ function reportServerDiagnostics(server, name, diagnostics) {
1067
+ if (!diagnostics || diagnostics.length === 0) return;
1068
+ const text = [diagnosticsHeader(name), ...diagnostics.map((d) => formatDiagnostic(d))].join("\n");
1069
+ const logger = server.config?.logger;
1070
+ if (logger) logger.warn(text);
1071
+ else console.warn(text);
1072
+ }
540
1073
  /** A committed plate file past this is almost always a `<Skeleton>` set too high. */
541
1074
  const MAX_PLATE_BYTES = 2e6;
1075
+ /**
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
1080
+ * up front from `content-length` and while streaming, so a lying or absent
1081
+ * `content-length` cannot get past it. The dev endpoint is local and
1082
+ * unauthenticated, so this is the real backstop, not the header.
1083
+ */
1084
+ const MAX_BODY_BYTES = 4e6;
1085
+ /** The only content type the capture client posts; anything else is rejected up front. */
1086
+ function isJsonContentType(value) {
1087
+ const header = Array.isArray(value) ? value[0] : value;
1088
+ if (typeof header !== "string") return false;
1089
+ return /^application\/json\b/i.test(header.trim());
1090
+ }
542
1091
  function isRecord(value) {
543
1092
  return typeof value === "object" && value !== null;
544
1093
  }
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));
568
- }
569
1094
  /** Send the merged plate (and its disk coverage) over the HMR event so the
570
1095
  * client hot-swaps it in place and the HUD updates its band display. */
571
1096
  function announcePlate(ws, name, plateFile) {
@@ -620,6 +1145,123 @@ function readCoverage(dir) {
620
1145
  }
621
1146
  return out;
622
1147
  }
1148
+ /** The on-disk Fixture sidecar shape (ADR 0015): version, plate name, and the
1149
+ * OPAQUE devalue string. The node side never parses `data` — it is the browser
1150
+ * dev client's job (it has the live runtime). */
1151
+ const FIXTURE_VERSION = 1;
1152
+ const FIXTURE_SUFFIX = ".fixture.json";
1153
+ /** A recorded Fixture sidecar — `data` is the verbatim devalue string, treated
1154
+ * as opaque text server-side. */
1155
+ function isFixtureFile(value) {
1156
+ return isRecord(value) && typeof value.v === "number" && typeof value.name === "string" && typeof value.data === "string";
1157
+ }
1158
+ /**
1159
+ * All recorded Fixtures as `{ <name>: <devalueString> }`, read from the
1160
+ * `<name>.fixture.json` sidecars (ADR 0015). The map values are the OPAQUE
1161
+ * devalue strings exactly as written — the node side never parses them; the
1162
+ * browser dev client owns the devalue decode. The `.fixture.json` suffix keeps
1163
+ * these distinct from Plate files (`isValidPlateName` rejects the dotted name, so
1164
+ * `readCoverage` never picks them up — Fixtures never leak into Plate coverage).
1165
+ *
1166
+ * @internal
1167
+ */
1168
+ function readFixtures(dir) {
1169
+ const out = {};
1170
+ let entries;
1171
+ try {
1172
+ entries = readdirSync(dir, {
1173
+ recursive: true,
1174
+ encoding: "utf8"
1175
+ });
1176
+ } catch {
1177
+ return out;
1178
+ }
1179
+ for (const entry of entries) {
1180
+ const rel = entry.replaceAll("\\", "/");
1181
+ if (!rel.endsWith(FIXTURE_SUFFIX)) continue;
1182
+ const name = rel.slice(0, -13);
1183
+ if (!isValidPlateName(name)) continue;
1184
+ let raw;
1185
+ try {
1186
+ raw = readFileSync(resolve(dir, rel), "utf8");
1187
+ } catch {
1188
+ continue;
1189
+ }
1190
+ try {
1191
+ const parsed = JSON.parse(raw);
1192
+ if (isFixtureFile(parsed) && parsed.v === FIXTURE_VERSION) out[name] = parsed.data;
1193
+ } catch {}
1194
+ }
1195
+ return out;
1196
+ }
1197
+ /**
1198
+ * Write a recorded Fixture sidecar (ADR 0015). Reuses the capture endpoint's
1199
+ * front-door checks (method, content type, declared/streamed size cap) and the
1200
+ * `isValidPlateName` gate, but treats the devalue `data` payload as OPAQUE TEXT:
1201
+ * it is validated only as a string and written verbatim, never eval'd or parsed
1202
+ * server-side. The sidecar lives NEXT TO the Plate but is never merged into it,
1203
+ * so the persisted Plate stays purely structural.
1204
+ *
1205
+ * @internal
1206
+ */
1207
+ function handleFixturePost(req, res, fixturePath) {
1208
+ if (req.method !== "POST") {
1209
+ res.statusCode = 405;
1210
+ res.end();
1211
+ return;
1212
+ }
1213
+ if (!isJsonContentType(req.headers?.["content-type"])) {
1214
+ res.statusCode = 415;
1215
+ res.end("unsupported media type");
1216
+ return;
1217
+ }
1218
+ const declared = Number(Array.isArray(req.headers?.["content-length"]) ? req.headers["content-length"][0] : req.headers?.["content-length"]);
1219
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
1220
+ res.statusCode = 413;
1221
+ res.end("payload too large");
1222
+ return;
1223
+ }
1224
+ let body = "";
1225
+ let bytes = 0;
1226
+ let aborted = false;
1227
+ req.on("data", (chunk) => {
1228
+ if (aborted) return;
1229
+ const text = typeof chunk === "string" ? chunk : chunk?.toString() ?? "";
1230
+ bytes += Buffer.byteLength(text);
1231
+ if (bytes > MAX_BODY_BYTES) {
1232
+ aborted = true;
1233
+ res.statusCode = 413;
1234
+ res.end("payload too large");
1235
+ req.destroy?.();
1236
+ return;
1237
+ }
1238
+ body += text;
1239
+ });
1240
+ req.on("end", () => {
1241
+ if (aborted) return;
1242
+ try {
1243
+ const raw = JSON.parse(body);
1244
+ if (!isRecord(raw) || typeof raw.name !== "string" || !isValidPlateName(raw.name) || typeof raw.data !== "string") {
1245
+ res.statusCode = 400;
1246
+ res.end("invalid fixture payload");
1247
+ return;
1248
+ }
1249
+ const file = fixturePath(raw.name);
1250
+ const sidecar = {
1251
+ v: FIXTURE_VERSION,
1252
+ name: raw.name,
1253
+ data: raw.data
1254
+ };
1255
+ mkdirSync(dirname(file), { recursive: true });
1256
+ writeFileSync(file, `${JSON.stringify(sidecar, null, 1)}\n`);
1257
+ res.statusCode = 204;
1258
+ res.end();
1259
+ } catch {
1260
+ res.statusCode = 400;
1261
+ res.end("invalid fixture payload");
1262
+ }
1263
+ });
1264
+ }
623
1265
  /**
624
1266
  * Fold a posted capture into its plate file and hot-swap the plate in place.
625
1267
  *
@@ -631,20 +1273,46 @@ function handleCapturePost(req, res, server, platePath) {
631
1273
  res.end();
632
1274
  return;
633
1275
  }
1276
+ if (!isJsonContentType(req.headers?.["content-type"])) {
1277
+ res.statusCode = 415;
1278
+ res.end("unsupported media type");
1279
+ return;
1280
+ }
1281
+ const declared = Number(Array.isArray(req.headers?.["content-length"]) ? req.headers["content-length"][0] : req.headers?.["content-length"]);
1282
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
1283
+ res.statusCode = 413;
1284
+ res.end("payload too large");
1285
+ return;
1286
+ }
634
1287
  let body = "";
1288
+ let bytes = 0;
1289
+ let aborted = false;
635
1290
  req.on("data", (chunk) => {
636
- body += typeof chunk === "string" ? chunk : chunk?.toString() ?? "";
1291
+ if (aborted) return;
1292
+ const text = typeof chunk === "string" ? chunk : chunk?.toString() ?? "";
1293
+ bytes += Buffer.byteLength(text);
1294
+ if (bytes > MAX_BODY_BYTES) {
1295
+ aborted = true;
1296
+ res.statusCode = 413;
1297
+ res.end("payload too large");
1298
+ req.destroy?.();
1299
+ return;
1300
+ }
1301
+ body += text;
637
1302
  });
638
1303
  req.on("end", () => {
1304
+ if (aborted) return;
639
1305
  try {
640
- const payload = JSON.parse(body);
641
- if (!isIncomingCapture(payload) || !isValidPlateName(payload.name)) {
1306
+ const payload = parseIncomingCapture(JSON.parse(body));
1307
+ if (!payload) {
642
1308
  res.statusCode = 400;
643
1309
  res.end("invalid capture payload");
644
1310
  return;
645
1311
  }
1312
+ reportServerDiagnostics(server, payload.name, payload.capture.diagnostics);
646
1313
  const file = platePath(payload.name);
647
- const existing = readPlateFile(file);
1314
+ const { plateFile: existing, invalid } = loadPlateFile(file);
1315
+ if (invalid) reportServerDiagnostics(server, payload.name, [makeDiagnostic("invalid-plate-file", { detail: `${payload.name}.json` })]);
648
1316
  const next = addCapture(existing, payload.name, payload.capture, payload.breakpoints ?? []);
649
1317
  const serialized = `${JSON.stringify(next, null, 1)}\n`;
650
1318
  if (existing !== null && `${JSON.stringify(existing, null, 1)}\n` === serialized) {
@@ -663,33 +1331,47 @@ function handleCapturePost(req, res, server, platePath) {
663
1331
  announcePlate(server.ws, payload.name, next);
664
1332
  res.statusCode = 204;
665
1333
  res.end();
666
- } catch (error) {
1334
+ } catch {
667
1335
  res.statusCode = 400;
668
- res.end(error instanceof Error ? error.message : "invalid capture payload");
1336
+ res.end("invalid capture payload");
669
1337
  }
670
1338
  });
671
1339
  }
1340
+ function readPlateFile(file) {
1341
+ return loadPlateFile(file).plateFile;
1342
+ }
672
1343
  /**
673
- * Names come from import specifiers and POST bodies keep them inside the plates dir.
674
- *
675
- * @internal
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.
676
1349
  */
677
- function isValidPlateName(name) {
678
- return /^[\w-]+(\/[\w-]+)*$/.test(name);
679
- }
680
- function readPlateFile(file) {
1350
+ function loadPlateFile(file) {
681
1351
  let raw;
682
1352
  try {
683
1353
  raw = readFileSync(file, "utf8");
684
1354
  } catch {
685
- return null;
1355
+ return {
1356
+ plateFile: null,
1357
+ invalid: false
1358
+ };
686
1359
  }
687
1360
  try {
688
- const plateFile = JSON.parse(raw);
689
- if (!isPlateFile(plateFile) || plateFile.v !== 1) return null;
690
- return plateFile;
1361
+ const plateFile = parsePlateFile(JSON.parse(raw));
1362
+ if (!plateFile) return {
1363
+ plateFile: null,
1364
+ invalid: true
1365
+ };
1366
+ return {
1367
+ plateFile,
1368
+ invalid: false
1369
+ };
691
1370
  } catch {
692
- return null;
1371
+ return {
1372
+ plateFile: null,
1373
+ invalid: true
1374
+ };
693
1375
  }
694
1376
  }
695
1377
  /**
@@ -723,4 +1405,4 @@ function renderPlateModule(merged) {
723
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(", ")} } })`;
724
1406
  }
725
1407
  //#endregion
726
- export { addCapture, collectRefs, handleCapturePost, isValidPlateName, mergeViews, readCoverage, renderPlateModule, xrayVitePlugin };
1408
+ export { addCapture, collectRefs, handleCapturePost, handleFixturePost, isValidPlateName, mergeViews, readCoverage, readFixtures, renderPlateModule, xrayVitePlugin };