@womp/mcp-ui 0.1.11 → 0.1.13

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.
@@ -0,0 +1,576 @@
1
+ import { $, callTool, createCard, pushModelContext, toggleFullscreen } from "../shared/bridge";
2
+
3
+ /**
4
+ * The camera card — the OpenAI MCP's first project (swan-backend
5
+ * docs/OPENAI_MCP.md). Brand-neutral: nothing here may say Womp.
6
+ *
7
+ * The 3D view is Carlos's hosted viewer (Kakapo on WebGPU) in a nested frame,
8
+ * pointed at the scene the server renders for the design's current revision.
9
+ * Two directions of change meet here:
10
+ *
11
+ * - The user drags a slider in the viewer's Customize panel. The viewer only
12
+ * answers `kakapo:getParams`, so the card asks every 1.5s, and a difference
13
+ * is saved with `sync_camera_design` (app-only). The viewer already shows it;
14
+ * no reload.
15
+ * - The model calls `update_camera_design`. That renders no card, so this card
16
+ * polls `get_camera_design` and reloads the viewer when the revision moved
17
+ * for any reason other than its own write.
18
+ *
19
+ * Colours are not viewer knobs (they live in the scene's materials), so a swatch
20
+ * saves and then reloads the viewer with the new revision. Each part is solid
21
+ * or a gradient of 2-3 colours; the colours come from the palette, the native
22
+ * colour picker, or the eyedropper where the browser has one.
23
+ *
24
+ * Ordering happens in the card: Order (or a `checkout_camera` result, which
25
+ * arrives with mode "checkout") opens the shipping form, and the form submits to
26
+ * the app-only `place_order` for the revision on screen. Nothing is charged.
27
+ */
28
+
29
+ interface DesignOption {
30
+ name: string;
31
+ label: string;
32
+ value: number;
33
+ display: string;
34
+ }
35
+
36
+ interface Design {
37
+ designId: string;
38
+ rev: number;
39
+ mode?: "design" | "checkout";
40
+ viewerUrl: string;
41
+ viewerOrigin: string;
42
+ sceneUrl: string;
43
+ options: DesignOption[];
44
+ /** Each part's colours: one is solid, more is a gradient. */
45
+ colors: Record<Slot, string[]>;
46
+ palette: { name: string; hex: string }[];
47
+ price: { amount: number; currency: string; label: string };
48
+ lastChange?: { source: string; writer: string; fields: string[] };
49
+ }
50
+
51
+ interface OrderResult {
52
+ orderId: string;
53
+ rev: number;
54
+ price: { label: string };
55
+ }
56
+
57
+ type Slot = "shell" | "button";
58
+ const SLOTS: Slot[] = ["shell", "button"];
59
+ type View = "design" | "order" | "done";
60
+
61
+ /** The most colours a gradient takes (the server holds the same limit). */
62
+ const MAX_COLORS = 3;
63
+
64
+ /** Chromium's EyeDropper API; absent elsewhere. */
65
+ type EyeDropperCtor = new () => { open(): Promise<{ sRGBHex: string }> };
66
+ const EyeDropper = (window as Window & { EyeDropper?: EyeDropperCtor }).EyeDropper;
67
+
68
+ const CONTACT_FIELDS = ["name", "email", "address", "city", "postcode", "country"] as const;
69
+
70
+ /** Marks this card's own writes, so its polling does not reload the viewer for them. */
71
+ const writer = `card-${Math.random().toString(36).slice(2, 10)}`;
72
+
73
+ const cardEl = $("card");
74
+ const viewer = $("viewer") as HTMLIFrameElement;
75
+ const fallback = $("fallback");
76
+ const form = $("order-form") as HTMLFormElement;
77
+
78
+ let design: Design | undefined;
79
+ /** Revision the viewer is showing. */
80
+ let loadedRev = 0;
81
+ let viewerReady = false;
82
+ /** Last knob values read back from the viewer. */
83
+ let viewerParams: Record<string, number> | undefined;
84
+ let webgpu: boolean | undefined;
85
+ let syncTimer: number | undefined;
86
+ let syncing: Promise<void> | undefined;
87
+ let readyTimer: number | undefined;
88
+ let pollTimer: number | undefined;
89
+ let lastActivity = Date.now();
90
+ let statusTimer: number | undefined;
91
+ /** Which colour of a part's gradient the swatches, picker and eyedropper set. */
92
+ const activeStop: Record<Slot, number> = { shell: 0, button: 0 };
93
+
94
+ function setStatus(text: string, clearAfterMs?: number) {
95
+ clearTimeout(statusTimer);
96
+ $("status").textContent = text;
97
+ if (clearAfterMs) statusTimer = window.setTimeout(() => ($("status").textContent = ""), clearAfterMs);
98
+ }
99
+
100
+ function showView(view: View) {
101
+ cardEl.dataset.view = view;
102
+ $("view-design").hidden = view !== "design";
103
+ $("view-order").hidden = view !== "order";
104
+ $("view-done").hidden = view !== "done";
105
+ }
106
+
107
+ const { app } = createCard("Camera", {
108
+ onInput: () => setStatus("Setting up your camera…"),
109
+ onResult: (raw) => {
110
+ const data = raw as unknown as Partial<Design>;
111
+ // The bridge also calls this with a bare { status: "running" } placeholder.
112
+ if (typeof data.designId !== "string") return;
113
+ adopt(data as Design);
114
+ },
115
+ });
116
+
117
+ /** A design arrived from the tool call that rendered this card. */
118
+ function adopt(next: Design) {
119
+ const isNewDesign = next.designId !== design?.designId;
120
+ design = next;
121
+ render();
122
+ if (isNewDesign) {
123
+ void loadViewer();
124
+ schedulePoll(2_500);
125
+ } else if (next.rev > loadedRev) {
126
+ void loadViewer();
127
+ }
128
+ if (next.mode === "checkout") openOrderForm();
129
+ }
130
+
131
+ // ---- the viewer -------------------------------------------------------------
132
+
133
+ async function detectWebGPU(): Promise<boolean> {
134
+ const gpu = (navigator as Navigator & { gpu?: { requestAdapter(): Promise<unknown> } }).gpu;
135
+ if (!gpu) return false;
136
+ try {
137
+ return Boolean(await gpu.requestAdapter());
138
+ } catch {
139
+ return false;
140
+ }
141
+ }
142
+
143
+ async function loadViewer(note?: string) {
144
+ if (!design) return;
145
+ if (webgpu === undefined) {
146
+ setStatus("Checking 3D support…");
147
+ webgpu = await detectWebGPU();
148
+ report(webgpu ? "webgpu-available" : "webgpu-missing");
149
+ }
150
+ loadedRev = design.rev;
151
+ if (!webgpu) {
152
+ showFallback();
153
+ setStatus("");
154
+ return;
155
+ }
156
+ viewerReady = false;
157
+ viewerParams = undefined;
158
+ fallback.hidden = true;
159
+ viewer.hidden = false;
160
+ viewer.src = design.viewerUrl;
161
+ setStatus(note ?? "Loading the 3D viewer…");
162
+ clearTimeout(readyTimer);
163
+ readyTimer = window.setTimeout(() => {
164
+ if (viewerReady) return;
165
+ setStatus("The 3D viewer hasn't loaded — this app may not allow it here.");
166
+ report("viewer-timeout");
167
+ }, 25_000);
168
+ }
169
+
170
+ function showFallback() {
171
+ if (!design) return;
172
+ viewer.hidden = true;
173
+ fallback.hidden = false;
174
+ fallback.textContent = "";
175
+ const p = document.createElement("p");
176
+ p.textContent =
177
+ "The 3D preview needs WebGPU, which this browser or device doesn't offer. You can still change the design here or in chat, and order it.";
178
+ const list = document.createElement("dl");
179
+ const rows: [string, string][] = [
180
+ ["Shell colour", paintText(design.colors.shell)],
181
+ ["Button colour", paintText(design.colors.button)],
182
+ ...design.options.map((o) => [o.label, o.display] as [string, string]),
183
+ ];
184
+ for (const [k, v] of rows) {
185
+ const dt = document.createElement("dt");
186
+ dt.textContent = k;
187
+ const dd = document.createElement("dd");
188
+ dd.textContent = v;
189
+ list.append(dt, dd);
190
+ }
191
+ fallback.append(p, list);
192
+ }
193
+
194
+ /** The viewer reports `scripts: [{ nodeId, name, params: { knob: value } }]`; knob names are unique across them. */
195
+ function flatten(scripts: unknown): Record<string, number> {
196
+ const out: Record<string, number> = {};
197
+ if (!Array.isArray(scripts)) return out;
198
+ for (const script of scripts) {
199
+ const params = (script as { params?: Record<string, unknown> })?.params ?? {};
200
+ for (const [name, value] of Object.entries(params)) {
201
+ if (typeof value === "number" && Number.isFinite(value)) out[name] = value;
202
+ }
203
+ }
204
+ return out;
205
+ }
206
+
207
+ window.addEventListener("message", (event) => {
208
+ if (!design || event.source !== viewer.contentWindow || event.origin !== design.viewerOrigin) return;
209
+ const msg = event.data as { type?: string; scripts?: unknown };
210
+ if (msg?.type === "kakapo:ready") {
211
+ viewerReady = true;
212
+ clearTimeout(readyTimer);
213
+ viewerParams = flatten(msg.scripts);
214
+ setStatus("");
215
+ report("viewer-ready");
216
+ } else if (msg?.type === "kakapo:params") {
217
+ onViewerParams(flatten(msg.scripts));
218
+ } else if (msg?.type === "kakapo:print") {
219
+ // The viewer's own Print button is our Order.
220
+ onViewerParams(flatten(msg.scripts));
221
+ openOrderForm();
222
+ }
223
+ });
224
+
225
+ // The viewer has no change event, so read its knobs on an interval while visible.
226
+ window.setInterval(() => {
227
+ if (!design || !viewerReady || document.visibilityState !== "visible") return;
228
+ viewer.contentWindow?.postMessage({ type: "kakapo:getParams" }, design.viewerOrigin);
229
+ }, 1_500);
230
+
231
+ function onViewerParams(params: Record<string, number>) {
232
+ if (!viewerParams) {
233
+ viewerParams = params;
234
+ return;
235
+ }
236
+ const before = viewerParams;
237
+ if (!Object.keys(params).some((k) => params[k] !== before[k])) return;
238
+ viewerParams = params;
239
+ lastActivity = Date.now();
240
+ setStatus("Saving…");
241
+ clearTimeout(syncTimer);
242
+ syncTimer = window.setTimeout(() => void flushSync(), 700);
243
+ }
244
+
245
+ // ---- saving -------------------------------------------------------------------
246
+
247
+ /**
248
+ * Save the viewer's knobs (and a part's colours, if they changed). Serialised,
249
+ * so two quick changes cannot race each other's baseRev.
250
+ */
251
+ async function flushSync(colors?: Partial<Record<Slot, string[]>>) {
252
+ clearTimeout(syncTimer);
253
+ syncTimer = undefined;
254
+ while (syncing) await syncing;
255
+ if (!design) return;
256
+ const baseRev = design.rev;
257
+ syncing = (async () => {
258
+ try {
259
+ const res = await callTool(app, "sync_camera_design", {
260
+ designId: design!.designId,
261
+ baseRev,
262
+ writer,
263
+ params: viewerParams ?? {},
264
+ ...(colors ? { colors } : {}),
265
+ });
266
+ const next = res.structuredContent as Design | undefined;
267
+ if (res.isError || !next) {
268
+ setStatus("Couldn't save that change.");
269
+ return;
270
+ }
271
+ design = next;
272
+ render();
273
+ if (next.rev === baseRev) {
274
+ setStatus("");
275
+ return;
276
+ }
277
+ // Anything besides our own write since baseRev (a chat change merged in)
278
+ // means the viewer is behind. So is a colour, which it cannot set itself.
279
+ const merged = next.rev > baseRev + 1;
280
+ if (merged || colors) void loadViewer(merged ? "Merged with a change from the chat…" : "Applying colour…");
281
+ else loadedRev = next.rev;
282
+ if (!merged && !colors) setStatus("Saved", 1_500);
283
+ void pushModelContext(app, contextLine(next));
284
+ } catch {
285
+ setStatus("Couldn't save that change.");
286
+ }
287
+ })();
288
+ try {
289
+ await syncing;
290
+ } finally {
291
+ syncing = undefined;
292
+ }
293
+ }
294
+
295
+ /** "coral", or "coral → violet gradient" */
296
+ function paintText(paint: string[]) {
297
+ return paint.length > 1 ? `${paint.join(" → ")} gradient` : paint[0];
298
+ }
299
+
300
+ const contextLine = (d: Design) =>
301
+ `The user adjusted their camera in the card. Design ${d.designId}, revision ${d.rev}: ` +
302
+ `shell ${paintText(d.colors.shell)}, button ${paintText(d.colors.button)}; ` +
303
+ d.options.map((o) => `${o.label} ${o.display}`).join(", ") +
304
+ ".";
305
+
306
+ // ---- picking up chat changes ----------------------------------------------------
307
+
308
+ function schedulePoll(ms: number) {
309
+ clearTimeout(pollTimer);
310
+ pollTimer = window.setTimeout(poll, ms);
311
+ }
312
+
313
+ async function poll() {
314
+ const idle = Date.now() - lastActivity;
315
+ try {
316
+ if (design && document.visibilityState === "visible" && !syncing) {
317
+ const res = await callTool(app, "get_camera_design", { designId: design.designId });
318
+ const next = res.structuredContent as Design | undefined;
319
+ if (!res.isError && next && next.rev > design.rev) {
320
+ lastActivity = Date.now();
321
+ if (syncTimer !== undefined) {
322
+ // A slider move is waiting to be saved: save it first, so the reload
323
+ // below shows the chat's change AND the user's.
324
+ await flushSync();
325
+ } else {
326
+ design = next;
327
+ render();
328
+ if (next.rev > loadedRev) void loadViewer("Updated from the chat…");
329
+ }
330
+ }
331
+ }
332
+ } catch {
333
+ // A failed poll is not a broken card; try again on the next tick.
334
+ }
335
+ // Brisk while someone is working on it, lazy once they have moved on.
336
+ schedulePoll(idle < 120_000 ? 2_500 : idle < 600_000 ? 8_000 : 20_000);
337
+ }
338
+
339
+ // ---- controls -----------------------------------------------------------------
340
+
341
+ /** A colour as CSS: a palette name's swatch, or the hex itself. */
342
+ function cssColor(color: string) {
343
+ return design?.palette.find((p) => p.name === color)?.hex ?? color;
344
+ }
345
+
346
+ function render() {
347
+ if (!design) return;
348
+ for (const slot of SLOTS) {
349
+ const paint = design.colors[slot];
350
+ const gradient = paint.length > 1;
351
+ activeStop[slot] = Math.min(activeStop[slot], paint.length - 1);
352
+ const chosen = paint[activeStop[slot]];
353
+
354
+ $(`solid-${slot}`).setAttribute("aria-pressed", String(!gradient));
355
+ $(`gradient-${slot}`).setAttribute("aria-pressed", String(gradient));
356
+ renderStops(slot, paint);
357
+
358
+ const row = $(`sw-${slot}`);
359
+ row.textContent = "";
360
+ const entries = [...design.palette];
361
+ // A hex (from the model, the picker or the eyedropper) has no swatch of its own — show it first.
362
+ for (const color of [...new Set(paint)].reverse()) {
363
+ if (color.startsWith("#") && !entries.some((p) => p.name === color)) entries.unshift({ name: color, hex: color });
364
+ }
365
+ for (const p of entries) {
366
+ const b = document.createElement("button");
367
+ b.type = "button";
368
+ b.className = p.name === "clear" ? "swatch clear" : "swatch";
369
+ b.style.background = p.hex;
370
+ b.title = p.name;
371
+ b.setAttribute("aria-label", `${slot} colour: ${p.name}`);
372
+ b.setAttribute("aria-pressed", String(chosen === p.name));
373
+ b.addEventListener("click", () => void pickColor(slot, p.name));
374
+ row.append(b);
375
+ }
376
+ ($(`pick-${slot}`) as HTMLInputElement).value = cssColor(chosen);
377
+ $(`dropper-${slot}`).hidden = !EyeDropper;
378
+ }
379
+ $("price").textContent = `Full-colour print · ${design.price.label}`;
380
+ $("order-summary").textContent =
381
+ `Your camera, printed in full colour: ${design.price.label}. ` +
382
+ `Shell ${paintText(design.colors.shell)}, button ${paintText(design.colors.button)}.`;
383
+ if (!fallback.hidden) showFallback();
384
+ }
385
+
386
+ /** A gradient's colours on a track, first to last; picking one makes it the one the swatches set. */
387
+ function renderStops(slot: Slot, paint: string[]) {
388
+ const box = $(`stops-${slot}`);
389
+ box.hidden = paint.length < 2;
390
+ box.textContent = "";
391
+ if (paint.length < 2) return;
392
+ const track = document.createElement("div");
393
+ track.className = "track";
394
+ track.style.background = `linear-gradient(90deg, ${paint.map(cssColor).join(", ")})`;
395
+ paint.forEach((color, i) => {
396
+ const stop = document.createElement("button");
397
+ stop.type = "button";
398
+ stop.className = "stop";
399
+ stop.style.left = `calc(11px + (100% - 22px) * ${i / (paint.length - 1)})`;
400
+ stop.style.background = cssColor(color);
401
+ stop.title = color;
402
+ stop.setAttribute("aria-label", `${slot} gradient colour ${i + 1} of ${paint.length}: ${color}`);
403
+ stop.setAttribute("aria-pressed", String(i === activeStop[slot]));
404
+ stop.addEventListener("click", () => {
405
+ activeStop[slot] = i;
406
+ render();
407
+ });
408
+ track.append(stop);
409
+ });
410
+ const add = iconButton("+", `${slot}: add a gradient colour`, paint.length >= MAX_COLORS, () => {
411
+ activeStop[slot] = paint.length;
412
+ void setPaint(slot, [...paint, partner(paint[paint.length - 1])]);
413
+ });
414
+ const remove = iconButton("−", `${slot}: remove the selected gradient colour`, paint.length <= 2, () => {
415
+ void setPaint(slot, paint.filter((_, i) => i !== activeStop[slot]));
416
+ });
417
+ box.append(track, add, remove);
418
+ }
419
+
420
+ function iconButton(text: string, label: string, disabled: boolean, onClick: () => void) {
421
+ const b = document.createElement("button");
422
+ b.type = "button";
423
+ b.className = "icon-button";
424
+ b.textContent = text;
425
+ b.title = label;
426
+ b.setAttribute("aria-label", label);
427
+ b.disabled = disabled;
428
+ b.addEventListener("click", onClick);
429
+ return b;
430
+ }
431
+
432
+ /** A colour that reads well next to `color`, for a gradient's new end. */
433
+ function partner(color: string) {
434
+ return color === "violet" ? "sky" : "violet";
435
+ }
436
+
437
+ /** The eyedropper's answer as #rrggbb; some Chromium versions say `rgb(r, g, b)`. */
438
+ function toHex(color: string) {
439
+ const rgb = /^rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(color);
440
+ if (!rgb) return color.toLowerCase();
441
+ return `#${rgb.slice(1, 4).map((c) => Number(c).toString(16).padStart(2, "0")).join("")}`;
442
+ }
443
+
444
+ /** Set the colour being edited: the part's only colour, or the selected one of its gradient. */
445
+ async function pickColor(slot: Slot, color: string) {
446
+ if (!design) return;
447
+ const paint = [...design.colors[slot]];
448
+ paint[activeStop[slot]] = color;
449
+ await setPaint(slot, paint);
450
+ }
451
+
452
+ async function setPaint(slot: Slot, paint: string[]) {
453
+ if (!design) return;
454
+ const current = design.colors[slot];
455
+ if (paint.length === current.length && paint.every((c, i) => c === current[i])) return;
456
+ lastActivity = Date.now();
457
+ // Show it at once; the save's answer replaces it.
458
+ design = { ...design, colors: { ...design.colors, [slot]: paint } };
459
+ render();
460
+ setStatus("Saving…");
461
+ await flushSync({ [slot]: paint });
462
+ }
463
+
464
+ for (const slot of SLOTS) {
465
+ $(`solid-${slot}`).addEventListener("click", () => {
466
+ if (!design || design.colors[slot].length < 2) return;
467
+ const keep = design.colors[slot][activeStop[slot]];
468
+ activeStop[slot] = 0;
469
+ void setPaint(slot, [keep]);
470
+ });
471
+ $(`gradient-${slot}`).addEventListener("click", () => {
472
+ if (!design || design.colors[slot].length > 1) return;
473
+ const [only] = design.colors[slot];
474
+ activeStop[slot] = 1;
475
+ void setPaint(slot, [only, partner(only)]);
476
+ });
477
+ // `change`, not `input`: the picker fires `input` on every drag, and each save reloads the viewer.
478
+ $(`pick-${slot}`).addEventListener("change", (event) => {
479
+ void pickColor(slot, (event.target as HTMLInputElement).value.toLowerCase());
480
+ });
481
+ $(`dropper-${slot}`).addEventListener("click", async () => {
482
+ if (!EyeDropper) return;
483
+ try {
484
+ const { sRGBHex } = await new EyeDropper().open();
485
+ await pickColor(slot, toHex(sRGBHex));
486
+ } catch {
487
+ // Escape cancels it; a host may also refuse it.
488
+ }
489
+ });
490
+ }
491
+
492
+ // ---- ordering -----------------------------------------------------------------
493
+
494
+ function openOrderForm() {
495
+ if (!design) return;
496
+ lastActivity = Date.now();
497
+ showView("order");
498
+ setStatus("");
499
+ (form.elements.namedItem("name") as HTMLInputElement | null)?.focus();
500
+ }
501
+
502
+ /** The form's values, or undefined after marking what is missing. */
503
+ function readContact(): Record<(typeof CONTACT_FIELDS)[number], string> | undefined {
504
+ const contact = {} as Record<(typeof CONTACT_FIELDS)[number], string>;
505
+ let ok = true;
506
+ for (const name of CONTACT_FIELDS) {
507
+ const input = form.elements.namedItem(name) as HTMLInputElement;
508
+ const value = input.value.trim();
509
+ const valid = name === "email" ? /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) : value.length > 0;
510
+ input.setAttribute("aria-invalid", String(!valid));
511
+ if (!valid) ok = false;
512
+ contact[name] = value;
513
+ }
514
+ return ok ? contact : undefined;
515
+ }
516
+
517
+ async function placeOrder() {
518
+ if (!design) return;
519
+ const contact = readContact();
520
+ if (!contact) {
521
+ setStatus("Fill in the highlighted fields.");
522
+ return;
523
+ }
524
+ const button = $("order-place") as HTMLButtonElement;
525
+ button.disabled = true;
526
+ setStatus("Placing your order…");
527
+ try {
528
+ // Order what is on screen: save any pending knob turn first.
529
+ if (syncTimer !== undefined || syncing) await flushSync();
530
+ const res = await callTool(app, "place_order", { designId: design.designId, rev: design.rev, writer, contact });
531
+ const order = res.structuredContent as OrderResult | undefined;
532
+ if (res.isError || !order) {
533
+ setStatus("The order didn't go through. Check the details and try again.");
534
+ return;
535
+ }
536
+ $("done-text").textContent = `Test order ${order.orderId} is recorded (${order.price.label}). Nothing was charged.`;
537
+ showView("done");
538
+ setStatus("");
539
+ void pushModelContext(app, `The user placed test order ${order.orderId} for camera design ${design.designId}, revision ${order.rev}.`);
540
+ } catch {
541
+ setStatus("The order didn't go through. Try again in a moment.");
542
+ } finally {
543
+ button.disabled = false;
544
+ }
545
+ }
546
+
547
+ $("customize").addEventListener("click", () => {
548
+ lastActivity = Date.now();
549
+ void toggleFullscreen(app);
550
+ });
551
+ $("order").addEventListener("click", openOrderForm);
552
+ $("order-back").addEventListener("click", () => showView("design"));
553
+ $("order-place").addEventListener("click", () => void placeOrder());
554
+ $("done-back").addEventListener("click", () => showView("design"));
555
+ form.addEventListener("submit", (event) => {
556
+ event.preventDefault();
557
+ void placeOrder();
558
+ });
559
+
560
+ // The bridge sets data-display-mode from the host; keep the toggle's label honest.
561
+ new MutationObserver(() => {
562
+ $("customize").textContent = document.documentElement.dataset.displayMode === "fullscreen" ? "Done" : "Customize";
563
+ }).observe(document.documentElement, { attributes: true, attributeFilter: ["data-display-mode"] });
564
+
565
+ /** Whether the viewer could run, and where — the data that settles "how many sessions have WebGPU". */
566
+ function report(event: string, detail?: string) {
567
+ void callTool(app, "report_viewer_status", {
568
+ ...(design ? { designId: design.designId } : {}),
569
+ writer,
570
+ event,
571
+ ...(webgpu === undefined ? {} : { webgpu }),
572
+ ...(detail ? { detail } : {}),
573
+ userAgent: navigator.userAgent.slice(0, 300),
574
+ displayMode: document.documentElement.dataset.displayMode ?? "inline",
575
+ }).catch(() => {});
576
+ }