@womp/mcp-ui 0.1.14 → 0.1.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@womp/mcp-ui",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "MCP Apps widgets for Womp's MCP server — interactive cards rendered inline in Claude and other MCP hosts.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -15,7 +15,7 @@
15
15
  },
16
16
  "scripts": {
17
17
  "build": "yarn build:ui && yarn build:bake",
18
- "build:ui": "yarn build:ui:image && yarn build:ui:mesh && yarn build:ui:print && yarn build:ui:cart && yarn build:ui:order && yarn build:ui:upload && yarn build:ui:openai-camera",
18
+ "build:ui": "yarn build:ui:image && yarn build:ui:mesh && yarn build:ui:print && yarn build:ui:cart && yarn build:ui:order && yarn build:ui:upload && yarn build:ui:openai-camera && yarn build:ui:openai-quote",
19
19
  "build:bake": "node scripts/bake-html.mjs",
20
20
  "dev": "vite",
21
21
  "dev:host": "vite",
@@ -31,7 +31,8 @@
31
31
  "build:ui:cart": "CARD=cart vite build",
32
32
  "build:ui:order": "CARD=order vite build",
33
33
  "build:ui:upload": "CARD=upload vite build",
34
- "build:ui:openai-camera": "CARD=openai-camera vite build"
34
+ "build:ui:openai-camera": "CARD=openai-camera vite build",
35
+ "build:ui:openai-quote": "CARD=openai-quote vite build"
35
36
  },
36
37
  "devDependencies": {
37
38
  "@modelcontextprotocol/ext-apps": "^1.7.5",
package/src/cart/main.ts CHANGED
@@ -10,7 +10,7 @@ import { $, callTool, createCard, openLink, type GenerationResult } from "../sha
10
10
  * the app-callable `view_cart` until every thumbnail exists — the same
11
11
  * self-filling trick as the mesh card, bounded so a dead render can't poll
12
12
  * forever. Like every card, it never touches money: checkout is a
13
- * `ui/open-link` to `/pay/<ids>`.
13
+ * `ui/open-link` to `/checkout/<ids>`.
14
14
  */
15
15
 
16
16
  interface CartItem {
@@ -42,12 +42,6 @@
42
42
  height: auto;
43
43
  min-height: 0;
44
44
  }
45
- /* While ordering, the design stays in view but gives the form the room. */
46
- .card[data-view="order"] .stage,
47
- .card[data-view="done"] .stage {
48
- height: 220px;
49
- flex: none;
50
- }
51
45
  .stage iframe {
52
46
  display: block;
53
47
  width: 100%;
@@ -78,8 +72,6 @@
78
72
  .fallback dt { color: var(--color-text-secondary, rgba(27, 27, 26, 0.62)); }
79
73
  .fallback dd { margin: 0; font-variant-numeric: tabular-nums; }
80
74
 
81
- .view[hidden] { display: none; }
82
-
83
75
  .controls {
84
76
  display: grid;
85
77
  gap: 8px;
@@ -246,51 +238,6 @@
246
238
  }
247
239
  button.action:disabled { opacity: 0.5; cursor: default; }
248
240
 
249
- form {
250
- display: grid;
251
- gap: 8px;
252
- padding: 12px 14px 0;
253
- grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
254
- }
255
- form .wide { grid-column: 1 / -1; }
256
- form h2 {
257
- margin: 0;
258
- font-size: var(--font-text-sm-size, 14px);
259
- }
260
- form .note {
261
- margin: 0;
262
- font-size: var(--font-text-xs-size, 12px);
263
- color: var(--color-text-secondary, rgba(27, 27, 26, 0.62));
264
- }
265
- label {
266
- display: grid;
267
- gap: 3px;
268
- min-width: 0;
269
- font-size: var(--font-text-xs-size, 12px);
270
- color: var(--color-text-secondary, rgba(27, 27, 26, 0.62));
271
- }
272
- input {
273
- width: 100%;
274
- box-sizing: border-box;
275
- font: inherit;
276
- font-size: var(--font-text-sm-size, 13px);
277
- color: var(--color-text-primary, #1b1b1a);
278
- background: var(--color-background-primary, #ffffff);
279
- border: 1px solid var(--color-border-primary, rgba(0, 0, 0, 0.14));
280
- border-radius: 8px;
281
- padding: 7px 9px;
282
- }
283
- input[aria-invalid="true"] { border-color: #c2410c; }
284
-
285
- .done {
286
- padding: 14px;
287
- display: grid;
288
- gap: 6px;
289
- font-size: var(--font-text-sm-size, 13px);
290
- }
291
- .done h2 { margin: 0; font-size: var(--font-text-md-size, 15px); }
292
- .done p { margin: 0; color: var(--color-text-secondary, rgba(27, 27, 26, 0.62)); }
293
-
294
241
  .status {
295
242
  padding: 0 14px 10px;
296
243
  font-size: var(--font-text-xs-size, 11px);
@@ -300,13 +247,13 @@
300
247
  </style>
301
248
  </head>
302
249
  <body>
303
- <div class="card" id="card" data-view="design">
250
+ <div class="card" id="card">
304
251
  <div class="stage">
305
252
  <iframe id="viewer" title="3D preview of your camera" allow="fullscreen" hidden></iframe>
306
253
  <div class="fallback" id="fallback" hidden></div>
307
254
  </div>
308
255
 
309
- <div class="view" id="view-design">
256
+ <div id="view-design">
310
257
  <div class="controls">
311
258
  <div class="row">
312
259
  <span>Shell</span>
@@ -348,35 +295,6 @@
348
295
  </div>
349
296
  </div>
350
297
 
351
- <div class="view" id="view-order" hidden>
352
- <form id="order-form" novalidate>
353
- <h2 class="wide">Where should we send it?</h2>
354
- <p class="note wide" id="order-summary"></p>
355
- <label class="wide">Full name<input name="name" autocomplete="name" maxlength="200" /></label>
356
- <label class="wide">Email<input name="email" type="email" autocomplete="email" maxlength="200" /></label>
357
- <label class="wide">Address<input name="address" autocomplete="street-address" maxlength="200" /></label>
358
- <label>City<input name="city" autocomplete="address-level2" maxlength="200" /></label>
359
- <label>Postcode / ZIP<input name="postcode" autocomplete="postal-code" maxlength="40" /></label>
360
- <label class="wide">Country<input name="country" autocomplete="country-name" maxlength="100" /></label>
361
- </form>
362
- <div class="foot">
363
- <div class="price">Test mode — nothing is charged.</div>
364
- <button class="action" id="order-back" type="button">Back</button>
365
- <button class="action primary" id="order-place" type="button">Place test order</button>
366
- </div>
367
- </div>
368
-
369
- <div class="view" id="view-done" hidden>
370
- <div class="done">
371
- <h2>Order received</h2>
372
- <p id="done-text"></p>
373
- </div>
374
- <div class="foot">
375
- <div class="price"></div>
376
- <button class="action" id="done-back" type="button">Back to design</button>
377
- </div>
378
- </div>
379
-
380
298
  <div class="status" id="status">Connecting&hellip;</div>
381
299
  </div>
382
300
  <script type="module" src="./main.ts"></script>
@@ -1,4 +1,4 @@
1
- import { $, callTool, createCard, pushModelContext, toggleFullscreen } from "../shared/bridge";
1
+ import { $, callTool, createCard, pushModelContext, sendChatMessage, toggleFullscreen } from "../shared/bridge";
2
2
 
3
3
  /**
4
4
  * The camera card — the OpenAI MCP's first project (swan-backend
@@ -13,17 +13,20 @@ import { $, callTool, createCard, pushModelContext, toggleFullscreen } from "../
13
13
  * is saved with `sync_camera_design` (app-only). The viewer already shows it;
14
14
  * no reload.
15
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.
16
+ * polls `get_camera_design` and moves the viewer to the new revision when it
17
+ * changed for any reason other than its own write.
18
18
  *
19
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.
20
+ * saves and moves the viewer to the new revision. A viewer that says it can
21
+ * (`kakapo:setScene` in its ready message) swaps the revision in place, keeping
22
+ * its camera and re-modelling only what changed; any other is reloaded. Each part
23
+ * is solid or a gradient of 2-3 colours, turned 45° at a time by the arrow; the
24
+ * colours come from the palette, the native colour picker, or the eyedropper
25
+ * where the browser has one.
23
26
  *
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
+ * Order asks for a quote in the chat, as if the user had typed it: the model
28
+ * answers with `checkout_camera`, whose quote card (src/openai-quote) carries the
29
+ * checkout link. Nothing about paying happens in here.
27
30
  */
28
31
 
29
32
  interface DesignOption {
@@ -36,49 +39,60 @@ interface DesignOption {
36
39
  interface Design {
37
40
  designId: string;
38
41
  rev: number;
39
- mode?: "design" | "checkout";
40
42
  viewerUrl: string;
41
43
  viewerOrigin: string;
42
44
  sceneUrl: string;
43
45
  options: DesignOption[];
44
46
  /** Each part's colours: one is solid, more is a gradient. */
45
47
  colors: Record<Slot, string[]>;
48
+ /** Which way each part's gradient runs, in degrees. */
49
+ gradientAngles: Record<Slot, number>;
46
50
  palette: { name: string; hex: string }[];
47
51
  price: { amount: number; currency: string; label: string };
48
52
  lastChange?: { source: string; writer: string; fields: string[] };
49
53
  }
50
54
 
51
- interface OrderResult {
52
- orderId: string;
53
- rev: number;
54
- price: { label: string };
55
- }
56
-
57
55
  type Slot = "shell" | "button";
58
56
  const SLOTS: Slot[] = ["shell", "button"];
59
- type View = "design" | "order" | "done";
60
57
 
61
58
  /** The most colours a gradient takes (the server holds the same limit). */
62
59
  const MAX_COLORS = 3;
63
60
 
61
+ /** An arrow pointing right (Lucide `arrow-right`, ISC). */
62
+ const ARROW =
63
+ '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="m13 6 6 6-6 6"/></svg>';
64
+ /**
65
+ * Where each part's arrow points at 0°, in CSS degrees: the shell runs top to
66
+ * bottom, the button left to right. In the default view both turn anticlockwise
67
+ * as the angle grows, so the arrow turns by minus the angle.
68
+ */
69
+ const ARROW_AT_0: Record<Slot, number> = { shell: 90, button: 0 };
70
+
64
71
  /** Chromium's EyeDropper API; absent elsewhere. */
65
72
  type EyeDropperCtor = new () => { open(): Promise<{ sRGBHex: string }> };
66
73
  const EyeDropper = (window as Window & { EyeDropper?: EyeDropperCtor }).EyeDropper;
67
74
 
68
- const CONTACT_FIELDS = ["name", "email", "address", "city", "postcode", "country"] as const;
69
-
70
75
  /** Marks this card's own writes, so its polling does not reload the viewer for them. */
71
76
  const writer = `card-${Math.random().toString(36).slice(2, 10)}`;
72
77
 
73
- const cardEl = $("card");
74
78
  const viewer = $("viewer") as HTMLIFrameElement;
75
79
  const fallback = $("fallback");
76
- const form = $("order-form") as HTMLFormElement;
77
80
 
78
81
  let design: Design | undefined;
79
82
  /** Revision the viewer is showing. */
80
83
  let loadedRev = 0;
81
84
  let viewerReady = false;
85
+ /** The design the viewer frame was loaded for. */
86
+ let viewerDesignId: string | undefined;
87
+ /** Whether the viewer takes a new revision by message instead of a reload. */
88
+ let viewerCanSwap = false;
89
+ /** Where to post to the viewer: its origin, or "*" when it speaks as "null" (an opaque host). */
90
+ let viewerTarget = "*";
91
+ /** The scene URL a swap is waiting on, and the reload that stands in if it never lands. */
92
+ let pendingSwap: string | undefined;
93
+ let swapTimer: number | undefined;
94
+ /** Waiting for the viewer's next params message. */
95
+ const paramsWaiters: (() => void)[] = [];
82
96
  /** Last knob values read back from the viewer. */
83
97
  let viewerParams: Record<string, number> | undefined;
84
98
  let webgpu: boolean | undefined;
@@ -97,13 +111,6 @@ function setStatus(text: string, clearAfterMs?: number) {
97
111
  if (clearAfterMs) statusTimer = window.setTimeout(() => ($("status").textContent = ""), clearAfterMs);
98
112
  }
99
113
 
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
114
  const { app } = createCard("Camera", {
108
115
  onInput: () => setStatus("Setting up your camera…"),
109
116
  onResult: (raw) => {
@@ -125,7 +132,6 @@ function adopt(next: Design) {
125
132
  } else if (next.rev > loadedRev) {
126
133
  void loadViewer();
127
134
  }
128
- if (next.mode === "checkout") openOrderForm();
129
135
  }
130
136
 
131
137
  // ---- the viewer -------------------------------------------------------------
@@ -153,8 +159,29 @@ async function loadViewer(note?: string) {
153
159
  setStatus("");
154
160
  return;
155
161
  }
162
+ if (viewerReady && viewerCanSwap && viewerDesignId === design.designId) swapScene(design.sceneUrl, note);
163
+ else reloadViewer(note);
164
+ }
165
+
166
+ /** Hand the viewer another revision of the design on screen. */
167
+ function swapScene(url: string, note?: string) {
168
+ pendingSwap = url;
169
+ setStatus(note ?? "Updating the 3D view…");
170
+ viewer.contentWindow?.postMessage({ type: "kakapo:setScene", url }, viewerTarget);
171
+ clearTimeout(swapTimer);
172
+ swapTimer = window.setTimeout(() => {
173
+ if (pendingSwap === url) reloadViewer(note);
174
+ }, 15_000);
175
+ }
176
+
177
+ function reloadViewer(note?: string) {
178
+ if (!design) return;
156
179
  viewerReady = false;
180
+ viewerCanSwap = false;
157
181
  viewerParams = undefined;
182
+ viewerDesignId = design.designId;
183
+ pendingSwap = undefined;
184
+ clearTimeout(swapTimer);
158
185
  fallback.hidden = true;
159
186
  viewer.hidden = false;
160
187
  viewer.src = design.viewerUrl;
@@ -177,8 +204,8 @@ function showFallback() {
177
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.";
178
205
  const list = document.createElement("dl");
179
206
  const rows: [string, string][] = [
180
- ["Shell colour", paintText(design.colors.shell)],
181
- ["Button colour", paintText(design.colors.button)],
207
+ ["Shell colour", paintText(design.colors.shell, design.gradientAngles.shell)],
208
+ ["Button colour", paintText(design.colors.button, design.gradientAngles.button)],
182
209
  ...design.options.map((o) => [o.label, o.display] as [string, string]),
183
210
  ];
184
211
  for (const [k, v] of rows) {
@@ -215,29 +242,56 @@ const fromViewer = (event: MessageEvent, d: Design) =>
215
242
 
216
243
  window.addEventListener("message", (event) => {
217
244
  if (!design || !fromViewer(event, design)) return;
218
- const msg = event.data as { type?: string; scripts?: unknown };
245
+ const msg = event.data as { type?: string; scripts?: unknown; messages?: unknown; url?: string; error?: string };
219
246
  if (msg?.type === "kakapo:ready") {
220
247
  viewerReady = true;
248
+ viewerTarget = event.origin === "null" ? "*" : event.origin;
249
+ viewerCanSwap = Array.isArray(msg.messages) && msg.messages.includes("kakapo:setScene");
221
250
  clearTimeout(readyTimer);
222
251
  viewerParams = flatten(msg.scripts);
223
252
  setStatus("");
224
253
  report("viewer-ready");
254
+ } else if (msg?.type === "kakapo:sceneSet") {
255
+ if (msg.url !== pendingSwap) return; // an earlier swap, answering late
256
+ pendingSwap = undefined;
257
+ clearTimeout(swapTimer);
258
+ if (msg.error) {
259
+ reloadViewer();
260
+ return;
261
+ }
262
+ // The revision's knob values are the viewer's now: the baseline for spotting the user's next change.
263
+ viewerParams = flatten(msg.scripts);
264
+ setStatus("");
225
265
  } else if (msg?.type === "kakapo:params") {
226
266
  onViewerParams(flatten(msg.scripts));
267
+ for (const done of paramsWaiters.splice(0)) done();
227
268
  } else if (msg?.type === "kakapo:print") {
228
269
  // The viewer's own Print button is our Order.
229
270
  onViewerParams(flatten(msg.scripts));
230
- openOrderForm();
271
+ void order();
231
272
  }
232
273
  });
233
274
 
234
275
  // The viewer has no change event, so read its knobs on an interval while visible.
235
276
  window.setInterval(() => {
236
277
  if (!design || !viewerReady || document.visibilityState !== "visible") return;
237
- // "*": under an opaque host the viewer's origin is "null", which no target origin can name. The request carries nothing.
238
- viewer.contentWindow?.postMessage({ type: "kakapo:getParams" }, "*");
278
+ viewer.contentWindow?.postMessage({ type: "kakapo:getParams" }, viewerTarget);
239
279
  }, 1_500);
240
280
 
281
+ /**
282
+ * The viewer's knobs as they are now rather than at the last poll, for a save
283
+ * the user just asked for: one made with values up to 1.5s old would put back
284
+ * a slider they had just moved.
285
+ */
286
+ function freshViewerParams(): Promise<void> {
287
+ if (!viewerReady) return Promise.resolve();
288
+ return new Promise((resolve) => {
289
+ paramsWaiters.push(resolve);
290
+ viewer.contentWindow?.postMessage({ type: "kakapo:getParams" }, viewerTarget);
291
+ window.setTimeout(resolve, 500);
292
+ });
293
+ }
294
+
241
295
  function onViewerParams(params: Record<string, number>) {
242
296
  if (!viewerParams) {
243
297
  viewerParams = params;
@@ -254,11 +308,17 @@ function onViewerParams(params: Record<string, number>) {
254
308
 
255
309
  // ---- saving -------------------------------------------------------------------
256
310
 
311
+ /** What the card changes besides the viewer's knobs. */
312
+ interface Finish {
313
+ colors?: Partial<Record<Slot, string[]>>;
314
+ gradientAngles?: Partial<Record<Slot, number>>;
315
+ }
316
+
257
317
  /**
258
- * Save the viewer's knobs (and a part's colours, if they changed). Serialised,
259
- * so two quick changes cannot race each other's baseRev.
318
+ * Save the viewer's knobs (and a part's colours or gradient angle, if they
319
+ * changed). Serialised, so two quick changes cannot race each other's baseRev.
260
320
  */
261
- async function flushSync(colors?: Partial<Record<Slot, string[]>>) {
321
+ async function flushSync(finish?: Finish) {
262
322
  clearTimeout(syncTimer);
263
323
  syncTimer = undefined;
264
324
  while (syncing) await syncing;
@@ -271,7 +331,7 @@ async function flushSync(colors?: Partial<Record<Slot, string[]>>) {
271
331
  baseRev,
272
332
  writer,
273
333
  params: viewerParams ?? {},
274
- ...(colors ? { colors } : {}),
334
+ ...finish,
275
335
  });
276
336
  const next = res.structuredContent as Design | undefined;
277
337
  if (res.isError || !next) {
@@ -285,11 +345,11 @@ async function flushSync(colors?: Partial<Record<Slot, string[]>>) {
285
345
  return;
286
346
  }
287
347
  // Anything besides our own write since baseRev (a chat change merged in)
288
- // means the viewer is behind. So is a colour, which it cannot set itself.
348
+ // means the viewer is behind. So does a colour, which lives in the scene file.
289
349
  const merged = next.rev > baseRev + 1;
290
- if (merged || colors) void loadViewer(merged ? "Merged with a change from the chat…" : "Applying colour…");
350
+ if (merged || finish) void loadViewer(merged ? "Merged with a change from the chat…" : "Applying colour…");
291
351
  else loadedRev = next.rev;
292
- if (!merged && !colors) setStatus("Saved", 1_500);
352
+ if (!merged && !finish) setStatus("Saved", 1_500);
293
353
  void pushModelContext(app, contextLine(next));
294
354
  } catch {
295
355
  setStatus("Couldn't save that change.");
@@ -302,14 +362,15 @@ async function flushSync(colors?: Partial<Record<Slot, string[]>>) {
302
362
  }
303
363
  }
304
364
 
305
- /** "coral", or "coral → violet gradient" */
306
- function paintText(paint: string[]) {
307
- return paint.length > 1 ? `${paint.join(" → ")} gradient` : paint[0];
365
+ /** "coral", "coral → violet gradient", or "coral → violet gradient turned 45°" */
366
+ function paintText(paint: string[], angle = 0) {
367
+ if (paint.length < 2) return paint[0];
368
+ return `${paint.join(" → ")} gradient${angle ? ` turned ${angle}°` : ""}`;
308
369
  }
309
370
 
310
371
  const contextLine = (d: Design) =>
311
372
  `The user adjusted their camera in the card. Design ${d.designId}, revision ${d.rev}: ` +
312
- `shell ${paintText(d.colors.shell)}, button ${paintText(d.colors.button)}; ` +
373
+ `shell ${paintText(d.colors.shell, d.gradientAngles.shell)}, button ${paintText(d.colors.button, d.gradientAngles.button)}; ` +
313
374
  d.options.map((o) => `${o.label} ${o.display}`).join(", ") +
314
375
  ".";
315
376
 
@@ -387,9 +448,6 @@ function render() {
387
448
  $(`dropper-${slot}`).hidden = !EyeDropper;
388
449
  }
389
450
  $("price").textContent = `Full-colour print · ${design.price.label}`;
390
- $("order-summary").textContent =
391
- `Your camera, printed in full colour: ${design.price.label}. ` +
392
- `Shell ${paintText(design.colors.shell)}, button ${paintText(design.colors.button)}.`;
393
451
  if (!fallback.hidden) showFallback();
394
452
  }
395
453
 
@@ -424,7 +482,12 @@ function renderStops(slot: Slot, paint: string[]) {
424
482
  const remove = iconButton("−", `${slot}: remove the selected gradient colour`, paint.length <= 2, () => {
425
483
  void setPaint(slot, paint.filter((_, i) => i !== activeStop[slot]));
426
484
  });
427
- box.append(track, add, remove);
485
+ const angle = design?.gradientAngles[slot] ?? 0;
486
+ const turn = iconButton("", `${slot}: turn the gradient 45° (now ${angle}°)`, false, () => void setAngle(slot, (angle + 45) % 360));
487
+ // The arrow points the way the colours run, first to last, as the part sits in the default view.
488
+ turn.innerHTML = ARROW;
489
+ turn.style.transform = `rotate(${ARROW_AT_0[slot] - angle}deg)`;
490
+ box.append(turn, track, add, remove);
428
491
  }
429
492
 
430
493
  function iconButton(text: string, label: string, disabled: boolean, onClick: () => void) {
@@ -468,7 +531,18 @@ async function setPaint(slot: Slot, paint: string[]) {
468
531
  design = { ...design, colors: { ...design.colors, [slot]: paint } };
469
532
  render();
470
533
  setStatus("Saving…");
471
- await flushSync({ [slot]: paint });
534
+ await freshViewerParams();
535
+ await flushSync({ colors: { [slot]: paint } });
536
+ }
537
+
538
+ async function setAngle(slot: Slot, angle: number) {
539
+ if (!design || design.gradientAngles[slot] === angle) return;
540
+ lastActivity = Date.now();
541
+ design = { ...design, gradientAngles: { ...design.gradientAngles, [slot]: angle } };
542
+ render();
543
+ setStatus("Saving…");
544
+ await freshViewerParams();
545
+ await flushSync({ gradientAngles: { [slot]: angle } });
472
546
  }
473
547
 
474
548
  for (const slot of SLOTS) {
@@ -501,55 +575,30 @@ for (const slot of SLOTS) {
501
575
 
502
576
  // ---- ordering -----------------------------------------------------------------
503
577
 
504
- function openOrderForm() {
505
- if (!design) return;
506
- lastActivity = Date.now();
507
- showView("order");
508
- setStatus("");
509
- (form.elements.namedItem("name") as HTMLInputElement | null)?.focus();
510
- }
578
+ let ordering = false;
511
579
 
512
- /** The form's values, or undefined after marking what is missing. */
513
- function readContact(): Record<(typeof CONTACT_FIELDS)[number], string> | undefined {
514
- const contact = {} as Record<(typeof CONTACT_FIELDS)[number], string>;
515
- let ok = true;
516
- for (const name of CONTACT_FIELDS) {
517
- const input = form.elements.namedItem(name) as HTMLInputElement;
518
- const value = input.value.trim();
519
- const valid = name === "email" ? /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) : value.length > 0;
520
- input.setAttribute("aria-invalid", String(!valid));
521
- if (!valid) ok = false;
522
- contact[name] = value;
523
- }
524
- return ok ? contact : undefined;
525
- }
526
-
527
- async function placeOrder() {
528
- if (!design) return;
529
- const contact = readContact();
530
- if (!contact) {
531
- setStatus("Fill in the highlighted fields.");
532
- return;
533
- }
534
- const button = $("order-place") as HTMLButtonElement;
580
+ /**
581
+ * Ask for a quote in the chat. Whatever the user just changed is saved first,
582
+ * so the quote is for what is on screen, and fullscreen closes so the quote card
583
+ * that answers is in view.
584
+ */
585
+ async function order() {
586
+ if (!design || ordering) return;
587
+ ordering = true;
588
+ lastActivity = Date.now();
589
+ const button = $("order") as HTMLButtonElement;
535
590
  button.disabled = true;
536
- setStatus("Placing your order…");
591
+ setStatus("Getting your quote…");
537
592
  try {
538
- // Order what is on screen: save any pending knob turn first.
593
+ await freshViewerParams();
539
594
  if (syncTimer !== undefined || syncing) await flushSync();
540
- const res = await callTool(app, "place_order", { designId: design.designId, rev: design.rev, writer, contact });
541
- const order = res.structuredContent as OrderResult | undefined;
542
- if (res.isError || !order) {
543
- setStatus("The order didn't go through. Check the details and try again.");
544
- return;
545
- }
546
- $("done-text").textContent = `Test order ${order.orderId} is recorded (${order.price.label}). Nothing was charged.`;
547
- showView("done");
548
- setStatus("");
549
- void pushModelContext(app, `The user placed test order ${order.orderId} for camera design ${design.designId}, revision ${order.rev}.`);
595
+ if (document.documentElement.dataset.displayMode === "fullscreen") await toggleFullscreen(app);
596
+ const sent = await sendChatMessage(app, "Order it");
597
+ setStatus(sent ? "" : "Ask in the chat to order it.");
550
598
  } catch {
551
- setStatus("The order didn't go through. Try again in a moment.");
599
+ setStatus("Couldn't ask for a quote. Ask in the chat to order it.");
552
600
  } finally {
601
+ ordering = false;
553
602
  button.disabled = false;
554
603
  }
555
604
  }
@@ -558,14 +607,7 @@ $("customize").addEventListener("click", () => {
558
607
  lastActivity = Date.now();
559
608
  void toggleFullscreen(app);
560
609
  });
561
- $("order").addEventListener("click", openOrderForm);
562
- $("order-back").addEventListener("click", () => showView("design"));
563
- $("order-place").addEventListener("click", () => void placeOrder());
564
- $("done-back").addEventListener("click", () => showView("design"));
565
- form.addEventListener("submit", (event) => {
566
- event.preventDefault();
567
- void placeOrder();
568
- });
610
+ $("order").addEventListener("click", () => void order());
569
611
 
570
612
  // The bridge sets data-display-mode from the host; keep the toggle's label honest.
571
613
  new MutationObserver(() => {