@hueest/xray 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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 RenderNode, c as emptyPlate, i as PlateNode, n as MergedPlate, r as Plate, s as ViewCapture, t as LeafKind } from "./plate-BIr62u7l.js";
2
+ import { a as XrayCaptureWalker, i as WalkerDecision, n as CaptureTooLargeError, o as defineXrayCaptureWalker, r as CaptureWalkerContext, t as CaptureOptions } from "./serialize-Cs7hUxmK.js";
3
3
 
4
4
  //#region src/chunk.d.ts
5
5
  /**
@@ -9,36 +9,30 @@ import { CaptureOptions, CaptureRegimeOptions, CaptureTooLargeError, capture, ca
9
9
  * chunks and renders the fallback.
10
10
  */
11
11
  declare function serializePlate(merged: MergedPlate): Plate;
12
- //#endregion
13
- //#region src/merge.d.ts
14
12
  /**
15
- * Turn the per-view captures in a plate file into the one plate a Skeleton
16
- * renders.
17
- *
18
- * Each view is kept whole its own subtree, its own ids, its own rules —
19
- * and shown only across the viewport range it owns, via `@media display:none`
20
- * gates. We do NOT merge the views' trees into one. An earlier design (ADR
21
- * 0004) did, sharing common structure and forking the rest, but aligning two
22
- * genuinely-different DOM trees (mobile stacked vs desktop two-column) without
23
- * stable keys proved unreliable: every heuristic mis-paired some nodes and
24
- * remapped one view's rules onto another's elements, corrupting both. A lone
25
- * capture is pixel-faithful; keeping each view lone preserves that. The cost
26
- * is a larger plate (roughly the sum of the views) — bounded by the plate
27
- * size cap — in exchange for correctness.
28
- *
29
- * Pure data-in/data-out — runs in node (plugin load) and in tests.
13
+ * Distinct plate names referenced by the tree's stitches, in first-seen order.
14
+ * Walks a `RenderNode` tree (the stored, post-classify shape), so it drives the
15
+ * gen-side stitch import graph straight off a `StoredPlate.tree`
16
+ * (`renderPlateModule`, index.ts). Relocated here from the now-deleted `merge.ts`
17
+ * in the ADR 0022 cutover — it is the one node-side primitive that survived the
18
+ * `mergeViews`/`addCapture` removal (the multi-View merge/gate now lives in the
19
+ * in-browser `serializePlateIR`, project.ts).
30
20
  */
31
- declare function mergeViews(file: PlateFile): MergedPlate;
32
- /** Distinct plate names referenced by the tree's stitches, in first-seen order. */
33
21
  declare function collectRefs(tree: RenderNode | null): string[];
34
- /** Fold a fresh capture into the plate file: union breakpoints, one capture per view. */
35
- declare function addCapture(file: PlateFile | null, name: string, capture: ViewCapture, breakpoints: readonly number[]): PlateFile;
36
22
  //#endregion
37
23
  //#region src/core.d.ts
38
24
  /**
39
- * Capture a live DOM subtree straight to a renderable Plate `capture` followed
40
- * by `serializePlate` in one call. `roots` are the top-level elements to capture
41
- * (a component's rendered roots). Pass the result to a `<Skeleton plate>`.
25
+ * Capture a live DOM subtree straight to a renderable Plate. Runs the same
26
+ * uniform pipeline the dev client does for a single View measure (`captureView`),
27
+ * reduce (`runPlatePasses`), project (`serializePlateIR` the structured
28
+ * `StoredPlate`), then lower to chunks (`serializePlate`) — in one call. `roots`
29
+ * are the top-level elements to capture (a component's rendered roots). Pass the
30
+ * result to a `<Skeleton plate>`.
31
+ *
32
+ * `options.walker` is the public programmable capture walker (ADR 0018, Q2 —
33
+ * yes, public): the same `XrayCaptureWalker` the `<Skeleton captureWalker>` prop
34
+ * threads through in dev, available here for driving capture directly. It is
35
+ * capture-only and never persisted into the returned Plate.
42
36
  */
43
37
  declare function captureElement(roots: readonly Element[], options: CaptureOptions): Plate;
44
38
  /**
@@ -52,4 +46,4 @@ declare function captureElement(roots: readonly Element[], options: CaptureOptio
52
46
  */
53
47
  declare function renderPlateHtml(plate: Plate): string;
54
48
  //#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 };
49
+ export { type CaptureOptions, CaptureTooLargeError, type CaptureWalkerContext, type LeafKind, type MergedPlate, type Plate, type PlateNode, type ViewCapture, type WalkerDecision, type XrayCaptureWalker, captureElement, collectRefs, defineXrayCaptureWalker, emptyPlate, renderPlateHtml, serializePlate };
package/dist/core.js CHANGED
@@ -1,214 +1,28 @@
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";
4
- //#region src/chunk.ts
5
- /**
6
- * Serialize a merged plate's RenderNode tree into the shipped `Plate` (ADR
7
- * 0008). This is the framework-neutral half of rendering: a skeleton is static,
8
- * text-free DOM, so the structure is flattened to HTML strings here, at build,
9
- * once — instead of every adapter walking a tree on the latency-critical first
10
- * paint. The adapter only injects the strings and mounts a child plate at each
11
- * stitch (see the React adapters). No escaping risk: bones carry only the
12
- * content-addressed class tokens (ADR 0007), never user content.
13
- */
14
- function nodeClass(node) {
15
- return NODE_CLASS + (node.leaf ? ` ${LEAF_CLASS} ${LEAF_CLASS}-${node.leaf}` : "") + (node.cls ? ` ${node.cls}` : "");
16
- }
17
- /**
18
- * Whether this subtree holds a stitch (`ref`) anywhere — a stitched subtree
19
- * can't collapse to a flat string, since a `ref` resolves to a child plate at
20
- * render time and must stay a mount point.
21
- */
22
- function hasStitch(node) {
23
- return node.ref !== void 0 || (node.kids?.some(hasStitch) ?? false);
24
- }
25
- /** Serialize a stitch-free subtree to one HTML string (caller guarantees no `ref` below). */
26
- function toHtml(node) {
27
- const kids = node.kids ? node.kids.map(toHtml).join("") : "";
28
- return `<div class="${nodeClass(node)}">${kids}</div>`;
29
- }
30
- /**
31
- * Serialize a node list into chunks: each maximal stitch-free run becomes one
32
- * HTML `string`; a `ref` node becomes `{ r }`; a node that isn't itself a stitch
33
- * but holds one deeper stays a real element (`{ c, k }`) and recurses. Mirrors
34
- * the element shape the runtime renderer would otherwise build per node.
35
- */
36
- function toChunks(nodes) {
37
- const out = [];
38
- let buf = "";
39
- const flush = () => {
40
- if (buf) {
41
- out.push(buf);
42
- buf = "";
43
- }
44
- };
45
- for (const node of nodes) {
46
- if (!hasStitch(node)) {
47
- buf += toHtml(node);
48
- continue;
49
- }
50
- flush();
51
- if (node.ref !== void 0) out.push({ r: node.ref });
52
- else out.push({
53
- c: nodeClass(node),
54
- k: toChunks(node.kids ?? [])
55
- });
56
- }
57
- flush();
58
- return out;
59
- }
60
- /**
61
- * Turn a merged plate (RenderNode tree) into the shipped plate (chunks). The
62
- * root's own content classes travel as `rootCls` (the adapter puts them on the
63
- * root element); its children become `chunks`. A capture-less plate has null
64
- * chunks and renders the fallback.
65
- */
66
- function serializePlate(merged) {
67
- const { v, name, tree, css } = merged;
68
- if (!tree) return {
69
- v,
70
- name,
71
- chunks: null,
72
- css
73
- };
74
- const chunks = toChunks(tree.kids ?? []);
75
- return tree.cls ? {
76
- v,
77
- name,
78
- chunks,
79
- rootCls: tree.cls,
80
- css
81
- } : {
82
- v,
83
- name,
84
- chunks,
85
- css
86
- };
87
- }
88
- //#endregion
89
- //#region src/merge.ts
90
- /**
91
- * Turn the per-view captures in a plate file into the one plate a Skeleton
92
- * renders.
93
- *
94
- * Each view is kept whole — its own subtree, its own ids, its own rules —
95
- * and shown only across the viewport range it owns, via `@media display:none`
96
- * gates. We do NOT merge the views' trees into one. An earlier design (ADR
97
- * 0004) did, sharing common structure and forking the rest, but aligning two
98
- * genuinely-different DOM trees (mobile stacked vs desktop two-column) without
99
- * stable keys proved unreliable: every heuristic mis-paired some nodes and
100
- * remapped one view's rules onto another's elements, corrupting both. A lone
101
- * capture is pixel-faithful; keeping each view lone preserves that. The cost
102
- * is a larger plate (roughly the sum of the views) — bounded by the plate
103
- * size cap — in exchange for correctness.
104
- *
105
- * Pure data-in/data-out — runs in node (plugin load) and in tests.
106
- */
107
- function mergeViews(file) {
108
- const views = file.views;
109
- if (views.length === 0) return emptyPlate(file.name);
110
- if (views.length === 1) {
111
- const [only] = views;
112
- if (!only) return emptyPlate(file.name);
113
- const { tree, css } = classify(only.rules, only.tree, file.name);
114
- return {
115
- v: 1,
116
- name: file.name,
117
- tree,
118
- css
119
- };
120
- }
121
- const starts = views.map((r) => regimeFor(r.width, file.breakpoints).min ?? 0);
122
- let nextId = 1;
123
- const rootKids = [];
124
- const rules = [];
125
- views.forEach((view, i) => {
126
- const idMap = /* @__PURE__ */ new Map();
127
- const kids = (view.tree.kids ?? []).map((kid) => cloneWithIds(kid, idMap, () => nextId++));
128
- rootKids.push(...kids);
129
- rules.push(...remapRules(view.rules, idMap));
130
- const lo = i === 0 ? 0 : starts[i] ?? 0;
131
- const hi = i === views.length - 1 ? Number.POSITIVE_INFINITY : starts[i + 1] ?? 0;
132
- for (const kid of kids) {
133
- if (lo > 0) rules.push(gate(kid.id, `(max-width: ${lo - .02}px)`));
134
- if (hi !== Number.POSITIVE_INFINITY) rules.push(gate(kid.id, `(min-width: ${hi}px)`));
135
- }
136
- });
137
- const { tree, css } = classify(rules, {
138
- id: 0,
139
- kids: rootKids
140
- }, file.name);
141
- return {
142
- v: 1,
143
- name: file.name,
144
- tree,
145
- css
146
- };
147
- }
148
- function gate(id, condition) {
149
- return {
150
- ids: [id],
151
- decls: ["display: none !important"],
152
- media: [condition],
153
- spec: SPEC_GATE,
154
- order: id
155
- };
156
- }
157
- /** Deep-clone a captured subtree into fresh ids, recording the mapping. */
158
- function cloneWithIds(node, idMap, allocate) {
159
- const id = allocate();
160
- idMap.set(node.id, id);
161
- const kids = node.kids?.map((kid) => cloneWithIds(kid, idMap, allocate));
162
- return {
163
- id,
164
- ...node.leaf ? { leaf: node.leaf } : {},
165
- ...node.ref !== void 0 ? { ref: node.ref } : {},
166
- ...kids ? { kids } : {}
167
- };
168
- }
169
- /** Distinct plate names referenced by the tree's stitches, in first-seen order. */
170
- function collectRefs(tree) {
171
- const names = [];
172
- const seen = /* @__PURE__ */ new Set();
173
- const visit = (node) => {
174
- if (node.ref !== void 0 && !seen.has(node.ref)) {
175
- seen.add(node.ref);
176
- names.push(node.ref);
177
- }
178
- for (const kid of node.kids ?? []) visit(kid);
179
- };
180
- if (tree) visit(tree);
181
- return names;
182
- }
183
- function remapRules(rules, idMap) {
184
- return rules.map((rule) => ({
185
- ...rule,
186
- ids: rule.ids.map((id) => idMap.get(id) ?? id)
187
- }));
188
- }
189
- /** Fold a fresh capture into the plate file: union breakpoints, one capture per view. */
190
- function addCapture(file, name, capture, breakpoints) {
191
- const allBreakpoints = [...new Set([...file?.breakpoints ?? [], ...breakpoints])].toSorted((a, b) => a - b);
192
- const spanKey = (width) => JSON.stringify(regimeFor(width, allBreakpoints));
193
- const views = [...file?.views ?? [], capture];
194
- const byView = /* @__PURE__ */ new Map();
195
- for (const view of views) byView.set(spanKey(view.width), view);
196
- return {
197
- v: 1,
198
- name,
199
- breakpoints: allBreakpoints,
200
- views: [...byView.values()].toSorted((a, b) => a.width - b.width)
201
- };
202
- }
203
- //#endregion
1
+ import { a as ROOT_ATTR, d as emptyPlate, o as ROOT_CLASS, t as BASE_CSS } from "./plate-BRR6d8Se.js";
2
+ import { a as serializePlate, c as defineXrayCaptureWalker, i as collectRefs, l as runPlatePasses, o as CaptureTooLargeError, s as captureView, t as serializePlateIR } from "./project-DORoPAo7.js";
3
+ import { t as escapeStyleText } from "./css-escape-N7bOusGW.js";
204
4
  //#region src/core.ts
205
5
  /**
206
- * Capture a live DOM subtree straight to a renderable Plate `capture` followed
207
- * by `serializePlate` in one call. `roots` are the top-level elements to capture
208
- * (a component's rendered roots). Pass the result to a `<Skeleton plate>`.
6
+ * Capture a live DOM subtree straight to a renderable Plate. Runs the same
7
+ * uniform pipeline the dev client does for a single View measure (`captureView`),
8
+ * reduce (`runPlatePasses`), project (`serializePlateIR` the structured
9
+ * `StoredPlate`), then lower to chunks (`serializePlate`) — in one call. `roots`
10
+ * are the top-level elements to capture (a component's rendered roots). Pass the
11
+ * result to a `<Skeleton plate>`.
12
+ *
13
+ * `options.walker` is the public programmable capture walker (ADR 0018, Q2 —
14
+ * yes, public): the same `XrayCaptureWalker` the `<Skeleton captureWalker>` prop
15
+ * threads through in dev, available here for driving capture directly. It is
16
+ * capture-only and never persisted into the returned Plate.
209
17
  */
210
18
  function captureElement(roots, options) {
211
- return serializePlate(capture(roots, options));
19
+ const plate = captureView(roots, {
20
+ walker: options.walker,
21
+ captureRootIsBoundary: options.captureRootIsBoundary
22
+ });
23
+ plate.name = options.name;
24
+ runPlatePasses(plate);
25
+ return serializePlate(serializePlateIR(plate));
212
26
  }
213
27
  /**
214
28
  * Render a stitch-free Plate to a self-contained HTML string — the
@@ -222,9 +36,24 @@ function captureElement(roots, options) {
222
36
  function renderPlateHtml(plate) {
223
37
  const chunks = plate.chunks;
224
38
  if (!chunks) return "";
225
- 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
39
  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>`;
40
+ return `<div ${ROOT_ATTR}="${plate.name}" class="${className}" aria-hidden="true" aria-busy="true"><style>${BASE_CSS}\n${escapeStyleText(plate.css)}</style>${chunksToHtml(chunks)}</div>`;
41
+ }
42
+ /**
43
+ * Flatten chunks to one HTML string. A `string` concatenates; a template chunk
44
+ * (`{ t, n }`) expands to its cell repeated `n` times (the cell is stitch-free by
45
+ * construction); a stitch-free wrapper (`{ c, k }`) recurses inside a real `<div>`.
46
+ * A stitch (`{ r }`) resolves to a child plate at render and needs a framework
47
+ * adapter to mount, so a stitched plate throws here. Chunk HTML is xray-generated
48
+ * and interpolated raw, exactly as the single-string path was before.
49
+ */
50
+ function chunksToHtml(chunks) {
51
+ let html = "";
52
+ for (const chunk of chunks) if (typeof chunk === "string") html += chunk;
53
+ else if ("t" in chunk) html += chunk.t.repeat(chunk.n);
54
+ else if ("r" in chunk) throw new Error("xray: renderPlateHtml renders stitch-free plates only — use a framework adapter for stitched plates");
55
+ else html += `<div class="${chunk.c}">${chunksToHtml(chunk.k)}</div>`;
56
+ return html;
228
57
  }
229
58
  //#endregion
230
- export { CaptureTooLargeError, addCapture, capture, captureElement, captureRegime, collectRefs, emptyPlate, mergeViews, renderPlateHtml, serializePlate };
59
+ export { CaptureTooLargeError, captureElement, collectRefs, defineXrayCaptureWalker, emptyPlate, 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
  }