@womp/mcp-ui 0.1.19 → 0.1.21

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.
@@ -24,6 +24,13 @@ import { $, callTool, createCard, openExternal, pushModelContext, toggleFullscre
24
24
  * colours come from the palette, the native colour picker, or the eyedropper
25
25
  * where the browser has one.
26
26
  *
27
+ * Inline, the card is the render, the two colours, the price, Customize and
28
+ * Order. Customize goes fullscreen, where the same card grows every control:
29
+ * gradients, the picker, and the shape knobs under plain names. The card owns
30
+ * those controls; the viewer is asked to hide its own panel and Print button
31
+ * (`embed=1`). Until it takes `kakapo:setParams`, a knob turned here saves and
32
+ * swaps in the new revision, the way a colour does.
33
+ *
27
34
  * Order gets the quote from `checkout_camera` and opens its checkout link on
28
35
  * the web, all from here; paying happens there. In the chat, "order it" makes
29
36
  * the model answer with the quote card (src/openai-quote) instead.
@@ -34,6 +41,16 @@ interface DesignOption {
34
41
  label: string;
35
42
  value: number;
36
43
  display: string;
44
+ /** What the card needs to draw the control. A server without them leaves the knob out. */
45
+ kind?: "number" | "choice";
46
+ min?: number;
47
+ max?: number;
48
+ step?: number;
49
+ unit?: string;
50
+ choices?: { value: number; label: string }[];
51
+ /** Which section it sits in; the first in a section shows, the rest fold under "More". */
52
+ group?: string;
53
+ hint?: string;
37
54
  }
38
55
 
39
56
  interface Design {
@@ -54,6 +71,8 @@ interface Design {
54
71
 
55
72
  type Slot = "shell" | "button";
56
73
  const SLOTS: Slot[] = ["shell", "button"];
74
+ /** What each part is called on screen: plain words, not the template's. */
75
+ const PART_LABEL: Record<Slot, string> = { shell: "Body", button: "Shutter button" };
57
76
 
58
77
  /** The most colours a gradient takes (the server holds the same limit). */
59
78
  const MAX_COLORS = 3;
@@ -86,6 +105,8 @@ let viewerReady = false;
86
105
  let viewerDesignId: string | undefined;
87
106
  /** Whether the viewer takes a new revision by message instead of a reload. */
88
107
  let viewerCanSwap = false;
108
+ /** Whether the viewer takes knob values by message, so a knob needs no new scene. */
109
+ let viewerCanSetParams = false;
89
110
  /** Where to post to the viewer: its origin, or "*" when it speaks as "null" (an opaque host). */
90
111
  let viewerTarget = "*";
91
112
  /** The scene URL a swap is waiting on, and the reload that stands in if it never lands. */
@@ -102,6 +123,8 @@ let readyTimer: number | undefined;
102
123
  let pollTimer: number | undefined;
103
124
  let lastActivity = Date.now();
104
125
  let statusTimer: number | undefined;
126
+ /** Which part the swatches paint: one at a time keeps the card calm. */
127
+ let activePart: Slot = "shell";
105
128
  /** Which colour of a part's gradient the swatches, picker and eyedropper set. */
106
129
  const activeStop: Record<Slot, number> = { shell: 0, button: 0 };
107
130
 
@@ -178,13 +201,18 @@ function reloadViewer(note?: string) {
178
201
  if (!design) return;
179
202
  viewerReady = false;
180
203
  viewerCanSwap = false;
204
+ viewerCanSetParams = false;
181
205
  viewerParams = undefined;
182
206
  viewerDesignId = design.designId;
183
207
  pendingSwap = undefined;
184
208
  clearTimeout(swapTimer);
185
- fallback.hidden = true;
186
209
  viewer.hidden = false;
187
- viewer.src = design.viewerUrl;
210
+ // The drawing stands in until the viewer says it is ready.
211
+ drawPreview(false);
212
+ // The card owns the controls: ask the viewer to drop its own panel and Print button.
213
+ const src = new URL(design.viewerUrl);
214
+ src.searchParams.set("embed", "1");
215
+ viewer.src = src.toString();
188
216
  setStatus(note ?? "Loading the 3D viewer…");
189
217
  clearTimeout(readyTimer);
190
218
  readyTimer = window.setTimeout(() => {
@@ -197,25 +225,58 @@ function reloadViewer(note?: string) {
197
225
  function showFallback() {
198
226
  if (!design) return;
199
227
  viewer.hidden = true;
228
+ drawPreview(true);
229
+ }
230
+
231
+ /** The flat drawing over the stage: for good without WebGPU, or until the viewer is ready. */
232
+ function drawPreview(final: boolean) {
233
+ if (!design) return;
200
234
  fallback.hidden = false;
201
- fallback.textContent = "";
202
- const p = document.createElement("p");
203
- p.textContent =
204
- "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.";
205
- const list = document.createElement("dl");
206
- const rows: [string, string][] = [
207
- ["Shell colour", paintText(design.colors.shell, design.gradientAngles.shell)],
208
- ["Button colour", paintText(design.colors.button, design.gradientAngles.button)],
209
- ...design.options.map((o) => [o.label, o.display] as [string, string]),
210
- ];
211
- for (const [k, v] of rows) {
212
- const dt = document.createElement("dt");
213
- dt.textContent = k;
214
- const dd = document.createElement("dd");
215
- dd.textContent = v;
216
- list.append(dt, dd);
235
+ fallback.style.opacity = "";
236
+ fallback.innerHTML = cameraSvg(design);
237
+ if (final) {
238
+ const note = document.createElement("p");
239
+ note.textContent = "3D preview isn't available here. Colours still update.";
240
+ fallback.append(note);
217
241
  }
218
- fallback.append(p, list);
242
+ }
243
+
244
+ /** A part's paint as an SVG fill: a flat colour, or a gradient defined in `defs`. */
245
+ function svgPaint(slot: Slot, d: Design, defs: string[]) {
246
+ const paint = d.colors[slot].map(cssColor);
247
+ if (paint.length < 2) return paint[0];
248
+ // The angle turns the gradient the way the viewer does: 0° runs along the part's own axis.
249
+ const angle = (ARROW_AT_0[slot] - d.gradientAngles[slot] + 360) % 360;
250
+ const rad = (angle * Math.PI) / 180;
251
+ const x = Math.cos(rad) / 2;
252
+ const y = Math.sin(rad) / 2;
253
+ const stops = paint.map((c, i) => `<stop offset="${i / (paint.length - 1)}" stop-color="${c}"/>`).join("");
254
+ defs.push(
255
+ `<linearGradient id="g-${slot}" x1="${0.5 - x}" y1="${0.5 - y}" x2="${0.5 + x}" y2="${0.5 + y}">${stops}</linearGradient>`,
256
+ );
257
+ return `url(#g-${slot})`;
258
+ }
259
+
260
+ /** A flat drawing of the camera in its colours, for when the 3D view can't run. */
261
+ function cameraSvg(d: Design) {
262
+ const defs: string[] = [];
263
+ const shell = svgPaint("shell", d, defs);
264
+ const button = svgPaint("button", d, defs);
265
+ return (
266
+ `<svg viewBox="0 0 220 160" role="img" aria-label="Your camera: ${describe("shell", d)} body, ${describe("button", d)} shutter button">` +
267
+ `<defs>${defs.join("")}</defs>` +
268
+ `<rect x="150" y="12" width="40" height="22" rx="8" fill="${button}" stroke="rgba(0,0,0,.18)"/>` +
269
+ `<circle cx="34" cy="38" r="14" fill="none" stroke="rgba(0,0,0,.28)" stroke-width="6"/>` +
270
+ `<rect x="10" y="28" width="200" height="124" rx="30" fill="${shell}" stroke="rgba(0,0,0,.16)"/>` +
271
+ `<rect x="10" y="28" width="200" height="124" rx="30" fill="url(#shine)" opacity=".5"/>` +
272
+ `<circle cx="120" cy="92" r="40" fill="#1d1d20"/>` +
273
+ `<circle cx="120" cy="92" r="30" fill="#2c2c33" stroke="#47474f" stroke-width="2"/>` +
274
+ `<circle cx="120" cy="92" r="14" fill="#101014"/>` +
275
+ `<circle cx="110" cy="82" r="5" fill="#ffffff" opacity=".55"/>` +
276
+ `<circle cx="184" cy="54" r="5" fill="#ff4d4d"/>` +
277
+ `<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity=".7"/><stop offset=".45" stop-color="#fff" stop-opacity="0"/></linearGradient>` +
278
+ `</svg>`
279
+ );
219
280
  }
220
281
 
221
282
  /** The viewer reports `scripts: [{ nodeId, name, params: { knob: value } }]`; knob names are unique across them. */
@@ -247,6 +308,8 @@ window.addEventListener("message", (event) => {
247
308
  viewerReady = true;
248
309
  viewerTarget = event.origin === "null" ? "*" : event.origin;
249
310
  viewerCanSwap = Array.isArray(msg.messages) && msg.messages.includes("kakapo:setScene");
311
+ viewerCanSetParams = Array.isArray(msg.messages) && msg.messages.includes("kakapo:setParams");
312
+ fallback.hidden = true;
250
313
  clearTimeout(readyTimer);
251
314
  viewerParams = flatten(msg.scripts);
252
315
  setStatus("");
@@ -266,7 +329,7 @@ window.addEventListener("message", (event) => {
266
329
  onViewerParams(flatten(msg.scripts));
267
330
  for (const done of paramsWaiters.splice(0)) done();
268
331
  } else if (msg?.type === "kakapo:print") {
269
- // The viewer's own Print button is our Order.
332
+ // A viewer that ignores embed=1 still shows Print: treat it as Order.
270
333
  onViewerParams(flatten(msg.scripts));
271
334
  void order();
272
335
  }
@@ -414,14 +477,25 @@ function cssColor(color: string) {
414
477
  return design?.palette.find((p) => p.name === color)?.hex ?? color;
415
478
  }
416
479
 
480
+ const PAINT_NAME = (paint: string[]) =>
481
+ paint.length > 1 ? "Gradient" : paint[0].startsWith("#") ? "Custom" : paint[0][0].toUpperCase() + paint[0].slice(1);
482
+
417
483
  function render() {
418
484
  if (!design) return;
485
+ $("card").dataset.ready = "";
486
+ const full = document.documentElement.dataset.displayMode === "fullscreen";
419
487
  for (const slot of SLOTS) {
420
488
  const paint = design.colors[slot];
421
489
  const gradient = paint.length > 1;
422
490
  activeStop[slot] = Math.min(activeStop[slot], paint.length - 1);
423
- const chosen = paint[activeStop[slot]];
424
-
491
+ // Inline there is no gradient editing: a swatch replaces the whole part.
492
+ const chosen = gradient && !full ? undefined : paint[activeStop[slot]];
493
+
494
+ $(`note-${slot}`).textContent = PAINT_NAME(paint);
495
+ $(`dot-${slot}`).style.background =
496
+ paint.length > 1 ? `linear-gradient(135deg, ${paint.map(cssColor).join(", ")})` : cssColor(paint[0]);
497
+ $(`tab-${slot}`).setAttribute("aria-selected", String(slot === activePart));
498
+ $(`group-${slot}`).hidden = slot !== activePart;
425
499
  $(`solid-${slot}`).setAttribute("aria-pressed", String(!gradient));
426
500
  $(`gradient-${slot}`).setAttribute("aria-pressed", String(gradient));
427
501
  renderStops(slot, paint);
@@ -439,16 +513,180 @@ function render() {
439
513
  b.className = p.name === "clear" ? "swatch clear" : "swatch";
440
514
  b.style.background = p.hex;
441
515
  b.title = p.name;
442
- b.setAttribute("aria-label", `${slot} colour: ${p.name}`);
516
+ b.setAttribute("aria-label", `${PART_LABEL[slot]} colour: ${p.name}`);
443
517
  b.setAttribute("aria-pressed", String(chosen === p.name));
444
- b.addEventListener("click", () => void pickColor(slot, p.name));
518
+ b.addEventListener("click", () => void (full ? pickColor(slot, p.name) : setPaint(slot, [p.name])));
445
519
  row.append(b);
446
520
  }
447
- ($(`pick-${slot}`) as HTMLInputElement).value = cssColor(chosen);
448
- $(`dropper-${slot}`).hidden = !EyeDropper;
521
+
522
+ // Any colour: the native picker, then the eyedropper where the browser has one.
523
+ const pick = document.createElement("label");
524
+ pick.className = "pick";
525
+ pick.title = "Any colour";
526
+ const input = document.createElement("input");
527
+ input.type = "color";
528
+ input.setAttribute("aria-label", `${PART_LABEL[slot]}: any colour`);
529
+ input.value = toHex(cssColor(chosen ?? paint[0]));
530
+ // `change`, not `input`: the picker fires `input` on every drag, and each save reloads the viewer.
531
+ input.addEventListener("change", () => {
532
+ const hex = input.value.toLowerCase();
533
+ void (full ? pickColor(slot, hex) : setPaint(slot, [hex]));
534
+ });
535
+ pick.append(input);
536
+ row.append(pick);
537
+ if (EyeDropper && full) {
538
+ const dropper = iconButton("", `${PART_LABEL[slot]}: pick a colour from the screen`, false, async () => {
539
+ try {
540
+ const { sRGBHex } = await new EyeDropper().open();
541
+ await pickColor(slot, toHex(sRGBHex));
542
+ } catch {
543
+ // Escape cancels it; a host may also refuse it.
544
+ }
545
+ });
546
+ dropper.innerHTML = DROPPER;
547
+ dropper.classList.add("tool");
548
+ row.append(dropper);
549
+ }
550
+ }
551
+ renderKnobs();
552
+
553
+ $("price").textContent = design.price.label;
554
+ $("price-inline").textContent = design.price.label;
555
+ if (!fallback.hidden) drawPreview(viewer.hidden);
556
+ }
557
+
558
+ /** An eyedropper (Lucide `pipette`, ISC). */
559
+ const DROPPER =
560
+ '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m2 22 1-1h3l9-9"/><path d="M3 21v-3l9-9"/><path d="m15 6 3.4-3.4a2.1 2.1 0 1 1 3 3L18 9l.4.4a2.1 2.1 0 1 1-3 3l-3.8-3.8a2.1 2.1 0 1 1 3-3l.4.4Z"/></svg>';
561
+
562
+ // ---- shape knobs ----------------------------------------------------------------
563
+
564
+ /** Section titles, in the order they show. A knob with no group goes last. */
565
+ const GROUPS: Record<string, string> = { shape: "Shape", loop: "Strap loop", button: "Shutter button" };
566
+ /** A knob being dragged: re-rendering under it would drop the drag. */
567
+ let dragging: string | undefined;
568
+
569
+ function describe(slot: Slot, d: Design) {
570
+ const paint = d.colors[slot];
571
+ return paint.length > 1 ? paint.join(" to ") : PAINT_NAME(paint).toLowerCase();
572
+ }
573
+
574
+ function renderKnobs() {
575
+ const box = $("knobs");
576
+ if (!design || dragging) return;
577
+ const knobs = design.options.filter((o) => o.kind === "choice" ? o.choices?.length : o.kind === "number");
578
+ box.hidden = knobs.length === 0;
579
+ // Keep which "More" folds are open across re-renders.
580
+ const open = new Set([...box.querySelectorAll("details[open]")].map((d) => (d as HTMLElement).dataset.group));
581
+ box.textContent = "";
582
+ const groups = new Map<string, DesignOption[]>();
583
+ for (const k of knobs) {
584
+ const g = k.group && GROUPS[k.group] ? k.group : "other";
585
+ groups.set(g, [...(groups.get(g) ?? []), k]);
586
+ }
587
+ const order = [...Object.keys(GROUPS), "other"].filter((g) => groups.has(g));
588
+ for (const g of order) {
589
+ const section = document.createElement("section");
590
+ section.className = "section";
591
+ const h = document.createElement("h3");
592
+ h.textContent = GROUPS[g] ?? "More";
593
+ section.append(h);
594
+ const [first, ...rest] = groups.get(g)!;
595
+ section.append(knobControl(first));
596
+ if (rest.length) {
597
+ const more = document.createElement("details");
598
+ more.className = "more";
599
+ more.dataset.group = g;
600
+ more.open = open.has(g);
601
+ const summary = document.createElement("summary");
602
+ summary.textContent = `More ${(GROUPS[g] ?? "options").toLowerCase()} options`;
603
+ const inner = document.createElement("div");
604
+ for (const k of rest) inner.append(knobControl(k));
605
+ more.append(summary, inner);
606
+ section.append(more);
607
+ }
608
+ box.append(section);
609
+ }
610
+ }
611
+
612
+ function formatKnob(k: DesignOption, value: number) {
613
+ if (k.kind === "choice") return k.choices?.find((c) => c.value === value)?.label ?? String(value);
614
+ const decimals = k.step && k.step < 1 ? Math.min(2, String(k.step).split(".")[1]?.length ?? 1) : 0;
615
+ return `${value.toFixed(decimals)}${k.unit ? (k.unit === "°" ? "°" : ` ${k.unit}`) : ""}`;
616
+ }
617
+
618
+ function knobControl(k: DesignOption) {
619
+ const wrap = document.createElement("div");
620
+ wrap.className = "knob";
621
+ const head = document.createElement("div");
622
+ head.className = "knob-head";
623
+ const name = document.createElement("span");
624
+ name.textContent = k.label;
625
+ if (k.hint) name.title = k.hint;
626
+ head.append(name);
627
+ wrap.append(head);
628
+
629
+ if (k.kind === "choice") {
630
+ name.id = `knob-${k.name}`;
631
+ const seg = document.createElement("div");
632
+ seg.className = "segmented";
633
+ seg.setAttribute("role", "group");
634
+ seg.setAttribute("aria-labelledby", name.id);
635
+ for (const c of k.choices ?? []) {
636
+ const b = document.createElement("button");
637
+ b.type = "button";
638
+ b.textContent = c.label;
639
+ b.setAttribute("aria-pressed", String(c.value === k.value));
640
+ b.addEventListener("click", () => void setKnob(k.name, c.value));
641
+ seg.append(b);
642
+ }
643
+ wrap.append(seg);
644
+ return wrap;
645
+ }
646
+
647
+ const out = document.createElement("output");
648
+ out.textContent = formatKnob(k, k.value);
649
+ head.append(out);
650
+ const range = document.createElement("input");
651
+ range.type = "range";
652
+ range.min = String(k.min);
653
+ range.max = String(k.max);
654
+ range.step = String(k.step ?? 1);
655
+ range.value = String(k.value);
656
+ range.setAttribute("aria-label", k.label);
657
+ const fill = () => range.style.setProperty("--fill", `${((Number(range.value) - k.min!) / (k.max! - k.min! || 1)) * 100}%`);
658
+ fill();
659
+ range.addEventListener("input", () => {
660
+ dragging = k.name;
661
+ out.textContent = formatKnob(k, Number(range.value));
662
+ fill();
663
+ });
664
+ // Save on release: every save is a new revision of the scene.
665
+ range.addEventListener("change", () => {
666
+ dragging = undefined;
667
+ void setKnob(k.name, Number(range.value));
668
+ });
669
+ wrap.append(range);
670
+ return wrap;
671
+ }
672
+
673
+ /** Set one shape knob from the card's own control. */
674
+ async function setKnob(name: string, value: number) {
675
+ if (!design) return;
676
+ lastActivity = Date.now();
677
+ design = { ...design, options: design.options.map((o) => (o.name === name ? { ...o, value } : o)) };
678
+ render();
679
+ setStatus("Saving…");
680
+ await freshViewerParams();
681
+ viewerParams = { ...(viewerParams ?? {}), [name]: value };
682
+ if (viewerCanSetParams) {
683
+ // The viewer shows it at once; the save only records it.
684
+ viewer.contentWindow?.postMessage({ type: "kakapo:setParams", params: { [name]: value } }, viewerTarget);
685
+ await flushSync();
686
+ } else {
687
+ // An empty finish still means "the viewer is behind": swap in the saved revision.
688
+ await flushSync({});
449
689
  }
450
- $("price").textContent = `Full-colour print · ${design.price.label} + shipping`;
451
- if (!fallback.hidden) showFallback();
452
690
  }
453
691
 
454
692
  /** A gradient's colours on a track, first to last; picking one makes it the one the swatches set. */
@@ -467,7 +705,7 @@ function renderStops(slot: Slot, paint: string[]) {
467
705
  stop.style.left = `calc(11px + (100% - 22px) * ${i / (paint.length - 1)})`;
468
706
  stop.style.background = cssColor(color);
469
707
  stop.title = color;
470
- stop.setAttribute("aria-label", `${slot} gradient colour ${i + 1} of ${paint.length}: ${color}`);
708
+ stop.setAttribute("aria-label", `${PART_LABEL[slot]} gradient colour ${i + 1} of ${paint.length}: ${color}`);
471
709
  stop.setAttribute("aria-pressed", String(i === activeStop[slot]));
472
710
  stop.addEventListener("click", () => {
473
711
  activeStop[slot] = i;
@@ -475,15 +713,15 @@ function renderStops(slot: Slot, paint: string[]) {
475
713
  });
476
714
  track.append(stop);
477
715
  });
478
- const add = iconButton("+", `${slot}: add a gradient colour`, paint.length >= MAX_COLORS, () => {
716
+ const add = iconButton("+", `${PART_LABEL[slot]}: add a gradient colour`, paint.length >= MAX_COLORS, () => {
479
717
  activeStop[slot] = paint.length;
480
718
  void setPaint(slot, [...paint, partner(paint[paint.length - 1])]);
481
719
  });
482
- const remove = iconButton("−", `${slot}: remove the selected gradient colour`, paint.length <= 2, () => {
720
+ const remove = iconButton("−", `${PART_LABEL[slot]}: remove the selected gradient colour`, paint.length <= 2, () => {
483
721
  void setPaint(slot, paint.filter((_, i) => i !== activeStop[slot]));
484
722
  });
485
723
  const angle = design?.gradientAngles[slot] ?? 0;
486
- const turn = iconButton("", `${slot}: turn the gradient 45° (now ${angle}°)`, false, () => void setAngle(slot, (angle + 45) % 360));
724
+ const turn = iconButton("", `${PART_LABEL[slot]}: turn the gradient 45° (now ${angle}°)`, false, () => void setAngle(slot, (angle + 45) % 360));
487
725
  // The arrow points the way the colours run, first to last, as the part sits in the default view.
488
726
  turn.innerHTML = ARROW;
489
727
  turn.style.transform = `rotate(${ARROW_AT_0[slot] - angle}deg)`;
@@ -558,19 +796,6 @@ for (const slot of SLOTS) {
558
796
  activeStop[slot] = 1;
559
797
  void setPaint(slot, [only, partner(only)]);
560
798
  });
561
- // `change`, not `input`: the picker fires `input` on every drag, and each save reloads the viewer.
562
- $(`pick-${slot}`).addEventListener("change", (event) => {
563
- void pickColor(slot, (event.target as HTMLInputElement).value.toLowerCase());
564
- });
565
- $(`dropper-${slot}`).addEventListener("click", async () => {
566
- if (!EyeDropper) return;
567
- try {
568
- const { sRGBHex } = await new EyeDropper().open();
569
- await pickColor(slot, toHex(sRGBHex));
570
- } catch {
571
- // Escape cancels it; a host may also refuse it.
572
- }
573
- });
574
799
  }
575
800
 
576
801
  // ---- ordering -----------------------------------------------------------------
@@ -611,7 +836,7 @@ async function order() {
611
836
  return;
612
837
  }
613
838
  await openExternal(app, quote.checkoutUrl);
614
- setStatus("Checkout is open in your browser. Your design and price carry over.");
839
+ setStatus("Checkout is open in your browser. Your design and price carry over.", 6_000);
615
840
  // The link itself, and when to fetch a new one: told only that checkout
616
841
  // was open, the model once answered "order it" with the 3D viewer's URL
617
842
  // as the checkout link (2026-10-19).
@@ -631,14 +856,22 @@ async function order() {
631
856
 
632
857
  $("customize").addEventListener("click", () => {
633
858
  lastActivity = Date.now();
634
- void toggleFullscreen(app);
859
+ void toggleFullscreen(app).then(render);
635
860
  });
861
+ for (const slot of SLOTS) {
862
+ $(`tab-${slot}`).addEventListener("click", () => {
863
+ activePart = slot;
864
+ render();
865
+ });
866
+ }
867
+ $("close").addEventListener("click", () => void toggleFullscreen(app).then(render));
636
868
  $("order").addEventListener("click", () => void order());
637
869
 
638
- // The bridge sets data-display-mode from the host; keep the toggle's label honest.
639
- new MutationObserver(() => {
640
- $("customize").textContent = document.documentElement.dataset.displayMode === "fullscreen" ? "Done" : "Customize";
641
- }).observe(document.documentElement, { attributes: true, attributeFilter: ["data-display-mode"] });
870
+ // The bridge sets data-display-mode from the host: inline and fullscreen draw the colours differently.
871
+ new MutationObserver(() => render()).observe(document.documentElement, {
872
+ attributes: true,
873
+ attributeFilter: ["data-display-mode"],
874
+ });
642
875
 
643
876
  /** Whether the viewer could run, and where — the data that settles "how many sessions have WebGPU". */
644
877
  function report(event: string, detail?: string) {
@@ -652,3 +885,8 @@ function report(event: string, detail?: string) {
652
885
  displayMode: document.documentElement.dataset.displayMode ?? "inline",
653
886
  }).catch(() => {});
654
887
  }
888
+
889
+ // Before the first result: the camera in neutral greys, so the stage is never empty.
890
+ fallback.hidden = false;
891
+ fallback.innerHTML = cameraSvg({ colors: { shell: ["#e4e4e7"], button: ["#c4c4c8"] }, gradientAngles: { shell: 0, button: 0 } } as Design);
892
+ fallback.style.opacity = "0.6";
@@ -0,0 +1,136 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Order</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light dark;
10
+ }
11
+ body {
12
+ margin: 0;
13
+ font-family: var(--font-sans, system-ui, -apple-system, "Segoe UI", sans-serif);
14
+ color: var(--color-text-primary, #1b1b1a);
15
+ }
16
+
17
+ .card {
18
+ display: grid;
19
+ gap: 14px;
20
+ max-width: 520px;
21
+ padding: 14px;
22
+ border-radius: var(--border-radius-lg, 14px);
23
+ background: var(--color-background-secondary, #f6f6f4);
24
+ border: 1px solid var(--color-border-primary, rgba(0, 0, 0, 0.1));
25
+ box-sizing: border-box;
26
+ }
27
+
28
+ .head {
29
+ display: flex;
30
+ align-items: center;
31
+ gap: 12px;
32
+ min-width: 0;
33
+ }
34
+ /* The quote card's picture of the design, from its colours alone. */
35
+ .thumb {
36
+ position: relative;
37
+ flex: none;
38
+ width: 56px;
39
+ height: 56px;
40
+ border-radius: 12px;
41
+ border: 1px solid rgba(0, 0, 0, 0.14);
42
+ background: var(--color-background-tertiary, #e8e8e4);
43
+ box-sizing: border-box;
44
+ }
45
+ .thumb .cap {
46
+ position: absolute;
47
+ top: 9px;
48
+ right: 9px;
49
+ width: 14px;
50
+ height: 14px;
51
+ border-radius: 50%;
52
+ border: 1px solid rgba(0, 0, 0, 0.2);
53
+ }
54
+ .thumb .cap:not([style]) { display: none; }
55
+ .titles { min-width: 0; }
56
+ .titles h2 {
57
+ margin: 0;
58
+ font-size: var(--font-text-md-size, 15px);
59
+ }
60
+ .titles p {
61
+ margin: 2px 0 0;
62
+ font-size: var(--font-text-sm-size, 13px);
63
+ color: var(--color-text-secondary, rgba(27, 27, 26, 0.62));
64
+ }
65
+
66
+ /* Paid → Printing → Shipped → Delivered: a bar per step, filled once reached. */
67
+ .tracker {
68
+ display: grid;
69
+ grid-template-columns: repeat(4, 1fr);
70
+ gap: 6px;
71
+ margin: 0;
72
+ padding: 0;
73
+ list-style: none;
74
+ }
75
+ .tracker li {
76
+ display: grid;
77
+ gap: 6px;
78
+ font-size: var(--font-text-xs-size, 12px);
79
+ color: var(--color-text-secondary, rgba(27, 27, 26, 0.62));
80
+ }
81
+ .tracker li::before {
82
+ content: "";
83
+ height: 4px;
84
+ border-radius: 2px;
85
+ background: var(--color-border-primary, rgba(0, 0, 0, 0.12));
86
+ }
87
+ .tracker li.done::before { background: var(--color-text-primary, #1b1b1a); }
88
+ .tracker li.now {
89
+ font-weight: 600;
90
+ color: var(--color-text-primary, #1b1b1a);
91
+ }
92
+
93
+ button.action {
94
+ font: inherit;
95
+ font-size: var(--font-text-sm-size, 13px);
96
+ font-weight: 600;
97
+ border-radius: var(--border-radius-full, 999px);
98
+ padding: 9px 14px;
99
+ cursor: pointer;
100
+ border: 1px solid var(--color-border-primary, rgba(0, 0, 0, 0.14));
101
+ background: transparent;
102
+ color: var(--color-text-primary, #1b1b1a);
103
+ }
104
+
105
+ .status {
106
+ font-size: var(--font-text-xs-size, 11px);
107
+ color: var(--color-text-secondary, rgba(27, 27, 26, 0.62));
108
+ }
109
+ .status:empty { display: none; }
110
+ [hidden] { display: none !important; }
111
+ </style>
112
+ </head>
113
+ <body>
114
+ <div class="card">
115
+ <div class="head" id="head" hidden>
116
+ <div class="thumb" id="thumb" aria-hidden="true"><span class="cap" id="cap"></span></div>
117
+ <div class="titles">
118
+ <h2 id="title"></h2>
119
+ <p id="line"></p>
120
+ </div>
121
+ </div>
122
+
123
+ <ol class="tracker" id="tracker" aria-label="Order progress" hidden>
124
+ <li data-step="paid">Paid</li>
125
+ <li data-step="printing">Printing</li>
126
+ <li data-step="shipped">Shipped</li>
127
+ <li data-step="delivered">Delivered</li>
128
+ </ol>
129
+
130
+ <button class="action" id="track" type="button" hidden></button>
131
+
132
+ <div class="status" id="status">Connecting&hellip;</div>
133
+ </div>
134
+ <script type="module" src="./main.ts"></script>
135
+ </body>
136
+ </html>