@womp/mcp-ui 0.1.14 → 0.1.15

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.15",
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",
@@ -13,13 +13,16 @@ 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
27
  * Ordering happens in the card: Order (or a `checkout_camera` result, which
25
28
  * arrives with mode "checkout") opens the shipping form, and the form submits to
@@ -43,6 +46,8 @@ interface Design {
43
46
  options: DesignOption[];
44
47
  /** Each part's colours: one is solid, more is a gradient. */
45
48
  colors: Record<Slot, string[]>;
49
+ /** Which way each part's gradient runs, in degrees. */
50
+ gradientAngles: Record<Slot, number>;
46
51
  palette: { name: string; hex: string }[];
47
52
  price: { amount: number; currency: string; label: string };
48
53
  lastChange?: { source: string; writer: string; fields: string[] };
@@ -61,6 +66,16 @@ type View = "design" | "order" | "done";
61
66
  /** The most colours a gradient takes (the server holds the same limit). */
62
67
  const MAX_COLORS = 3;
63
68
 
69
+ /** An arrow pointing right (Lucide `arrow-right`, ISC). */
70
+ const ARROW =
71
+ '<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>';
72
+ /**
73
+ * Where each part's arrow points at 0°, in CSS degrees: the shell runs top to
74
+ * bottom, the button left to right. In the default view both turn anticlockwise
75
+ * as the angle grows, so the arrow turns by minus the angle.
76
+ */
77
+ const ARROW_AT_0: Record<Slot, number> = { shell: 90, button: 0 };
78
+
64
79
  /** Chromium's EyeDropper API; absent elsewhere. */
65
80
  type EyeDropperCtor = new () => { open(): Promise<{ sRGBHex: string }> };
66
81
  const EyeDropper = (window as Window & { EyeDropper?: EyeDropperCtor }).EyeDropper;
@@ -79,6 +94,17 @@ let design: Design | undefined;
79
94
  /** Revision the viewer is showing. */
80
95
  let loadedRev = 0;
81
96
  let viewerReady = false;
97
+ /** The design the viewer frame was loaded for. */
98
+ let viewerDesignId: string | undefined;
99
+ /** Whether the viewer takes a new revision by message instead of a reload. */
100
+ let viewerCanSwap = false;
101
+ /** Where to post to the viewer: its origin, or "*" when it speaks as "null" (an opaque host). */
102
+ let viewerTarget = "*";
103
+ /** The scene URL a swap is waiting on, and the reload that stands in if it never lands. */
104
+ let pendingSwap: string | undefined;
105
+ let swapTimer: number | undefined;
106
+ /** Waiting for the viewer's next params message. */
107
+ const paramsWaiters: (() => void)[] = [];
82
108
  /** Last knob values read back from the viewer. */
83
109
  let viewerParams: Record<string, number> | undefined;
84
110
  let webgpu: boolean | undefined;
@@ -153,8 +179,29 @@ async function loadViewer(note?: string) {
153
179
  setStatus("");
154
180
  return;
155
181
  }
182
+ if (viewerReady && viewerCanSwap && viewerDesignId === design.designId) swapScene(design.sceneUrl, note);
183
+ else reloadViewer(note);
184
+ }
185
+
186
+ /** Hand the viewer another revision of the design on screen. */
187
+ function swapScene(url: string, note?: string) {
188
+ pendingSwap = url;
189
+ setStatus(note ?? "Updating the 3D view…");
190
+ viewer.contentWindow?.postMessage({ type: "kakapo:setScene", url }, viewerTarget);
191
+ clearTimeout(swapTimer);
192
+ swapTimer = window.setTimeout(() => {
193
+ if (pendingSwap === url) reloadViewer(note);
194
+ }, 15_000);
195
+ }
196
+
197
+ function reloadViewer(note?: string) {
198
+ if (!design) return;
156
199
  viewerReady = false;
200
+ viewerCanSwap = false;
157
201
  viewerParams = undefined;
202
+ viewerDesignId = design.designId;
203
+ pendingSwap = undefined;
204
+ clearTimeout(swapTimer);
158
205
  fallback.hidden = true;
159
206
  viewer.hidden = false;
160
207
  viewer.src = design.viewerUrl;
@@ -177,8 +224,8 @@ function showFallback() {
177
224
  "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
225
  const list = document.createElement("dl");
179
226
  const rows: [string, string][] = [
180
- ["Shell colour", paintText(design.colors.shell)],
181
- ["Button colour", paintText(design.colors.button)],
227
+ ["Shell colour", paintText(design.colors.shell, design.gradientAngles.shell)],
228
+ ["Button colour", paintText(design.colors.button, design.gradientAngles.button)],
182
229
  ...design.options.map((o) => [o.label, o.display] as [string, string]),
183
230
  ];
184
231
  for (const [k, v] of rows) {
@@ -215,15 +262,29 @@ const fromViewer = (event: MessageEvent, d: Design) =>
215
262
 
216
263
  window.addEventListener("message", (event) => {
217
264
  if (!design || !fromViewer(event, design)) return;
218
- const msg = event.data as { type?: string; scripts?: unknown };
265
+ const msg = event.data as { type?: string; scripts?: unknown; messages?: unknown; url?: string; error?: string };
219
266
  if (msg?.type === "kakapo:ready") {
220
267
  viewerReady = true;
268
+ viewerTarget = event.origin === "null" ? "*" : event.origin;
269
+ viewerCanSwap = Array.isArray(msg.messages) && msg.messages.includes("kakapo:setScene");
221
270
  clearTimeout(readyTimer);
222
271
  viewerParams = flatten(msg.scripts);
223
272
  setStatus("");
224
273
  report("viewer-ready");
274
+ } else if (msg?.type === "kakapo:sceneSet") {
275
+ if (msg.url !== pendingSwap) return; // an earlier swap, answering late
276
+ pendingSwap = undefined;
277
+ clearTimeout(swapTimer);
278
+ if (msg.error) {
279
+ reloadViewer();
280
+ return;
281
+ }
282
+ // The revision's knob values are the viewer's now: the baseline for spotting the user's next change.
283
+ viewerParams = flatten(msg.scripts);
284
+ setStatus("");
225
285
  } else if (msg?.type === "kakapo:params") {
226
286
  onViewerParams(flatten(msg.scripts));
287
+ for (const done of paramsWaiters.splice(0)) done();
227
288
  } else if (msg?.type === "kakapo:print") {
228
289
  // The viewer's own Print button is our Order.
229
290
  onViewerParams(flatten(msg.scripts));
@@ -234,10 +295,23 @@ window.addEventListener("message", (event) => {
234
295
  // The viewer has no change event, so read its knobs on an interval while visible.
235
296
  window.setInterval(() => {
236
297
  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" }, "*");
298
+ viewer.contentWindow?.postMessage({ type: "kakapo:getParams" }, viewerTarget);
239
299
  }, 1_500);
240
300
 
301
+ /**
302
+ * The viewer's knobs as they are now rather than at the last poll, for a save
303
+ * the user just asked for: one made with values up to 1.5s old would put back
304
+ * a slider they had just moved.
305
+ */
306
+ function freshViewerParams(): Promise<void> {
307
+ if (!viewerReady) return Promise.resolve();
308
+ return new Promise((resolve) => {
309
+ paramsWaiters.push(resolve);
310
+ viewer.contentWindow?.postMessage({ type: "kakapo:getParams" }, viewerTarget);
311
+ window.setTimeout(resolve, 500);
312
+ });
313
+ }
314
+
241
315
  function onViewerParams(params: Record<string, number>) {
242
316
  if (!viewerParams) {
243
317
  viewerParams = params;
@@ -254,11 +328,17 @@ function onViewerParams(params: Record<string, number>) {
254
328
 
255
329
  // ---- saving -------------------------------------------------------------------
256
330
 
331
+ /** What the card changes besides the viewer's knobs. */
332
+ interface Finish {
333
+ colors?: Partial<Record<Slot, string[]>>;
334
+ gradientAngles?: Partial<Record<Slot, number>>;
335
+ }
336
+
257
337
  /**
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.
338
+ * Save the viewer's knobs (and a part's colours or gradient angle, if they
339
+ * changed). Serialised, so two quick changes cannot race each other's baseRev.
260
340
  */
261
- async function flushSync(colors?: Partial<Record<Slot, string[]>>) {
341
+ async function flushSync(finish?: Finish) {
262
342
  clearTimeout(syncTimer);
263
343
  syncTimer = undefined;
264
344
  while (syncing) await syncing;
@@ -271,7 +351,7 @@ async function flushSync(colors?: Partial<Record<Slot, string[]>>) {
271
351
  baseRev,
272
352
  writer,
273
353
  params: viewerParams ?? {},
274
- ...(colors ? { colors } : {}),
354
+ ...finish,
275
355
  });
276
356
  const next = res.structuredContent as Design | undefined;
277
357
  if (res.isError || !next) {
@@ -285,11 +365,11 @@ async function flushSync(colors?: Partial<Record<Slot, string[]>>) {
285
365
  return;
286
366
  }
287
367
  // 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.
368
+ // means the viewer is behind. So does a colour, which lives in the scene file.
289
369
  const merged = next.rev > baseRev + 1;
290
- if (merged || colors) void loadViewer(merged ? "Merged with a change from the chat…" : "Applying colour…");
370
+ if (merged || finish) void loadViewer(merged ? "Merged with a change from the chat…" : "Applying colour…");
291
371
  else loadedRev = next.rev;
292
- if (!merged && !colors) setStatus("Saved", 1_500);
372
+ if (!merged && !finish) setStatus("Saved", 1_500);
293
373
  void pushModelContext(app, contextLine(next));
294
374
  } catch {
295
375
  setStatus("Couldn't save that change.");
@@ -302,14 +382,15 @@ async function flushSync(colors?: Partial<Record<Slot, string[]>>) {
302
382
  }
303
383
  }
304
384
 
305
- /** "coral", or "coral → violet gradient" */
306
- function paintText(paint: string[]) {
307
- return paint.length > 1 ? `${paint.join(" → ")} gradient` : paint[0];
385
+ /** "coral", "coral → violet gradient", or "coral → violet gradient turned 45°" */
386
+ function paintText(paint: string[], angle = 0) {
387
+ if (paint.length < 2) return paint[0];
388
+ return `${paint.join(" → ")} gradient${angle ? ` turned ${angle}°` : ""}`;
308
389
  }
309
390
 
310
391
  const contextLine = (d: Design) =>
311
392
  `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)}; ` +
393
+ `shell ${paintText(d.colors.shell, d.gradientAngles.shell)}, button ${paintText(d.colors.button, d.gradientAngles.button)}; ` +
313
394
  d.options.map((o) => `${o.label} ${o.display}`).join(", ") +
314
395
  ".";
315
396
 
@@ -389,7 +470,8 @@ function render() {
389
470
  $("price").textContent = `Full-colour print · ${design.price.label}`;
390
471
  $("order-summary").textContent =
391
472
  `Your camera, printed in full colour: ${design.price.label}. ` +
392
- `Shell ${paintText(design.colors.shell)}, button ${paintText(design.colors.button)}.`;
473
+ `Shell ${paintText(design.colors.shell, design.gradientAngles.shell)}, ` +
474
+ `button ${paintText(design.colors.button, design.gradientAngles.button)}.`;
393
475
  if (!fallback.hidden) showFallback();
394
476
  }
395
477
 
@@ -424,7 +506,12 @@ function renderStops(slot: Slot, paint: string[]) {
424
506
  const remove = iconButton("−", `${slot}: remove the selected gradient colour`, paint.length <= 2, () => {
425
507
  void setPaint(slot, paint.filter((_, i) => i !== activeStop[slot]));
426
508
  });
427
- box.append(track, add, remove);
509
+ const angle = design?.gradientAngles[slot] ?? 0;
510
+ const turn = iconButton("", `${slot}: turn the gradient 45° (now ${angle}°)`, false, () => void setAngle(slot, (angle + 45) % 360));
511
+ // The arrow points the way the colours run, first to last, as the part sits in the default view.
512
+ turn.innerHTML = ARROW;
513
+ turn.style.transform = `rotate(${ARROW_AT_0[slot] - angle}deg)`;
514
+ box.append(turn, track, add, remove);
428
515
  }
429
516
 
430
517
  function iconButton(text: string, label: string, disabled: boolean, onClick: () => void) {
@@ -468,7 +555,18 @@ async function setPaint(slot: Slot, paint: string[]) {
468
555
  design = { ...design, colors: { ...design.colors, [slot]: paint } };
469
556
  render();
470
557
  setStatus("Saving…");
471
- await flushSync({ [slot]: paint });
558
+ await freshViewerParams();
559
+ await flushSync({ colors: { [slot]: paint } });
560
+ }
561
+
562
+ async function setAngle(slot: Slot, angle: number) {
563
+ if (!design || design.gradientAngles[slot] === angle) return;
564
+ lastActivity = Date.now();
565
+ design = { ...design, gradientAngles: { ...design.gradientAngles, [slot]: angle } };
566
+ render();
567
+ setStatus("Saving…");
568
+ await freshViewerParams();
569
+ await flushSync({ gradientAngles: { [slot]: angle } });
472
570
  }
473
571
 
474
572
  for (const slot of SLOTS) {
@@ -536,6 +634,7 @@ async function placeOrder() {
536
634
  setStatus("Placing your order…");
537
635
  try {
538
636
  // Order what is on screen: save any pending knob turn first.
637
+ await freshViewerParams();
539
638
  if (syncTimer !== undefined || syncing) await flushSync();
540
639
  const res = await callTool(app, "place_order", { designId: design.designId, rev: design.rev, writer, contact });
541
640
  const order = res.structuredContent as OrderResult | undefined;