@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.
- package/dist/index.d.ts +4 -0
- package/dist/index.js +18 -9
- package/dist/openai-camera/index.html +423 -173
- package/dist/openai-order/index.html +210 -0
- package/package.json +4 -3
- package/src/openai-camera/index.html +416 -166
- package/src/openai-camera/main.ts +289 -51
- package/src/openai-order/index.html +136 -0
- package/src/openai-order/main.ts +137 -0
|
@@ -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
|
|
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.
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
448
|
-
|
|
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
|
|
639
|
-
new MutationObserver(() => {
|
|
640
|
-
|
|
641
|
-
|
|
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…</div>
|
|
133
|
+
</div>
|
|
134
|
+
<script type="module" src="./main.ts"></script>
|
|
135
|
+
</body>
|
|
136
|
+
</html>
|