@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/client.js CHANGED
@@ -1,15 +1,545 @@
1
- import { i as captureRegime, t as CaptureTooLargeError } from "./serialize-Bq6yJnNV.js";
2
- import { n as regimeFor, t as deriveBreakpoints } from "./breakpoints-CBNlh7lQ.js";
1
+ import { a as serializePlate, d as formatDiagnostic, f as makeDiagnostic, l as runPlatePasses, n as deriveBreakpoints, o as CaptureTooLargeError, p as emit, r as regimeFor, s as captureView, t as serializePlateIR, u as diagnosticsHeader } from "./project-DORoPAo7.js";
2
+ //#region ../../node_modules/.pnpm/devalue@5.8.1/node_modules/devalue/src/base64.js
3
+ /** @type {(array_buffer: ArrayBuffer) => string} */
4
+ function encode_native(array_buffer) {
5
+ return new Uint8Array(array_buffer).toBase64();
6
+ }
7
+ /** @type {(base64: string) => ArrayBuffer} */
8
+ function decode_native(base64) {
9
+ return Uint8Array.fromBase64(base64).buffer;
10
+ }
11
+ /** @type {(array_buffer: ArrayBuffer) => string} */
12
+ function encode_buffer(array_buffer) {
13
+ return Buffer.from(array_buffer).toString("base64");
14
+ }
15
+ /** @type {(base64: string) => ArrayBuffer} */
16
+ function decode_buffer(base64) {
17
+ return Uint8Array.from(Buffer.from(base64, "base64")).buffer;
18
+ }
19
+ /** @type {(array_buffer: ArrayBuffer) => string} */
20
+ function encode_legacy(array_buffer) {
21
+ const array = new Uint8Array(array_buffer);
22
+ let binary = "";
23
+ const chunk_size = 32768;
24
+ for (let i = 0; i < array.length; i += chunk_size) {
25
+ const chunk = array.subarray(i, i + chunk_size);
26
+ binary += String.fromCharCode.apply(null, chunk);
27
+ }
28
+ return btoa(binary);
29
+ }
30
+ /** @type {(base64: string) => ArrayBuffer} */
31
+ function decode_legacy(base64) {
32
+ const binary_string = atob(base64);
33
+ const len = binary_string.length;
34
+ const array = new Uint8Array(len);
35
+ for (let i = 0; i < len; i++) array[i] = binary_string.charCodeAt(i);
36
+ return array.buffer;
37
+ }
38
+ const native = typeof Uint8Array.fromBase64 === "function";
39
+ const buffer = typeof process === "object" && process.versions?.node !== void 0;
40
+ const encode64 = native ? encode_native : buffer ? encode_buffer : encode_legacy;
41
+ const decode64 = native ? decode_native : buffer ? decode_buffer : decode_legacy;
42
+ //#endregion
43
+ //#region ../../node_modules/.pnpm/devalue@5.8.1/node_modules/devalue/src/constants.js
44
+ const MAX_ARRAY_LEN = 2 ** 32 - 1;
45
+ const MAX_ARRAY_INDEX = MAX_ARRAY_LEN - 1;
46
+ //#endregion
47
+ //#region ../../node_modules/.pnpm/devalue@5.8.1/node_modules/devalue/src/utils.js
48
+ var DevalueError = class extends Error {
49
+ /**
50
+ * @param {string} message
51
+ * @param {string[]} keys
52
+ * @param {any} [value] - The value that failed to be serialized
53
+ * @param {any} [root] - The root value being serialized
54
+ */
55
+ constructor(message, keys, value, root) {
56
+ super(message);
57
+ this.name = "DevalueError";
58
+ this.path = keys.join("");
59
+ this.value = value;
60
+ this.root = root;
61
+ }
62
+ };
63
+ /** @param {any} thing */
64
+ function is_primitive(thing) {
65
+ return thing === null || typeof thing !== "object" && typeof thing !== "function";
66
+ }
67
+ const object_proto_names = /* @__PURE__ */ Object.getOwnPropertyNames(Object.prototype).sort().join("\0");
68
+ /** @param {any} thing */
69
+ function is_plain_object(thing) {
70
+ const proto = Object.getPrototypeOf(thing);
71
+ return proto === Object.prototype || proto === null || Object.getPrototypeOf(proto) === null || Object.getOwnPropertyNames(proto).sort().join("\0") === object_proto_names;
72
+ }
73
+ /** @param {any} thing */
74
+ function get_type(thing) {
75
+ return Object.prototype.toString.call(thing).slice(8, -1);
76
+ }
77
+ /** @param {string} char */
78
+ function get_escaped_char(char) {
79
+ switch (char) {
80
+ case "\"": return "\\\"";
81
+ case "<": return "\\u003C";
82
+ case "\\": return "\\\\";
83
+ case "\n": return "\\n";
84
+ case "\r": return "\\r";
85
+ case " ": return "\\t";
86
+ case "\b": return "\\b";
87
+ case "\f": return "\\f";
88
+ case "\u2028": return "\\u2028";
89
+ case "\u2029": return "\\u2029";
90
+ default: return char < " " ? `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}` : "";
91
+ }
92
+ }
93
+ /** @param {string} str */
94
+ function stringify_string(str) {
95
+ let result = "";
96
+ let last_pos = 0;
97
+ const len = str.length;
98
+ for (let i = 0; i < len; i += 1) {
99
+ const char = str[i];
100
+ const replacement = get_escaped_char(char);
101
+ if (replacement) {
102
+ result += str.slice(last_pos, i) + replacement;
103
+ last_pos = i + 1;
104
+ }
105
+ }
106
+ return `"${last_pos === 0 ? str : result + str.slice(last_pos)}"`;
107
+ }
108
+ /** @param {Record<string | symbol, any>} object */
109
+ function enumerable_symbols(object) {
110
+ return Object.getOwnPropertySymbols(object).filter((symbol) => Object.getOwnPropertyDescriptor(object, symbol).enumerable);
111
+ }
112
+ const is_identifier = /^[a-zA-Z_$][a-zA-Z_$0-9]*$/;
113
+ /** @param {string} key */
114
+ function stringify_key(key) {
115
+ return is_identifier.test(key) ? "." + key : "[" + JSON.stringify(key) + "]";
116
+ }
117
+ /** @param {number} n */
118
+ function is_valid_array_index(n) {
119
+ if (!Number.isInteger(n)) return false;
120
+ if (n < 0) return false;
121
+ if (n > MAX_ARRAY_INDEX) return false;
122
+ return true;
123
+ }
124
+ /** @param {number} n */
125
+ function is_valid_array_len(n) {
126
+ if (!Number.isInteger(n)) return false;
127
+ if (n < 0) return false;
128
+ if (n > MAX_ARRAY_LEN) return false;
129
+ return true;
130
+ }
131
+ /** @param {string} s */
132
+ function is_valid_array_index_string(s) {
133
+ if (s.length === 0) return false;
134
+ if (s.length > 1 && s.charCodeAt(0) === 48) return false;
135
+ for (let i = 0; i < s.length; i++) {
136
+ const c = s.charCodeAt(i);
137
+ if (c < 48 || c > 57) return false;
138
+ }
139
+ return is_valid_array_index(+s);
140
+ }
141
+ /**
142
+ * Finds the populated indices of an array.
143
+ * @param {unknown[]} array
144
+ */
145
+ function valid_array_indices(array) {
146
+ const keys = Object.keys(array);
147
+ for (var i = keys.length - 1; i >= 0; i--) if (is_valid_array_index_string(keys[i])) break;
148
+ keys.length = i + 1;
149
+ return keys;
150
+ }
151
+ //#endregion
152
+ //#region ../../node_modules/.pnpm/devalue@5.8.1/node_modules/devalue/src/parse.js
153
+ /**
154
+ * Revive a value serialized with `devalue.stringify`
155
+ * @param {string} serialized
156
+ * @param {Record<string, (value: any) => any>} [revivers]
157
+ */
158
+ function parse(serialized, revivers) {
159
+ return unflatten(JSON.parse(serialized), revivers);
160
+ }
161
+ /**
162
+ * Revive a value flattened with `devalue.stringify`
163
+ * @param {number | any[]} parsed
164
+ * @param {Record<string, (value: any) => any>} [revivers]
165
+ */
166
+ function unflatten(parsed, revivers) {
167
+ if (typeof parsed === "number") return hydrate(parsed, true);
168
+ if (!Array.isArray(parsed) || parsed.length === 0) throw new Error("Invalid input");
169
+ const values = parsed;
170
+ const hydrated = Array(values.length);
171
+ /**
172
+ * A set of values currently being hydrated with custom revivers,
173
+ * used to detect invalid cyclical dependencies
174
+ * @type {Set<number> | null}
175
+ */
176
+ let hydrating = null;
177
+ /**
178
+ * @param {number} index
179
+ * @returns {any}
180
+ */
181
+ function hydrate(index, standalone = false) {
182
+ if (index === -1) return void 0;
183
+ if (index === -3) return NaN;
184
+ if (index === -4) return Infinity;
185
+ if (index === -5) return -Infinity;
186
+ if (index === -6) return -0;
187
+ if (standalone || typeof index !== "number") throw new Error(`Invalid input`);
188
+ if (index in hydrated) return hydrated[index];
189
+ const value = values[index];
190
+ if (!value || typeof value !== "object") hydrated[index] = value;
191
+ else if (Array.isArray(value)) if (typeof value[0] === "string") {
192
+ const type = value[0];
193
+ const reviver = revivers && Object.hasOwn(revivers, type) ? revivers[type] : void 0;
194
+ if (reviver) {
195
+ let i = value[1];
196
+ if (typeof i !== "number") i = values.push(value[1]) - 1;
197
+ hydrating ??= /* @__PURE__ */ new Set();
198
+ if (hydrating.has(i)) throw new Error("Invalid circular reference");
199
+ hydrating.add(i);
200
+ hydrated[index] = reviver(hydrate(i));
201
+ hydrating.delete(i);
202
+ return hydrated[index];
203
+ }
204
+ switch (type) {
205
+ case "Date":
206
+ hydrated[index] = new Date(value[1]);
207
+ break;
208
+ case "Set":
209
+ const set = /* @__PURE__ */ new Set();
210
+ hydrated[index] = set;
211
+ for (let i = 1; i < value.length; i += 1) set.add(hydrate(value[i]));
212
+ break;
213
+ case "Map":
214
+ const map = /* @__PURE__ */ new Map();
215
+ hydrated[index] = map;
216
+ for (let i = 1; i < value.length; i += 2) map.set(hydrate(value[i]), hydrate(value[i + 1]));
217
+ break;
218
+ case "RegExp":
219
+ hydrated[index] = new RegExp(value[1], value[2]);
220
+ break;
221
+ case "Object": {
222
+ const wrapped_index = value[1];
223
+ if (typeof values[wrapped_index] === "object" && values[wrapped_index][0] !== "BigInt") throw new Error("Invalid input");
224
+ hydrated[index] = Object(hydrate(wrapped_index));
225
+ break;
226
+ }
227
+ case "BigInt":
228
+ hydrated[index] = BigInt(value[1]);
229
+ break;
230
+ case "null":
231
+ const obj = Object.create(null);
232
+ hydrated[index] = obj;
233
+ for (let i = 1; i < value.length; i += 2) {
234
+ if (value[i] === "__proto__") throw new Error("Cannot parse an object with a `__proto__` property");
235
+ obj[value[i]] = hydrate(value[i + 1]);
236
+ }
237
+ break;
238
+ case "Int8Array":
239
+ case "Uint8Array":
240
+ case "Uint8ClampedArray":
241
+ case "Int16Array":
242
+ case "Uint16Array":
243
+ case "Float16Array":
244
+ case "Int32Array":
245
+ case "Uint32Array":
246
+ case "Float32Array":
247
+ case "Float64Array":
248
+ case "BigInt64Array":
249
+ case "BigUint64Array":
250
+ case "DataView": {
251
+ if (values[value[1]][0] !== "ArrayBuffer") throw new Error("Invalid data");
252
+ const TypedArrayConstructor = globalThis[type];
253
+ const buffer = hydrate(value[1]);
254
+ hydrated[index] = value[2] !== void 0 ? new TypedArrayConstructor(buffer, value[2], value[3]) : new TypedArrayConstructor(buffer);
255
+ break;
256
+ }
257
+ case "ArrayBuffer": {
258
+ const base64 = value[1];
259
+ if (typeof base64 !== "string") throw new Error("Invalid ArrayBuffer encoding");
260
+ hydrated[index] = decode64(base64);
261
+ break;
262
+ }
263
+ case "Temporal.Duration":
264
+ case "Temporal.Instant":
265
+ case "Temporal.PlainDate":
266
+ case "Temporal.PlainTime":
267
+ case "Temporal.PlainDateTime":
268
+ case "Temporal.PlainMonthDay":
269
+ case "Temporal.PlainYearMonth":
270
+ case "Temporal.ZonedDateTime": {
271
+ const temporalName = type.slice(9);
272
+ hydrated[index] = Temporal[temporalName].from(value[1]);
273
+ break;
274
+ }
275
+ case "URL":
276
+ hydrated[index] = new URL(value[1]);
277
+ break;
278
+ case "URLSearchParams":
279
+ hydrated[index] = new URLSearchParams(value[1]);
280
+ break;
281
+ default: throw new Error(`Unknown type ${type}`);
282
+ }
283
+ } else if (value[0] === -7) {
284
+ const len = value[1];
285
+ if (!is_valid_array_len(len)) throw new Error("Invalid input");
286
+ /** @type {any[]} */
287
+ const array = [];
288
+ hydrated[index] = array;
289
+ array[MAX_ARRAY_INDEX] = void 0;
290
+ delete array[MAX_ARRAY_INDEX];
291
+ for (let i = 2; i < value.length; i += 2) {
292
+ const idx = value[i];
293
+ if (!is_valid_array_index(idx) || idx >= len) throw new Error("Invalid input");
294
+ array[idx] = hydrate(value[i + 1]);
295
+ }
296
+ array.length = len;
297
+ } else {
298
+ const array = new Array(value.length);
299
+ hydrated[index] = array;
300
+ for (let i = 0; i < value.length; i += 1) {
301
+ const n = value[i];
302
+ if (n === -2) continue;
303
+ array[i] = hydrate(n);
304
+ }
305
+ }
306
+ else {
307
+ /** @type {Record<string, any>} */
308
+ const object = {};
309
+ hydrated[index] = object;
310
+ for (const key of Object.keys(value)) {
311
+ if (key === "__proto__") throw new Error("Cannot parse an object with a `__proto__` property");
312
+ const n = value[key];
313
+ object[key] = hydrate(n);
314
+ }
315
+ }
316
+ return hydrated[index];
317
+ }
318
+ return hydrate(0);
319
+ }
320
+ //#endregion
321
+ //#region ../../node_modules/.pnpm/devalue@5.8.1/node_modules/devalue/src/stringify.js
322
+ /**
323
+ * Turn a value into a JSON string that can be parsed with `devalue.parse`
324
+ * @param {any} value
325
+ * @param {Record<string, (value: any) => any>} [reducers]
326
+ */
327
+ function stringify(value, reducers) {
328
+ const stringified = run(false, value, reducers);
329
+ return typeof stringified === "string" ? stringified : `[${stringified.join(",")}]`;
330
+ }
331
+ /**
332
+ * @param {boolean} async
333
+ * @param {any} value
334
+ * @param {Record<string, (value: any) => any>} [reducers]
335
+ */
336
+ function run(async, value, reducers) {
337
+ /** @type {any[]} */
338
+ const stringified = [];
339
+ /** @type {Map<any, number>} */
340
+ const indexes = /* @__PURE__ */ new Map();
341
+ /** @type {Array<{ key: string, fn: (value: any) => any }>} */
342
+ const custom = [];
343
+ if (reducers) for (const key of Object.getOwnPropertyNames(reducers)) custom.push({
344
+ key,
345
+ fn: reducers[key]
346
+ });
347
+ /** @type {string[]} */
348
+ const keys = [];
349
+ let p = 0;
350
+ /**
351
+ * @param {any} thing
352
+ * @param {number} [index]
353
+ */
354
+ function flatten(thing, index) {
355
+ if (thing === void 0) return -1;
356
+ if (Number.isNaN(thing)) return -3;
357
+ if (thing === Infinity) return -4;
358
+ if (thing === -Infinity) return -5;
359
+ if (thing === 0 && 1 / thing < 0) return -6;
360
+ if (indexes.has(thing)) return indexes.get(thing);
361
+ index ??= p++;
362
+ indexes.set(thing, index);
363
+ for (const { key, fn } of custom) {
364
+ const value = fn(thing);
365
+ if (value) {
366
+ stringified[index] = `["${key}",${flatten(value)}]`;
367
+ return index;
368
+ }
369
+ }
370
+ if (typeof thing === "function") throw new DevalueError(`Cannot stringify a function`, keys, thing, value);
371
+ else if (typeof thing === "symbol") throw new DevalueError(`Cannot stringify a Symbol primitive`, keys, thing, value);
372
+ /** @type {string | Promise<any>} */
373
+ let str = "";
374
+ if (is_primitive(thing)) str = stringify_primitive(thing);
375
+ else if (typeof thing.then === "function") {
376
+ if (!async) throw new DevalueError(`Cannot stringify a Promise or thenable — use stringifyAsync instead`, keys, thing, value);
377
+ str = Promise.resolve(thing).then((value) => {
378
+ const i = flatten(value, index);
379
+ if (i < 0) stringified[index] = i;
380
+ });
381
+ } else {
382
+ const type = get_type(thing);
383
+ switch (type) {
384
+ case "Number":
385
+ case "String":
386
+ case "Boolean":
387
+ case "BigInt":
388
+ str = `["Object",${flatten(thing.valueOf())}]`;
389
+ break;
390
+ case "Date":
391
+ str = `["Date","${!isNaN(thing.getDate()) ? thing.toISOString() : ""}"]`;
392
+ break;
393
+ case "URL":
394
+ str = `["URL",${stringify_string(thing.toString())}]`;
395
+ break;
396
+ case "URLSearchParams":
397
+ str = `["URLSearchParams",${stringify_string(thing.toString())}]`;
398
+ break;
399
+ case "RegExp":
400
+ const { source, flags } = thing;
401
+ str = flags ? `["RegExp",${stringify_string(source)},"${flags}"]` : `["RegExp",${stringify_string(source)}]`;
402
+ break;
403
+ case "Array": {
404
+ let mostly_dense = false;
405
+ str = "[";
406
+ for (let i = 0; i < thing.length; i += 1) {
407
+ if (i > 0) str += ",";
408
+ if (Object.hasOwn(thing, i)) {
409
+ keys.push(`[${i}]`);
410
+ str += flatten(thing[i]);
411
+ keys.pop();
412
+ } else if (mostly_dense) str += -2;
413
+ else {
414
+ const populated_keys = valid_array_indices(thing);
415
+ const population = populated_keys.length;
416
+ const d = String(thing.length).length;
417
+ if ((thing.length - population) * 3 > 4 + d + population * (d + 1)) {
418
+ str = "[-7," + thing.length;
419
+ for (let j = 0; j < populated_keys.length; j++) {
420
+ const key = populated_keys[j];
421
+ keys.push(`[${key}]`);
422
+ str += "," + key + "," + flatten(thing[key]);
423
+ keys.pop();
424
+ }
425
+ break;
426
+ } else {
427
+ mostly_dense = true;
428
+ str += -2;
429
+ }
430
+ }
431
+ }
432
+ str += "]";
433
+ break;
434
+ }
435
+ case "Set":
436
+ str = "[\"Set\"";
437
+ for (const value of thing) str += `,${flatten(value)}`;
438
+ str += "]";
439
+ break;
440
+ case "Map":
441
+ str = "[\"Map\"";
442
+ for (const [key, value] of thing) {
443
+ keys.push(`.get(${is_primitive(key) ? stringify_primitive(key) : "..."})`);
444
+ str += `,${flatten(key)},${flatten(value)}`;
445
+ keys.pop();
446
+ }
447
+ str += "]";
448
+ break;
449
+ case "Int8Array":
450
+ case "Uint8Array":
451
+ case "Uint8ClampedArray":
452
+ case "Int16Array":
453
+ case "Uint16Array":
454
+ case "Float16Array":
455
+ case "Int32Array":
456
+ case "Uint32Array":
457
+ case "Float32Array":
458
+ case "Float64Array":
459
+ case "BigInt64Array":
460
+ case "BigUint64Array":
461
+ case "DataView": {
462
+ /** @type {import("./types.js").TypedArray} */
463
+ const typedArray = thing;
464
+ str = "[\"" + type + "\"," + flatten(typedArray.buffer);
465
+ if (typedArray.byteLength !== typedArray.buffer.byteLength) str += `,${typedArray.byteOffset},${typedArray.length}`;
466
+ str += "]";
467
+ break;
468
+ }
469
+ case "ArrayBuffer":
470
+ str = `["ArrayBuffer","${encode64(thing)}"]`;
471
+ break;
472
+ case "Temporal.Duration":
473
+ case "Temporal.Instant":
474
+ case "Temporal.PlainDate":
475
+ case "Temporal.PlainTime":
476
+ case "Temporal.PlainDateTime":
477
+ case "Temporal.PlainMonthDay":
478
+ case "Temporal.PlainYearMonth":
479
+ case "Temporal.ZonedDateTime":
480
+ str = `["${type}",${stringify_string(thing.toString())}]`;
481
+ break;
482
+ default:
483
+ if (!is_plain_object(thing)) throw new DevalueError(`Cannot stringify arbitrary non-POJOs`, keys, thing, value);
484
+ if (enumerable_symbols(thing).length > 0) throw new DevalueError(`Cannot stringify POJOs with symbolic keys`, keys, thing, value);
485
+ if (Object.getPrototypeOf(thing) === null) {
486
+ str = "[\"null\"";
487
+ for (const key of Object.keys(thing)) {
488
+ if (key === "__proto__") throw new DevalueError(`Cannot stringify objects with __proto__ keys`, keys, thing, value);
489
+ keys.push(stringify_key(key));
490
+ str += `,${stringify_string(key)},${flatten(thing[key])}`;
491
+ keys.pop();
492
+ }
493
+ str += "]";
494
+ } else {
495
+ str = "{";
496
+ let started = false;
497
+ for (const key of Object.keys(thing)) {
498
+ if (key === "__proto__") throw new DevalueError(`Cannot stringify objects with __proto__ keys`, keys, thing, value);
499
+ if (started) str += ",";
500
+ started = true;
501
+ keys.push(stringify_key(key));
502
+ str += `${stringify_string(key)}:${flatten(thing[key])}`;
503
+ keys.pop();
504
+ }
505
+ str += "}";
506
+ }
507
+ }
508
+ }
509
+ stringified[index] = str;
510
+ return index;
511
+ }
512
+ const index = flatten(value);
513
+ if (index < 0) return `${index}`;
514
+ return stringified;
515
+ }
516
+ /**
517
+ * @param {any} thing
518
+ * @returns {string}
519
+ */
520
+ function stringify_primitive(thing) {
521
+ const type = typeof thing;
522
+ if (type === "string") return stringify_string(thing);
523
+ if (thing === void 0) return (-1).toString();
524
+ if (thing === 0 && 1 / thing < 0) return (-6).toString();
525
+ if (type === "bigint") return `["BigInt","${thing}"]`;
526
+ return String(thing);
527
+ }
528
+ //#endregion
3
529
  //#region src/flash.ts
4
530
  /**
5
- * Dev-only radiographic capture effect — pure delight. When a capture lands we
6
- * briefly "expose" the captured element: a cyan bloom, a negative blink (an
7
- * x-ray IS a negative — `backdrop-filter: invert`), and a scan-line sweep down
8
- * the box. Honors `prefers-reduced-motion` (then just a soft bloom). It's a
9
- * transient `position: fixed`, `pointer-events: none` overlay sitting just under
10
- * the HUD; it never touches the capture pipeline or the captured DOM.
531
+ * Dev-only radiographic capture effect — pure delight. When a capture lands (and
532
+ * actually CHANGES the stored result) we briefly "expose" the captured element: a
533
+ * negative blink (an x-ray IS a negative — `backdrop-filter: invert`) that enters
534
+ * SHARPLY and fades a touch slower, framed by a soft shadow ring around the box so
535
+ * you can see WHICH area was captured. Short and quiet — it's feedback that a
536
+ * resize/navigation (or the capture-all sweep) recorded something, not a light show.
537
+ * Honors `prefers-reduced-motion` (then just a shadow pulse, no invert). A transient
538
+ * `position: fixed`, `pointer-events: none` overlay just under the HUD; it never
539
+ * touches the capture pipeline or the captured DOM.
11
540
  */
12
541
  const STYLE_ID = "xr-flash-style";
542
+ const DURATION_MS = 300;
13
543
  function ensureStyle() {
14
544
  if (document.getElementById(STYLE_ID)) return;
15
545
  const style = document.createElement("style");
@@ -17,31 +547,22 @@ function ensureStyle() {
17
547
  style.textContent = `
18
548
  .xr-flash {
19
549
  position: fixed; pointer-events: none; z-index: 2147483646;
20
- overflow: hidden; border-radius: 4px;
21
- animation: xr-expose 480ms ease-out forwards;
22
- }
23
- .xr-flash::after {
24
- content: ''; position: absolute; left: 0; right: 0; height: 18%;
25
- background: linear-gradient(180deg, transparent, rgba(186, 230, 253, 0.9), transparent);
26
- filter: blur(1px);
27
- animation: xr-scan 480ms ease-in-out forwards;
550
+ border-radius: 4px;
551
+ animation: xr-expose ${DURATION_MS}ms ease-out forwards;
28
552
  }
29
553
  @keyframes xr-expose {
30
- 0% { background: rgba(186, 230, 253, 0); backdrop-filter: invert(0); opacity: 1 }
31
- 18% { background: rgba(186, 230, 253, 0.28); backdrop-filter: invert(1) }
32
- 100% { background: rgba(186, 230, 253, 0); backdrop-filter: invert(0); opacity: 0 }
33
- }
34
- @keyframes xr-scan {
35
- from { transform: translateY(-100%) }
36
- to { transform: translateY(560%) }
554
+ /* Sharp in: full invert + ring by ~12% (~36ms). */
555
+ 0% { backdrop-filter: invert(0); box-shadow: 0 0 0 1px rgba(56, 189, 248, 0), 0 0 0 0 rgba(56, 189, 248, 0) }
556
+ 12% { backdrop-filter: invert(1); box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.85), 0 0 14px 1px rgba(56, 189, 248, 0.5) }
557
+ /* Slower fade out (ease-out decelerates the remaining ~88%). */
558
+ 100% { backdrop-filter: invert(0); box-shadow: 0 0 0 2px rgba(56, 189, 248, 0), 0 0 14px 1px rgba(56, 189, 248, 0) }
37
559
  }
38
560
  @keyframes xr-expose-soft {
39
- 0% { background: rgba(186, 230, 253, 0.25); opacity: 1 }
40
- 100% { background: rgba(186, 230, 253, 0); opacity: 0 }
561
+ 0% { box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.7) }
562
+ 100% { box-shadow: 0 0 0 2px rgba(56, 189, 248, 0) }
41
563
  }
42
564
  @media (prefers-reduced-motion: reduce) {
43
- .xr-flash { animation: xr-expose-soft 320ms ease-out forwards }
44
- .xr-flash::after { display: none }
565
+ .xr-flash { animation: xr-expose-soft ${DURATION_MS}ms ease-out forwards }
45
566
  }
46
567
  `;
47
568
  (document.head ?? document.documentElement).append(style);
@@ -78,7 +599,7 @@ function flashCapture(el) {
78
599
  flash.style.height = `${box.height}px`;
79
600
  flash.addEventListener("animationend", () => flash.remove(), { once: true });
80
601
  (document.body ?? document.documentElement).append(flash);
81
- setTimeout(() => flash.remove(), 1e3);
602
+ setTimeout(() => flash.remove(), 500);
82
603
  }
83
604
  //#endregion
84
605
  //#region src/client.ts
@@ -88,6 +609,23 @@ function isExtensionAck(value) {
88
609
  const DEFAULT_SETTLE_CAP = 5e3;
89
610
  const DEFAULT_MAX_NODES = 4e3;
90
611
  const RESIZE_DEBOUNCE = 300;
612
+ /**
613
+ * The capture roots for a site, the ONE place the auto/manual root models
614
+ * diverge (ADR 0014, finding #6).
615
+ *
616
+ * - AUTO: `site.el` is the `display:contents` wrapper, a layout-neutral box, so
617
+ * the content is its CHILDREN — the synthetic root (id 0) stands in for the
618
+ * wrapper and the children become the plate's top level (today's behavior).
619
+ * `<STYLE>` children are dropped (a stitched-in scoped sheet, never content).
620
+ * - MANUAL: `site.el` is the consumer's OWN styled element, so the element
621
+ * ITSELF is the root. Capturing its children would drop its box, classes,
622
+ * padding, border, background, and radius — the exact bug this fixes — so the
623
+ * element is the single root and its own box is serialized.
624
+ */
625
+ function rootsFor(site) {
626
+ if (site.mode === "manual") return [site.el];
627
+ return Array.from(site.el.children).filter((child) => child.tagName !== "STYLE");
628
+ }
91
629
  function installXrayClient(options) {
92
630
  const win = window;
93
631
  const defaultDelay = options.delay ?? 200;
@@ -95,6 +633,8 @@ function installXrayClient(options) {
95
633
  const maxNodes = options.maxNodes ?? DEFAULT_MAX_NODES;
96
634
  const sites = /* @__PURE__ */ new Set();
97
635
  const matchMediaQueries = /* @__PURE__ */ new Set();
636
+ let captureGeneration = 0;
637
+ let sweeping = false;
98
638
  const livePlates = /* @__PURE__ */ new Map();
99
639
  const plateSerialized = /* @__PURE__ */ new Map();
100
640
  const plateListeners = /* @__PURE__ */ new Map();
@@ -117,10 +657,11 @@ function installXrayClient(options) {
117
657
  };
118
658
  const updatePlate = (name, plate) => {
119
659
  const json = JSON.stringify(plate);
120
- if (plateSerialized.get(name) === json) return;
660
+ if (plateSerialized.get(name) === json) return false;
121
661
  plateSerialized.set(name, json);
122
662
  livePlates.set(name, plate);
123
663
  for (const cb of plateListeners.get(name) ?? []) cb();
664
+ return true;
124
665
  };
125
666
  const nativeMatchMedia = win.matchMedia.bind(win);
126
667
  win.matchMedia = (query) => {
@@ -128,7 +669,10 @@ function installXrayClient(options) {
128
669
  return nativeMatchMedia(query);
129
670
  };
130
671
  const lightbox = createToggleStore(win, LIGHTBOX_KEY, LIGHTBOX_PARAM, false);
131
- const captureEnabled = createToggleStore(win, CAPTURE_KEY, CAPTURE_PARAM, options.capture ?? true);
672
+ const captureEnabled = createToggleStore(win, CAPTURE_KEY, CAPTURE_PARAM, options.capture ?? false);
673
+ const replayEnabled = createToggleStore(win, REPLAY_KEY, REPLAY_PARAM, options.replay === "prefer" || options.replay === "missing-only");
674
+ const replayMode = options.replay === "missing-only" ? "missing-only" : "prefer";
675
+ const recordMode = options.record ?? "manual";
132
676
  const isPrimaryForName = (site) => {
133
677
  for (const other of sites) {
134
678
  if (other === site || other.disposed || other.name !== site.name || !other.el.isConnected) continue;
@@ -136,49 +680,212 @@ function installXrayClient(options) {
136
680
  }
137
681
  return true;
138
682
  };
139
- const captureSite = async (site, force = false) => {
140
- if (!force && !captureEnabled.get() || site.tooLarge || !isPrimaryForName(site)) return;
141
- await settle(site.el, site.delay, settleCap);
142
- if (site.disposed || site.tooLarge || !site.el.isConnected || !isPrimaryForName(site)) return;
143
- const roots = Array.from(site.el.children).filter((child) => child.tagName !== "STYLE");
144
- if (roots.length === 0) return;
683
+ /**
684
+ * Measure one View into a LOCAL, fresh 1-View `PlateIR` (no shared bundle) and
685
+ * return it only if the generation guard still holds AFTER the settle the
686
+ * heart of the ADR 0022 generation-guard preservation (plan §4). Because
687
+ * `captureView(roots, plate)` MUTATES the passed bundle in place
688
+ * (`plate.views.push`), a stale capture appended to a SHARED session PlateIR
689
+ * would corrupt the whole Plate's projection (one width's geometry under
690
+ * another's island/gate), and post-hoc rollback is impossible. So we capture
691
+ * into a local bundle FIRST, re-check the guard, and only the caller splices the
692
+ * survivor into the session bundle. Returns the local View on success, or
693
+ * `undefined` when the guard moved / the capture was refused / the site was torn
694
+ * down — the caller then DISCARDS it (never appends).
695
+ *
696
+ * `generation` is the token snapshotted by the caller BEFORE the settle; a
697
+ * width-change/disposal/uninstall mid-settle moves `captureGeneration` and this
698
+ * returns undefined. A `CaptureTooLargeError` marks the site and warns. Any
699
+ * other failure warns and returns undefined.
700
+ */
701
+ const captureLocalView = async (site, generation) => {
702
+ await settle(site.el, site.delay, settleCap, site.mode);
703
+ if (generation !== captureGeneration || site.disposed || site.tooLarge || !site.el.isConnected || !isPrimaryForName(site)) return;
704
+ const roots = rootsFor(site);
705
+ if (roots.length === 0) return void 0;
706
+ let local;
145
707
  try {
146
- const capture = captureRegime(roots, { maxNodes });
147
- const breakpoints = deriveBreakpoints(capture.rules, [...matchMediaQueries]);
148
- const span = regimeFor(capture.width, breakpoints);
149
- const payload = {
150
- name: site.name,
151
- capture: {
152
- ...capture,
153
- ...span
154
- },
155
- breakpoints
156
- };
157
- const key = JSON.stringify(span);
158
- const body = JSON.stringify(payload);
159
- if (site.sent.get(key) === body) return;
160
- site.sent.set(key, body);
161
- options.sink(payload);
162
- flashCapture(site.el);
708
+ local = captureView(roots, {
709
+ maxNodes,
710
+ walker: site.walker,
711
+ captureRootIsBoundary: site.mode === "manual"
712
+ });
163
713
  } catch (error) {
164
714
  if (error instanceof CaptureTooLargeError) {
165
715
  site.tooLarge = true;
166
- console.warn(`[xray] skipping capture of "${site.name}": ${error.nodeCount} nodes (max ${maxNodes}).`, "A skeleton this big usually means the <Skeleton> sits too high in the tree.");
716
+ reportDiagnostics(site.name, [makeDiagnostic(error.code, { detail: `${error.nodeCount} nodes (max ${maxNodes})` })]);
167
717
  return;
168
718
  }
169
719
  console.warn("[xray] capture failed for", site.name, error);
720
+ return;
721
+ }
722
+ const view = local.views[0];
723
+ if (!view) return void 0;
724
+ reportDiagnostics(site.name, view.diagnostics);
725
+ if (generation !== captureGeneration || site.disposed) return void 0;
726
+ return view;
727
+ };
728
+ const recording = /* @__PURE__ */ new Map();
729
+ const recordingBreakpoints = (buf) => [...new Set([...buf.views.flatMap((v) => v.bpSeed ?? []), ...deriveBreakpoints([], [...matchMediaQueries])])].toSorted((a, b) => a - b);
730
+ const projectRecording = (buf) => {
731
+ if (buf.views.length === 0) return void 0;
732
+ const breakpoints = recordingBreakpoints(buf);
733
+ const perBand = /* @__PURE__ */ new Map();
734
+ for (const view of buf.views) perBand.set(regimeFor(view.width, breakpoints).min ?? 0, view);
735
+ const views = [...perBand.values()].toSorted((a, b) => a.width - b.width);
736
+ return serializePlateIR({
737
+ name: buf.name,
738
+ views,
739
+ breakpoints
740
+ });
741
+ };
742
+ const previewRecording = (buf) => {
743
+ const stored = projectRecording(buf);
744
+ return stored ? updatePlate(buf.name, serializePlate(stored)) : false;
745
+ };
746
+ const finalizeRecording = async () => {
747
+ const entries = [...recording];
748
+ recording.clear();
749
+ await Promise.all(entries.map(async ([name, buf]) => {
750
+ const stored = projectRecording(buf);
751
+ if (!stored) return;
752
+ const diagnostics = buf.views.flatMap((v) => v.diagnostics ?? []);
753
+ const envelope = {
754
+ plate: stored,
755
+ ...diagnostics.length > 0 ? { diagnostics } : {}
756
+ };
757
+ try {
758
+ await options.sink(envelope);
759
+ } catch (error) {
760
+ if (!recording.has(name)) recording.set(name, buf);
761
+ console.warn(`[xray] recording write failed for "${name}" — kept in memory; retried when you next start a recording`, error);
762
+ }
763
+ }));
764
+ };
765
+ const runCapture = async (site, force) => {
766
+ if (!force && (!captureEnabled.get() || sweeping) || site.tooLarge || !isPrimaryForName(site)) return;
767
+ const view = await captureLocalView(site, captureGeneration);
768
+ if (!view) return;
769
+ if (sweeping && force) {
770
+ (site.sessionPlate ??= {
771
+ name: site.name,
772
+ views: [],
773
+ breakpoints: []
774
+ }).views.push(view);
775
+ return;
776
+ }
777
+ if (!captureEnabled.get()) return;
778
+ view.bpSeed = deriveBreakpoints(emit(view.bundle).rules, []);
779
+ runPlatePasses({
780
+ name: site.name,
781
+ views: [view],
782
+ breakpoints: []
783
+ });
784
+ let buf = recording.get(site.name);
785
+ if (!buf) {
786
+ buf = {
787
+ name: site.name,
788
+ views: [],
789
+ breakpoints: []
790
+ };
791
+ recording.set(site.name, buf);
792
+ }
793
+ buf.views = buf.views.filter((v) => v.width !== view.width);
794
+ buf.views.push(view);
795
+ if (previewRecording(buf)) flashCapture(site.el);
796
+ };
797
+ /**
798
+ * Project a per-site `PlateIR` to the structured `StoredPlate` IN THE BROWSER
799
+ * (the ADR 0022 Serialize) and hand the envelope to the sink. Derives the
800
+ * breakpoint union ONCE over every View's rules + the recorded matchMedia
801
+ * queries (relocating the old per-View derivation), runs the reduction passes
802
+ * once over the whole bundle, then `serializePlateIR`. Dedupes per-PLATE by the
803
+ * serialized StoredPlate string. ALWAYS catches so neither mode leaks an
804
+ * unhandled rejection; on failure it clears the dedupe so a retry re-sends and,
805
+ * ONLY when `awaitSink`, rethrows so capture-all still fails loudly (pieces
806
+ * 3/8). The radiographic flash runs only AFTER a successful send.
807
+ */
808
+ const sendPlate = async (site, plate, awaitSink) => {
809
+ if (plate.views.length === 0) return;
810
+ plate.name = site.name;
811
+ plate.breakpoints = deriveBreakpoints(plate.views.flatMap((v) => emit(v.bundle).rules), [...matchMediaQueries]);
812
+ runPlatePasses(plate);
813
+ const stored = serializePlateIR(plate);
814
+ const diagnostics = plate.views.flatMap((v) => v.diagnostics ?? []);
815
+ const envelope = {
816
+ plate: stored,
817
+ ...diagnostics.length > 0 ? { diagnostics } : {}
818
+ };
819
+ const body = JSON.stringify(envelope);
820
+ if (site.lastSent === body) return;
821
+ site.lastSent = body;
822
+ try {
823
+ await options.sink(envelope);
824
+ } catch (error) {
825
+ site.lastSent = void 0;
826
+ if (awaitSink) throw error;
827
+ console.warn("[xray] capture send failed for", site.name, error);
828
+ return;
170
829
  }
830
+ flashCapture(site.el);
831
+ };
832
+ /**
833
+ * Finalize a site's accumulated sweep `PlateIR` and POST it ONCE (awaited, the
834
+ * backpressure point). Clears `site.sessionPlate` first so any straggler capture
835
+ * for the site falls back to the browse path rather than mutating the bundle
836
+ * mid-flight. A session that captured no surviving View posts nothing.
837
+ */
838
+ const finalizeSessionPlate = async (site) => {
839
+ const session = site.sessionPlate;
840
+ site.sessionPlate = void 0;
841
+ if (!session || session.views.length === 0) return;
842
+ await sendPlate(site, session, true);
843
+ };
844
+ const scheduleCapture = (site, force = false) => {
845
+ if (site.inflight) {
846
+ site.dirty = true;
847
+ site.dirtyForce = site.dirtyForce || force;
848
+ return site.inflight;
849
+ }
850
+ const loop = async (initialForce) => {
851
+ let wantForce = initialForce;
852
+ for (;;) {
853
+ await runCapture(site, wantForce);
854
+ if (!site.dirty) {
855
+ site.inflight = void 0;
856
+ return;
857
+ }
858
+ site.dirty = false;
859
+ wantForce = site.dirtyForce;
860
+ site.dirtyForce = false;
861
+ }
862
+ };
863
+ const promise = loop(force);
864
+ site.inflight = promise;
865
+ return promise;
171
866
  };
172
867
  let resizeTimer;
173
868
  const onResize = () => {
869
+ if (sweeping) {
870
+ clearTimeout(resizeTimer);
871
+ return;
872
+ }
174
873
  clearTimeout(resizeTimer);
175
874
  resizeTimer = setTimeout(() => {
176
- for (const site of sites) captureSite(site);
875
+ if (sweeping) return;
876
+ for (const site of sites) scheduleCapture(site);
177
877
  }, RESIZE_DEBOUNCE);
178
878
  };
179
879
  win.addEventListener("resize", onResize);
880
+ let recordingOn = captureEnabled.get();
180
881
  const unsubscribeCapture = captureEnabled.subscribe(() => {
181
- if (captureEnabled.get()) for (const site of sites) captureSite(site);
882
+ const on = captureEnabled.get();
883
+ if (on === recordingOn) return;
884
+ recordingOn = on;
885
+ if (on) {
886
+ finalizeRecording();
887
+ for (const site of sites) scheduleCapture(site);
888
+ } else finalizeRecording();
182
889
  });
183
890
  const extAcks = /* @__PURE__ */ new Map();
184
891
  let nextExtReqId = 1;
@@ -222,26 +929,97 @@ function installXrayClient(options) {
222
929
  return () => coverageListeners.delete(cb);
223
930
  }
224
931
  };
932
+ const liveData = /* @__PURE__ */ new Map();
933
+ const recorded = /* @__PURE__ */ new Map();
934
+ const fixtureListeners = /* @__PURE__ */ new Set();
935
+ const notifyFixtures = () => {
936
+ for (const cb of fixtureListeners) cb();
937
+ };
938
+ const fixtures = {
939
+ register: (name, data) => {
940
+ const entry = { data };
941
+ const stack = liveData.get(name);
942
+ if (stack) stack.push(entry);
943
+ else liveData.set(name, [entry]);
944
+ return () => {
945
+ const current = liveData.get(name);
946
+ if (!current) return;
947
+ const i = current.indexOf(entry);
948
+ if (i !== -1) current.splice(i, 1);
949
+ if (current.length === 0) liveData.delete(name);
950
+ };
951
+ },
952
+ has: (name) => recorded.has(name),
953
+ read: (name) => recorded.get(name),
954
+ record: async (name) => {
955
+ const live = liveData.get(name)?.at(-1);
956
+ if (!live) throw new Error(`[xray] cannot record fixture for "${name}": no render-prop \`data\` is in scope. Only render-prop <Skeleton data={…}> sites participate in fixtures.`);
957
+ const encoded = stringify(live.data);
958
+ await options.recordFixtureSink?.({
959
+ name,
960
+ data: encoded
961
+ });
962
+ recorded.set(name, { data: parse(encoded) });
963
+ notifyFixtures();
964
+ },
965
+ recordAll: async () => {
966
+ await Promise.all([...liveData.keys()].map((name) => fixtures.record(name)));
967
+ },
968
+ seed: (encoded) => {
969
+ let changed = false;
970
+ for (const [name, str] of Object.entries(encoded)) {
971
+ if (recorded.has(name)) continue;
972
+ try {
973
+ recorded.set(name, { data: parse(str) });
974
+ changed = true;
975
+ } catch {}
976
+ }
977
+ if (changed) notifyFixtures();
978
+ },
979
+ subscribe: (cb) => {
980
+ fixtureListeners.add(cb);
981
+ return () => fixtureListeners.delete(cb);
982
+ }
983
+ };
225
984
  const sweepWidths = () => {
226
985
  const set = new Set(deriveBreakpoints([], [...matchMediaQueries]));
227
- for (const site of sites) for (const bp of coverageMap.get(site.name)?.breakpoints ?? []) set.add(bp);
986
+ for (const site of sites) for (const view of site.sessionPlate?.views ?? []) for (const bp of deriveBreakpoints(emit(view.bundle).rules, [...matchMediaQueries])) set.add(bp);
228
987
  const breakpoints = [...set].toSorted((a, b) => a - b);
229
988
  const min = breakpoints[0];
230
989
  const first = min === void 0 ? 390 : Math.max(1, Math.min(390, min - 1));
231
990
  return [...new Set([first, ...breakpoints])].toSorted((a, b) => a - b);
232
991
  };
992
+ const MAX_SWEEP_PASSES = 8;
233
993
  const captureAllViews = async () => {
234
- const widths = sweepWidths();
994
+ const visited = /* @__PURE__ */ new Set();
995
+ sweeping = true;
996
+ for (const site of sites) site.sessionPlate = {
997
+ name: site.name,
998
+ views: [],
999
+ breakpoints: []
1000
+ };
235
1001
  try {
236
- for (const width of widths) {
237
- await extCommand("emulate", {
238
- width,
239
- height: 2400,
240
- mobile: width < 600
241
- });
242
- await Promise.all([...sites].map((site) => captureSite(site, true)));
1002
+ for (let pass = 0;; pass++) {
1003
+ const pending = sweepWidths().filter((width) => !visited.has(width));
1004
+ if (pending.length === 0) break;
1005
+ if (pass >= MAX_SWEEP_PASSES) throw new Error(`xray capture-all did not converge after ${MAX_SWEEP_PASSES} passes (still discovering widths: ${pending.join(", ")})`);
1006
+ for (const width of pending) {
1007
+ visited.add(width);
1008
+ captureGeneration++;
1009
+ await extCommand("emulate", {
1010
+ width,
1011
+ height: 2400,
1012
+ mobile: width < 600
1013
+ });
1014
+ await Promise.all([...sites].map((site) => scheduleCapture(site, true)));
1015
+ }
1016
+ await options.refreshCoverage?.();
243
1017
  }
1018
+ sweeping = false;
1019
+ await Promise.all([...sites].map((site) => finalizeSessionPlate(site)));
244
1020
  } finally {
1021
+ sweeping = false;
1022
+ for (const site of sites) site.sessionPlate = void 0;
245
1023
  await extCommand("reset").catch(() => void 0);
246
1024
  }
247
1025
  };
@@ -252,21 +1030,29 @@ function installXrayClient(options) {
252
1030
  updatePlate,
253
1031
  captureAllViews,
254
1032
  coverage,
1033
+ replay: replayEnabled,
1034
+ replayMode,
1035
+ recordMode,
1036
+ fixtures,
255
1037
  captured: (name, el, opts) => {
256
1038
  const site = {
257
1039
  name,
258
1040
  el,
259
1041
  delay: opts?.delay ?? defaultDelay,
260
- sent: /* @__PURE__ */ new Map(),
1042
+ mode: opts?.mode ?? "auto",
1043
+ walker: opts?.walker,
261
1044
  disposed: false,
262
- tooLarge: false
1045
+ tooLarge: false,
1046
+ dirty: false,
1047
+ dirtyForce: false
263
1048
  };
264
1049
  sites.add(site);
265
- captureSite(site);
1050
+ scheduleCapture(site);
266
1051
  return () => {
267
1052
  site.disposed = true;
268
1053
  sites.delete(site);
269
- for (const other of sites) if (other.name === site.name) captureSite(other);
1054
+ captureGeneration++;
1055
+ for (const other of sites) if (other.name === site.name) scheduleCapture(other);
270
1056
  };
271
1057
  }
272
1058
  };
@@ -277,13 +1063,37 @@ function installXrayClient(options) {
277
1063
  clearTimeout(resizeTimer);
278
1064
  unsubscribeCapture();
279
1065
  globalThis.__XRAY__ = void 0;
1066
+ captureGeneration++;
1067
+ for (const site of sites) site.disposed = true;
280
1068
  sites.clear();
281
1069
  };
282
1070
  }
1071
+ /**
1072
+ * Surface a capture's dev-only diagnostics as ONE grouped `console.warn` keyed
1073
+ * by plate name: a header line, then one formatted line per diagnostic via the
1074
+ * shared formatter (diagnostics.ts) — the same text the Vite server prints, so
1075
+ * the two never drift. `console.group` collapses the lines under the header;
1076
+ * we fall back to a single multi-line warn where it's unavailable. No-op when
1077
+ * there are no diagnostics, so a clean capture is silent.
1078
+ */
1079
+ function reportDiagnostics(name, diagnostics) {
1080
+ if (!diagnostics || diagnostics.length === 0) return;
1081
+ const header = diagnosticsHeader(name);
1082
+ const lines = diagnostics.map((d) => formatDiagnostic(d));
1083
+ if (typeof console.group === "function" && typeof console.groupEnd === "function") {
1084
+ console.group(header);
1085
+ for (const line of lines) console.warn(line);
1086
+ console.groupEnd();
1087
+ return;
1088
+ }
1089
+ console.warn([header, ...lines].join("\n"));
1090
+ }
283
1091
  const LIGHTBOX_KEY = "xray:lightbox";
284
1092
  const LIGHTBOX_PARAM = "xray-lightbox";
285
1093
  const CAPTURE_KEY = "xray:capture";
286
1094
  const CAPTURE_PARAM = "xray-capture";
1095
+ const REPLAY_KEY = "xray:replay";
1096
+ const REPLAY_PARAM = "xray-replay";
287
1097
  /**
288
1098
  * A sticky boolean toggle: per-tab in sessionStorage, seedable to `true` via
289
1099
  * `?<param>` in the URL, flipped from the HUD or the console
@@ -322,9 +1132,29 @@ function createToggleStore(win, key, param, initial) {
322
1132
  * made single-shot capture non-deterministic in the M1 harness; this is the
323
1133
  * cure. Hard-capped so a perpetually-animating subtree still captures.
324
1134
  */
325
- function settle(el, delay, cap) {
1135
+ /**
1136
+ * Force lazy images in the subtree to load and await their decode, so the walk
1137
+ * measures their real boxes. A `loading="lazy"` image below the fold never enters
1138
+ * the viewport during capture, so its IntersectionObserver never fires — flip it
1139
+ * to eager to trigger the load. Capped: a slow or broken image resolves the race
1140
+ * via the timeout instead of blocking the capture.
1141
+ */
1142
+ function awaitImages(root, cap) {
1143
+ const imgs = Array.from(root.querySelectorAll("img"));
1144
+ if (imgs.length === 0) return Promise.resolve();
1145
+ const decoded = imgs.map((img) => {
1146
+ if (img.loading === "lazy") img.loading = "eager";
1147
+ if (typeof img.decode !== "function") return Promise.resolve();
1148
+ return img.decode().then(() => void 0, () => void 0);
1149
+ });
1150
+ return Promise.race([Promise.all(decoded).then(() => void 0), new Promise((resolve) => {
1151
+ setTimeout(resolve, cap);
1152
+ })]);
1153
+ }
1154
+ function settle(el, delay, cap, mode = "auto") {
326
1155
  const doc = el.ownerDocument;
327
- return (doc.fonts ? doc.fonts.ready : Promise.resolve()).then(() => new Promise((resolve) => {
1156
+ const fonts = doc.fonts ? doc.fonts.ready : Promise.resolve();
1157
+ return Promise.all([fonts, awaitImages(el, cap)]).then(() => new Promise((resolve) => {
328
1158
  let timer;
329
1159
  let capTimer;
330
1160
  const observer = new MutationObserver(bump);
@@ -345,7 +1175,8 @@ function settle(el, delay, cap) {
345
1175
  childList: true,
346
1176
  characterData: true
347
1177
  });
348
- if (resizeObserver) for (const child of Array.from(el.children)) resizeObserver.observe(child);
1178
+ if (resizeObserver) if (mode === "manual") resizeObserver.observe(el);
1179
+ else for (const child of Array.from(el.children)) resizeObserver.observe(child);
349
1180
  timer = setTimeout(done, delay);
350
1181
  capTimer = setTimeout(done, cap);
351
1182
  }));