@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/core.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { a as PlateNode, c as emptyPlate, i as PlateFile, n as MergedPlate, o as RenderNode, r as Plate, s as ViewCapture, t as LeafKind } from "./plate-DboxMKg-.js";
2
- import { CaptureOptions, CaptureRegimeOptions, CaptureTooLargeError, capture, captureRegime } from "./serialize.js";
1
+ import { a as PlateNode, c as emptyPlate, i as PlateFile, n as MergedPlate, o as RenderNode, r as Plate, s as ViewCapture, t as LeafKind } from "./plate-Bv6W-GkA.js";
2
+ import { a as WalkerResult, c as captureRegime, i as CaptureWalkerContext, l as defineXrayCaptureWalker, n as CaptureRegimeOptions, o as XrayCaptureWalker, r as CaptureTooLargeError, s as capture, t as CaptureOptions } from "./serialize-B--oY2bV.js";
3
3
 
4
4
  //#region src/chunk.d.ts
5
5
  /**
@@ -39,6 +39,11 @@ declare function addCapture(file: PlateFile | null, name: string, capture: ViewC
39
39
  * Capture a live DOM subtree straight to a renderable Plate — `capture` followed
40
40
  * by `serializePlate` in one call. `roots` are the top-level elements to capture
41
41
  * (a component's rendered roots). Pass the result to a `<Skeleton plate>`.
42
+ *
43
+ * `options.walker` is the public programmable capture walker (ADR 0018, Q2 —
44
+ * yes, public): the same `XrayCaptureWalker` the `<Skeleton captureWalker>` prop
45
+ * threads through in dev, available here for driving capture directly. It is
46
+ * capture-only and never persisted into the returned Plate.
42
47
  */
43
48
  declare function captureElement(roots: readonly Element[], options: CaptureOptions): Plate;
44
49
  /**
@@ -52,4 +57,4 @@ declare function captureElement(roots: readonly Element[], options: CaptureOptio
52
57
  */
53
58
  declare function renderPlateHtml(plate: Plate): string;
54
59
  //#endregion
55
- export { type CaptureOptions, type CaptureRegimeOptions, CaptureTooLargeError, type LeafKind, type MergedPlate, type Plate, type PlateFile, type PlateNode, type ViewCapture, addCapture, capture, captureElement, captureRegime, collectRefs, emptyPlate, mergeViews, renderPlateHtml, serializePlate };
60
+ export { type CaptureOptions, type CaptureRegimeOptions, CaptureTooLargeError, type CaptureWalkerContext, type LeafKind, type MergedPlate, type Plate, type PlateFile, type PlateNode, type ViewCapture, type WalkerResult, type XrayCaptureWalker, addCapture, capture, captureElement, captureRegime, collectRefs, defineXrayCaptureWalker, emptyPlate, mergeViews, renderPlateHtml, serializePlate };
package/dist/core.js CHANGED
@@ -1,6 +1,6 @@
1
- import { a as ROOT_ATTR, i as NODE_CLASS, o as ROOT_CLASS, r as LEAF_CLASS, s as SPEC_GATE, t as BASE_CSS, u as emptyPlate } from "./plate-BuzRMPx4.js";
2
- import { i as captureRegime, o as classify, r as capture, t as CaptureTooLargeError } from "./serialize-Bq6yJnNV.js";
3
- import { n as regimeFor } from "./breakpoints-CBNlh7lQ.js";
1
+ import { a as ROOT_ATTR, c as SPEC_GATE, d as emptyPlate, i as NODE_CLASS, o as ROOT_CLASS, r as LEAF_CLASS, t as BASE_CSS } from "./plate-DoE1HEXp.js";
2
+ import { a as captureRegime, i as capture, n as regimeFor, o as defineXrayCaptureWalker, r as CaptureTooLargeError, u as classify } from "./breakpoints-CMoFUUOv.js";
3
+ import { t as escapeStyleText } from "./css-escape-N7bOusGW.js";
4
4
  //#region src/chunk.ts
5
5
  /**
6
6
  * Serialize a merged plate's RenderNode tree into the shipped `Plate` (ADR
@@ -190,7 +190,8 @@ function remapRules(rules, idMap) {
190
190
  function addCapture(file, name, capture, breakpoints) {
191
191
  const allBreakpoints = [...new Set([...file?.breakpoints ?? [], ...breakpoints])].toSorted((a, b) => a - b);
192
192
  const spanKey = (width) => JSON.stringify(regimeFor(width, allBreakpoints));
193
- const views = [...file?.views ?? [], capture];
193
+ const { diagnostics: _diagnostics, ...persisted } = capture;
194
+ const views = [...file?.views ?? [], persisted];
194
195
  const byView = /* @__PURE__ */ new Map();
195
196
  for (const view of views) byView.set(spanKey(view.width), view);
196
197
  return {
@@ -206,6 +207,11 @@ function addCapture(file, name, capture, breakpoints) {
206
207
  * Capture a live DOM subtree straight to a renderable Plate — `capture` followed
207
208
  * by `serializePlate` in one call. `roots` are the top-level elements to capture
208
209
  * (a component's rendered roots). Pass the result to a `<Skeleton plate>`.
210
+ *
211
+ * `options.walker` is the public programmable capture walker (ADR 0018, Q2 —
212
+ * yes, public): the same `XrayCaptureWalker` the `<Skeleton captureWalker>` prop
213
+ * threads through in dev, available here for driving capture directly. It is
214
+ * capture-only and never persisted into the returned Plate.
209
215
  */
210
216
  function captureElement(roots, options) {
211
217
  return serializePlate(capture(roots, options));
@@ -224,7 +230,7 @@ function renderPlateHtml(plate) {
224
230
  if (!chunks) return "";
225
231
  if (chunks.length !== 1 || typeof chunks[0] !== "string") throw new Error("xray: renderPlateHtml renders stitch-free plates only — use a framework adapter for stitched plates");
226
232
  const className = plate.rootCls ? `${ROOT_CLASS} ${plate.rootCls}` : ROOT_CLASS;
227
- return `<div ${ROOT_ATTR}="${plate.name}" class="${className}" aria-hidden="true" aria-busy="true"><style>${BASE_CSS}\n${plate.css}</style>${chunks[0]}</div>`;
233
+ return `<div ${ROOT_ATTR}="${plate.name}" class="${className}" aria-hidden="true" aria-busy="true"><style>${BASE_CSS}\n${escapeStyleText(plate.css)}</style>${chunks[0]}</div>`;
228
234
  }
229
235
  //#endregion
230
- export { CaptureTooLargeError, addCapture, capture, captureElement, captureRegime, collectRefs, emptyPlate, mergeViews, renderPlateHtml, serializePlate };
236
+ export { CaptureTooLargeError, addCapture, capture, captureElement, captureRegime, collectRefs, defineXrayCaptureWalker, emptyPlate, mergeViews, renderPlateHtml, serializePlate };
@@ -0,0 +1,52 @@
1
+ //#region src/css-escape.ts
2
+ /**
3
+ * Dependency-free raw-text safety for the one place a Plate's CSS string is
4
+ * interpolated into `<style>…</style>` markup.
5
+ *
6
+ * A plate's `css` is normally produced by the capture path (`filterLayoutDecls`
7
+ * + `classify`), which only ever emits well-formed `selector { prop: value }`
8
+ * rules — never a raw-text terminator. But a committed `plates/<name>.json` is
9
+ * UNTRUSTED at render time (it can be hand-edited or arrive over git, see the
10
+ * capture-input-validation plan's decisions), and a corrupt/malicious `css`
11
+ * could carry a `</style>` or an HTML comment delimiter. `<style>` is a
12
+ * raw-text element: the parser ends the element at the first `</style` it sees
13
+ * and resumes parsing the rest as ordinary HTML, so an unescaped terminator in
14
+ * the CSS body turns the tail of the plate into live markup.
15
+ *
16
+ * The React text-child path (`<style>{css}</style>`) is already safe — React
17
+ * escapes the child — but the two STRING paths interpolate `css` directly into
18
+ * a markup string (`renderPlateHtml` builds an HTML string; `react.core.tsx`
19
+ * feeds one to `dangerouslySetInnerHTML`). Those must run the body through this
20
+ * helper first.
21
+ *
22
+ * This lives in its own dependency-free module ON PURPOSE: it is imported by
23
+ * render/browser paths, so it must NOT pull in the Node-only validation code
24
+ * (valibot). It neutralizes only the sequences that can escape a raw-text
25
+ * `<style>` context — a `<` that begins `</style`, and the HTML comment / CDATA
26
+ * delimiters the tokenizer treats specially inside raw text — by inserting a
27
+ * CSS-comment break (`/​**​/`) that the CSS parser ignores. It does not, and is
28
+ * not meant to, sanitize CSS semantically; POST-time validation
29
+ * (`validate.ts`) is what rejects junk declarations.
30
+ *
31
+ * @module
32
+ */
33
+ /**
34
+ * Make a CSS body safe to interpolate into a `<style>…</style>` STRING. Breaks
35
+ * up the only sequences the HTML tokenizer acts on inside a raw-text `<style>`
36
+ * element, so none can terminate the element early or open a comment/CDATA run:
37
+ *
38
+ * - `</style` (the end-tag open, case-insensitive) — inserts the break between
39
+ * `<` and `/style` so the parser never matches the end tag.
40
+ * - `<!--`, `-->`, `<![CDATA[` — the comment/CDATA delimiters; split so neither
41
+ * a comment nor a CDATA section can open.
42
+ *
43
+ * The inserted `/​**​/` is an empty CSS comment: inert to the CSS parser (it
44
+ * cannot appear mid-token in a value we emit, and a stray one only ends a
45
+ * token early), so a well-formed plate is rendered byte-for-byte equivalently
46
+ * while a corrupt one can no longer break out of the style element.
47
+ */
48
+ function escapeStyleText(css) {
49
+ return css.replace(/<(\/style)/gi, "</**/$1").replace(/<!--/g, "<!/**/--").replace(/-->/g, "--/**/>").replace(/<!\[CDATA\[/g, "<![/**/CDATA[");
50
+ }
51
+ //#endregion
52
+ export { escapeStyleText as t };
package/dist/hud.js CHANGED
@@ -52,6 +52,7 @@ function installXrayHud() {
52
52
  .bands { text-align: right }
53
53
  .band.covered { color: #4ade80 }
54
54
  .band.missing { color: #52525b }
55
+ .fixture { color: #fbbf24; margin-left: 6px }
55
56
  .toggle { display: flex; gap: 6px; align-items: center; cursor: pointer; user-select: none }
56
57
  .toggle input { accent-color: #4ade80; margin: 0 }
57
58
  .action {
@@ -70,6 +71,8 @@ function installXrayHud() {
70
71
  let extensionPresent = false;
71
72
  let sweeping = false;
72
73
  let sweepStatus = "";
74
+ let recording = false;
75
+ let recordStatus = "";
73
76
  const render = () => {
74
77
  panel.textContent = "";
75
78
  const coverage = globalThis.__XRAY__?.coverage?.get();
@@ -82,6 +85,26 @@ function installXrayHud() {
82
85
  if (capture) panel.append(toggleRow(capture, "Capture"));
83
86
  const lightbox = globalThis.__XRAY__?.lightbox;
84
87
  if (lightbox) panel.append(toggleRow(lightbox, "Light Box"));
88
+ const replay = globalThis.__XRAY__?.replay;
89
+ if (replay) panel.append(toggleRow(replay, "Replay"));
90
+ const fixtures = globalThis.__XRAY__?.fixtures;
91
+ if (fixtures) {
92
+ const button = document.createElement("button");
93
+ button.className = "action";
94
+ button.type = "button";
95
+ button.disabled = recording;
96
+ button.textContent = recording ? "Recording fixture…" : "Record fixture";
97
+ button.addEventListener("click", () => {
98
+ recordFixtures(fixtures);
99
+ });
100
+ panel.append(button);
101
+ if (recordStatus) {
102
+ const status = document.createElement("div");
103
+ status.className = "status";
104
+ status.textContent = recordStatus;
105
+ panel.append(status);
106
+ }
107
+ }
85
108
  if (globalThis.__XRAY__?.captureAllViews) if (extensionPresent) {
86
109
  const button = document.createElement("button");
87
110
  button.className = "action";
@@ -109,6 +132,12 @@ function installXrayHud() {
109
132
  row.className = "plate";
110
133
  const label = document.createElement("span");
111
134
  label.textContent = name;
135
+ if (globalThis.__XRAY__?.fixtures?.has(name)) {
136
+ const tag = document.createElement("span");
137
+ tag.className = "fixture";
138
+ tag.textContent = "◆ fixture";
139
+ label.append(tag);
140
+ }
112
141
  const bands = document.createElement("span");
113
142
  bands.className = "bands";
114
143
  for (const band of bandsOf(plateCoverage)) {
@@ -136,6 +165,21 @@ function installXrayHud() {
136
165
  render();
137
166
  }
138
167
  }
168
+ async function recordFixtures(fixtures) {
169
+ if (recording || !fixtures) return;
170
+ recording = true;
171
+ recordStatus = "";
172
+ render();
173
+ try {
174
+ await fixtures.recordAll();
175
+ recordStatus = "Fixture recorded.";
176
+ } catch (error) {
177
+ recordStatus = `Failed: ${error instanceof Error ? error.message : String(error)}`;
178
+ } finally {
179
+ recording = false;
180
+ render();
181
+ }
182
+ }
139
183
  const onHudMessage = (event) => {
140
184
  if (event.source !== window) return;
141
185
  if (isExtensionHello(event.data) && !extensionPresent) {
@@ -149,6 +193,7 @@ function installXrayHud() {
149
193
  kind: "whois"
150
194
  }, "*");
151
195
  const unsubscribeCoverage = globalThis.__XRAY__?.coverage?.subscribe(render);
196
+ const unsubscribeFixtures = globalThis.__XRAY__?.fixtures?.subscribe(render);
152
197
  render();
153
198
  const mount = () => {
154
199
  if (document.body) document.body.append(host);
@@ -158,6 +203,7 @@ function installXrayHud() {
158
203
  return { dispose() {
159
204
  window.removeEventListener("message", onHudMessage);
160
205
  unsubscribeCoverage?.();
206
+ unsubscribeFixtures?.();
161
207
  host.remove();
162
208
  } };
163
209
  }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,53 @@
1
1
  import { Plugin } from "vite";
2
2
 
3
+ //#region src/diagnostics.d.ts
4
+ /**
5
+ * Dev-only capture diagnostics: a small, serializable record of why a Plate may
6
+ * be incomplete or lower-fidelity, plus the one shared formatter both the
7
+ * browser console (`client.ts`) and the Vite server output (`index.ts`) print.
8
+ *
9
+ * Motivation: the serializer silently swallows a handful of capture omissions
10
+ * (an unreadable cross-origin stylesheet, skipped pseudo-elements, a collapsed
11
+ * list run, an over-the-limit subtree). Early adopters asking "why does this
12
+ * skeleton look wrong?" had to read the serializer source to find out. These
13
+ * diagnostics surface those omissions as data the capture process returns, so a
14
+ * caller can log them — without the core performing any console side effects.
15
+ *
16
+ * Strictly DEV-ONLY (see the plan's decisions and ADR 0008): this type is NOT a
17
+ * public package export and MUST NEVER reach the persisted Plate JSON, the
18
+ * `MergedPlate`, or the production adapter. It rides on the TRANSIENT
19
+ * `ViewCapture.diagnostics` field only, which `captureRegime` populates and the
20
+ * dev client strips before posting (so committed `plates/<name>.json` stays
21
+ * byte-identical to a capture that produced no diagnostics).
22
+ *
23
+ * Centralized on purpose: a follow-up plan (capture-input-validation) reuses
24
+ * this same messaging for invalid posted/committed plates, so the code table
25
+ * and formatter are kept reusable. Browser and server share `formatDiagnostic`
26
+ * so their warning text cannot drift.
27
+ *
28
+ * @module
29
+ */
30
+ /**
31
+ * One aggregated capture omission. Serializable on purpose: NO DOM nodes, NO
32
+ * CSS bodies, NO captured text — only a stable `code`, a human message, an
33
+ * optional aggregate `count`, and an optional short `detail` (e.g. a plate name
34
+ * or a node count, never user content). `count` aggregates occurrences: there
35
+ * is at most one record per code per capture, never one per skipped selector or
36
+ * node.
37
+ */
38
+ interface CaptureDiagnostic {
39
+ code: DiagnosticCode;
40
+ message: string;
41
+ count?: number;
42
+ detail?: string;
43
+ }
44
+ /**
45
+ * The closed set of capture-omission codes. Each maps to one silent site in the
46
+ * serializer (or an existing oversized-capture warning folded into the same
47
+ * vocabulary). Kept as a const union so the message table is exhaustive.
48
+ */
49
+ type DiagnosticCode = 'unreadable-stylesheet' | 'unreadable-import' | 'skipped-dynamic-pseudo' | 'skipped-pseudo-element' | 'unsupported-rule' | 'dropped-tag' | 'pruned-run' | 'too-large' | 'invalid-plate-file';
50
+ //#endregion
3
51
  //#region src/plate.d.ts
4
52
  type LeafKind = 'text' | 'media' | 'box';
5
53
  /**
@@ -110,6 +158,17 @@ interface ViewCapture {
110
158
  max?: number;
111
159
  tree: PlateNode;
112
160
  rules: PlateRule[];
161
+ /**
162
+ * TRANSIENT, dev-only capture diagnostics (why this view may be lower
163
+ * fidelity). Populated by `captureRegime` in the browser; the dev client logs
164
+ * it and posts it (so the Vite server can print the same text), then it is
165
+ * STRIPPED at the merge layer (`addCapture`) before persisting. It MUST NEVER
166
+ * be persisted: it is not part of the committed `plates/<name>.json` shape,
167
+ * the `isViewCapture` validator does not require it, and `addCapture` drops it
168
+ * so neither it nor `mergeViews` ever writes it. Absent in production (the
169
+ * prod adapter ships no capture). See diagnostics.ts and ADR 0008.
170
+ */
171
+ diagnostics?: CaptureDiagnostic[];
113
172
  }
114
173
  /** The committed `plates/<name>.json`: per-view captures, merged at load. */
115
174
  interface PlateFile {
@@ -151,6 +210,68 @@ interface Span {
151
210
  min?: number;
152
211
  max?: number;
153
212
  }
213
+ /**
214
+ * Dev-only Fixture store (ADR 0015): stored render-INPUT `data` per Plate,
215
+ * recorded to and replayed from a `plates/<name>.fixture.json` sidecar. Entirely
216
+ * dev-only — devalue and this store never reach the production bundle.
217
+ *
218
+ * Two cooperating halves, both flowing through the render-prop `data` seam (ADR
219
+ * 0014):
220
+ * - The dev adapter (`react.dev.tsx`) calls `register(name, data)` at
221
+ * `loading=false` with the live `data` it is about to render, so the latest
222
+ * recordable input for each mounted Plate is always in scope for Record.
223
+ * - `record(name)` serializes that registered live `data` with devalue and POSTs
224
+ * it to the sidecar (the HUD button and the `first-ready`/`always` modes call
225
+ * it). `has(name)` reports whether a Fixture exists (HUD coverage marker), and
226
+ * `read(name)` returns the parsed Fixture `data` for Replay to substitute in
227
+ * front of the render prop.
228
+ *
229
+ * The store holds DECODED values (the dev client owns the devalue parse, since it
230
+ * has the live runtime); only the on-disk sidecar and the wire carry the opaque
231
+ * devalue string.
232
+ */
233
+ interface FixtureStore {
234
+ /**
235
+ * Record the latest live `data` the dev adapter is about to render for `name`,
236
+ * so Record always has the current recordable input in scope. A `register` with
237
+ * the same value is cheap; the adapter calls it on every ready render. Returns a
238
+ * disposer the adapter runs on unmount so an unmounted Skeleton's `data` never
239
+ * lingers in the store: an unmounted instance must never be recorded or shadow a
240
+ * still-mounted one (ADR 0015). With several Skeletons of the same plate name
241
+ * mounted, the live value is the most-recently-registered still-mounted instance.
242
+ */
243
+ register: (name: string, data: unknown) => () => void;
244
+ /** True once a recorded Fixture exists for `name` (drives the HUD coverage marker). */
245
+ has: (name: string) => boolean;
246
+ /** The parsed Fixture `data` for `name`, or `undefined` when none is recorded. */
247
+ read: (name: string) => {
248
+ data: unknown;
249
+ } | undefined;
250
+ /**
251
+ * Serialize the registered live `data` for `name` with devalue and POST it to
252
+ * the sidecar. Returns a promise that resolves once the write is
253
+ * server-confirmed (so the HUD can surface a failed Record). Throws loudly when
254
+ * the live `data` is unrecordable (devalue's contract) or no live `data` was
255
+ * registered (a plain-children `<Skeleton>` has none).
256
+ */
257
+ record: (name: string) => Promise<void>;
258
+ /**
259
+ * Record every Skeleton that currently has live `data` registered (the HUD's
260
+ * single "Record fixture" button — one click captures the inputs of all mounted
261
+ * render-prop Skeletons). Resolves once all writes are server-confirmed; a
262
+ * rejected write surfaces to the HUD.
263
+ */
264
+ recordAll: () => Promise<void>;
265
+ /**
266
+ * Seed the store from the on-disk sidecars on boot: a `{ <name>: devalueString }`
267
+ * map (the opaque strings the server serves). The client decodes each with
268
+ * devalue. Existing entries are not clobbered, so a Fixture recorded this
269
+ * session survives a re-seed.
270
+ */
271
+ seed: (encoded: Record<string, string>) => void;
272
+ /** React subscription so the HUD re-renders when a Fixture is recorded or seeded. */
273
+ subscribe: (onChange: () => void) => () => void;
274
+ }
154
275
  /** A plate's detected width thresholds and the spans actually captured, from disk. */
155
276
  interface PlateCoverage {
156
277
  breakpoints: number[];
@@ -175,6 +296,8 @@ interface CoverageStore {
175
296
  interface CaptureHook {
176
297
  captured: (name: string, el: Element, opts?: {
177
298
  delay?: number;
299
+ walker?: unknown;
300
+ mode?: 'auto' | 'manual';
178
301
  }) => (() => void) | undefined;
179
302
  /** Light Box toggle: force every `<Skeleton>` to its skeleton, read by `<Skeleton>`. */
180
303
  lightbox?: ToggleStore;
@@ -188,6 +311,25 @@ interface CaptureHook {
188
311
  captureAllViews?: () => Promise<void>;
189
312
  /** Dev-only per-plate breakpoints + captured spans, from disk; fed by the bootstrap. */
190
313
  coverage?: CoverageStore;
314
+ /**
315
+ * Replay toggle (ADR 0015): when active, the dev adapter substitutes a recorded
316
+ * Fixture's `data` for the live `data` at `loading=false`. The boolean is the
317
+ * coarse on/off the HUD owns per tab; the per-mode policy (`prefer` vs
318
+ * `missing-only`) is carried alongside in `replayMode`.
319
+ */
320
+ replay?: ToggleStore;
321
+ /** Dev-only Replay policy: how a Fixture substitutes for live `data` (ADR 0015). */
322
+ replayMode?: 'prefer' | 'missing-only';
323
+ /**
324
+ * Dev-only Record policy (ADR 0015): when the dev adapter records the live
325
+ * `data` it is about to render. `'manual'` (the default) records nothing
326
+ * automatically — only the HUD button does; `'first-ready'` records once when a
327
+ * Skeleton goes ready and has no Fixture yet; `'always'` records on every ready
328
+ * render.
329
+ */
330
+ recordMode?: 'manual' | 'first-ready' | 'always';
331
+ /** Dev-only Fixture record/replay store (ADR 0015); never present in production. */
332
+ fixtures?: FixtureStore;
191
333
  }
192
334
  declare global {
193
335
  var __XRAY__: CaptureHook | undefined;
@@ -217,6 +359,35 @@ declare function collectRefs(tree: RenderNode | null): string[];
217
359
  /** Fold a fresh capture into the plate file: union breakpoints, one capture per view. */
218
360
  declare function addCapture(file: PlateFile | null, name: string, capture: ViewCapture, breakpoints: readonly number[]): PlateFile;
219
361
  //#endregion
362
+ //#region src/name.d.ts
363
+ /**
364
+ * Plate-name validation, shared by the Vite plugin (`index.ts`) and the
365
+ * Node-only POST validator (`validate.ts`).
366
+ *
367
+ * Lives in its own dependency-free module so `validate.ts` can reuse it without
368
+ * importing the plugin entry (which would be a cycle), and so the regex stays
369
+ * the single source of truth for what a plate name may be — the same rule the
370
+ * loader, the file watcher, the coverage reader, the POST endpoint, and the
371
+ * ref-graph emitter all gate on.
372
+ *
373
+ * @module
374
+ */
375
+ /**
376
+ * Names come from import specifiers and POST bodies — keep them inside the plates dir.
377
+ *
378
+ * Each path segment is `\w+(-\w+)*` — word chars with single interior hyphens,
379
+ * never doubled and never at a segment edge — and segments are joined by `/`.
380
+ * The class-prefix encoder (`classify.ts`) renders `/` as `--` to keep nested
381
+ * names readable, and that prefix is the isolation boundary across stitched
382
+ * plates (ADR 0007), so the encoding must be injective. Banning a literal `--`
383
+ * and any edge hyphen guarantees no `-` ever abuts a `/`, so every `--` in the
384
+ * prefix came from exactly one `/` — making distinct names always map to
385
+ * distinct prefixes (else e.g. `a/-b` and `a-/b` would both encode to `a---b`).
386
+ *
387
+ * @internal
388
+ */
389
+ declare function isValidPlateName(name: string): boolean;
390
+ //#endregion
220
391
  //#region src/index.d.ts
221
392
  /**
222
393
  * Options for `xrayVitePlugin()`.
@@ -243,6 +414,29 @@ interface XrayOptions {
243
414
  settleCap?: number;
244
415
  /** Refuse captures larger than this many nodes (Skeleton too high in the tree). Default: `4000`. */
245
416
  maxNodes?: number;
417
+ /**
418
+ * Dev-only Fixtures (ADR 0015): record a `<Skeleton>`'s render-input `data` to a
419
+ * `plates/<name>.fixture.json` sidecar and replay it in place of live data so
420
+ * capture is deterministic. Boot state only — the HUD is per-tab authority, just
421
+ * like `capture`. Entirely dev-only; nothing here ships in production.
422
+ */
423
+ fixtures?: {
424
+ /**
425
+ * When to record the live `data`: `'manual'` (HUD button only — the default,
426
+ * so live app data is never silently committed), `'first-ready'` (record once
427
+ * the first time a Skeleton goes ready with no Fixture yet), or `'always'`
428
+ * (record on every ready render). Default: `'manual'`.
429
+ */
430
+ record?: 'manual' | 'first-ready' | 'always';
431
+ /**
432
+ * Whether to replay a recorded Fixture in place of live `data`: `false` (off,
433
+ * the default), `'prefer'` (always use the Fixture when one exists), or
434
+ * `'missing-only'` (use the Fixture only when live `data` is nullish —
435
+ * `missing ≡ nullish`, so an empty array/object is present and stays
436
+ * capturable). Default: `false`.
437
+ */
438
+ replay?: false | 'prefer' | 'missing-only';
439
+ };
246
440
  }
247
441
  /**
248
442
  * Create the Vite plugins that resolve committed Plates and, during dev,
@@ -265,7 +459,10 @@ interface XrayOptions {
265
459
  declare function xrayVitePlugin(options?: XrayOptions): Plugin[];
266
460
  interface CaptureRequest {
267
461
  method?: string;
462
+ headers?: Record<string, string | string[] | undefined>;
268
463
  on(event: 'data' | 'end', listener: (chunk?: Buffer | string) => void): unknown;
464
+ /** Stop buffering the request body once the byte cap is exceeded. */
465
+ destroy?: () => void;
269
466
  }
270
467
  interface CaptureResponse {
271
468
  statusCode: number;
@@ -274,8 +471,21 @@ interface CaptureResponse {
274
471
  interface PlateBroadcaster {
275
472
  send(payload: unknown): void;
276
473
  }
474
+ /** The subset of Vite's logger we use; it preserves the formatted string verbatim. */
475
+ interface ServerLogger {
476
+ warn(message: string): void;
477
+ }
277
478
  interface CaptureServer {
278
479
  ws: PlateBroadcaster;
480
+ /**
481
+ * Vite's logger (`server.config.logger`). When present its `warn` prints the
482
+ * diagnostic text to the server output; otherwise we fall back to
483
+ * `console.warn`. Either way the SAME shared formatter (diagnostics.ts) is
484
+ * used, so server output matches the browser console exactly.
485
+ */
486
+ config?: {
487
+ logger?: ServerLogger;
488
+ };
279
489
  }
280
490
  /**
281
491
  * Per-plate breakpoints + captured spans, read from the committed plate files.
@@ -290,17 +500,33 @@ declare function readCoverage(dir: string): Record<string, {
290
500
  }[];
291
501
  }>;
292
502
  /**
293
- * Fold a posted capture into its plate file and hot-swap the plate in place.
503
+ * All recorded Fixtures as `{ <name>: <devalueString> }`, read from the
504
+ * `<name>.fixture.json` sidecars (ADR 0015). The map values are the OPAQUE
505
+ * devalue strings exactly as written — the node side never parses them; the
506
+ * browser dev client owns the devalue decode. The `.fixture.json` suffix keeps
507
+ * these distinct from Plate files (`isValidPlateName` rejects the dotted name, so
508
+ * `readCoverage` never picks them up — Fixtures never leak into Plate coverage).
294
509
  *
295
510
  * @internal
296
511
  */
297
- declare function handleCapturePost(req: CaptureRequest, res: CaptureResponse, server: CaptureServer, platePath: (name: string) => string): void;
512
+ declare function readFixtures(dir: string): Record<string, string>;
298
513
  /**
299
- * Names come from import specifiers and POST bodies — keep them inside the plates dir.
514
+ * Write a recorded Fixture sidecar (ADR 0015). Reuses the capture endpoint's
515
+ * front-door checks (method, content type, declared/streamed size cap) and the
516
+ * `isValidPlateName` gate, but treats the devalue `data` payload as OPAQUE TEXT:
517
+ * it is validated only as a string and written verbatim, never eval'd or parsed
518
+ * server-side. The sidecar lives NEXT TO the Plate but is never merged into it,
519
+ * so the persisted Plate stays purely structural.
300
520
  *
301
521
  * @internal
302
522
  */
303
- declare function isValidPlateName(name: string): boolean;
523
+ declare function handleFixturePost(req: CaptureRequest, res: CaptureResponse, fixturePath: (name: string) => string): void;
524
+ /**
525
+ * Fold a posted capture into its plate file and hot-swap the plate in place.
526
+ *
527
+ * @internal
528
+ */
529
+ declare function handleCapturePost(req: CaptureRequest, res: CaptureResponse, server: CaptureServer, platePath: (name: string) => string): void;
304
530
  /**
305
531
  * Render a plate as the virtual module's source. Each stitch (`ref` node) becomes
306
532
  * a real import of that child's virtual module, wired into a `refs` map on the
@@ -314,4 +540,4 @@ declare function isValidPlateName(name: string): boolean;
314
540
  */
315
541
  declare function renderPlateModule(merged: MergedPlate): string;
316
542
  //#endregion
317
- export { type LeafKind, type Plate, type PlateFile, type PlateNode, type PlateRule, type ViewCapture, XrayOptions, addCapture, collectRefs, handleCapturePost, isValidPlateName, mergeViews, readCoverage, renderPlateModule, xrayVitePlugin };
543
+ export { type LeafKind, type Plate, type PlateFile, type PlateNode, type PlateRule, type ViewCapture, XrayOptions, addCapture, collectRefs, handleCapturePost, handleFixturePost, isValidPlateName, mergeViews, readCoverage, readFixtures, renderPlateModule, xrayVitePlugin };