@womp/mcp-ui 0.1.13 → 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.13",
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) {
@@ -204,17 +251,40 @@ function flatten(scripts: unknown): Record<string, number> {
204
251
  return out;
205
252
  }
206
253
 
254
+ /**
255
+ * Where the viewer's messages come from. A host that frames the card on an
256
+ * opaque origin (a sandbox without allow-same-origin) passes that sandbox down
257
+ * to the viewer's frame, which then speaks as "null" — still our frame, as
258
+ * `event.source` proves.
259
+ */
260
+ const fromViewer = (event: MessageEvent, d: Design) =>
261
+ event.source === viewer.contentWindow && (event.origin === d.viewerOrigin || event.origin === "null");
262
+
207
263
  window.addEventListener("message", (event) => {
208
- if (!design || event.source !== viewer.contentWindow || event.origin !== design.viewerOrigin) return;
209
- const msg = event.data as { type?: string; scripts?: unknown };
264
+ if (!design || !fromViewer(event, design)) return;
265
+ const msg = event.data as { type?: string; scripts?: unknown; messages?: unknown; url?: string; error?: string };
210
266
  if (msg?.type === "kakapo:ready") {
211
267
  viewerReady = true;
268
+ viewerTarget = event.origin === "null" ? "*" : event.origin;
269
+ viewerCanSwap = Array.isArray(msg.messages) && msg.messages.includes("kakapo:setScene");
212
270
  clearTimeout(readyTimer);
213
271
  viewerParams = flatten(msg.scripts);
214
272
  setStatus("");
215
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("");
216
285
  } else if (msg?.type === "kakapo:params") {
217
286
  onViewerParams(flatten(msg.scripts));
287
+ for (const done of paramsWaiters.splice(0)) done();
218
288
  } else if (msg?.type === "kakapo:print") {
219
289
  // The viewer's own Print button is our Order.
220
290
  onViewerParams(flatten(msg.scripts));
@@ -225,9 +295,23 @@ window.addEventListener("message", (event) => {
225
295
  // The viewer has no change event, so read its knobs on an interval while visible.
226
296
  window.setInterval(() => {
227
297
  if (!design || !viewerReady || document.visibilityState !== "visible") return;
228
- viewer.contentWindow?.postMessage({ type: "kakapo:getParams" }, design.viewerOrigin);
298
+ viewer.contentWindow?.postMessage({ type: "kakapo:getParams" }, viewerTarget);
229
299
  }, 1_500);
230
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
+
231
315
  function onViewerParams(params: Record<string, number>) {
232
316
  if (!viewerParams) {
233
317
  viewerParams = params;
@@ -244,11 +328,17 @@ function onViewerParams(params: Record<string, number>) {
244
328
 
245
329
  // ---- saving -------------------------------------------------------------------
246
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
+
247
337
  /**
248
- * Save the viewer's knobs (and a part's colours, if they changed). Serialised,
249
- * so two quick changes cannot race each other's baseRev.
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.
250
340
  */
251
- async function flushSync(colors?: Partial<Record<Slot, string[]>>) {
341
+ async function flushSync(finish?: Finish) {
252
342
  clearTimeout(syncTimer);
253
343
  syncTimer = undefined;
254
344
  while (syncing) await syncing;
@@ -261,7 +351,7 @@ async function flushSync(colors?: Partial<Record<Slot, string[]>>) {
261
351
  baseRev,
262
352
  writer,
263
353
  params: viewerParams ?? {},
264
- ...(colors ? { colors } : {}),
354
+ ...finish,
265
355
  });
266
356
  const next = res.structuredContent as Design | undefined;
267
357
  if (res.isError || !next) {
@@ -275,11 +365,11 @@ async function flushSync(colors?: Partial<Record<Slot, string[]>>) {
275
365
  return;
276
366
  }
277
367
  // Anything besides our own write since baseRev (a chat change merged in)
278
- // means the viewer is behind. So is a colour, which it cannot set itself.
368
+ // means the viewer is behind. So does a colour, which lives in the scene file.
279
369
  const merged = next.rev > baseRev + 1;
280
- 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…");
281
371
  else loadedRev = next.rev;
282
- if (!merged && !colors) setStatus("Saved", 1_500);
372
+ if (!merged && !finish) setStatus("Saved", 1_500);
283
373
  void pushModelContext(app, contextLine(next));
284
374
  } catch {
285
375
  setStatus("Couldn't save that change.");
@@ -292,14 +382,15 @@ async function flushSync(colors?: Partial<Record<Slot, string[]>>) {
292
382
  }
293
383
  }
294
384
 
295
- /** "coral", or "coral → violet gradient" */
296
- function paintText(paint: string[]) {
297
- 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}°` : ""}`;
298
389
  }
299
390
 
300
391
  const contextLine = (d: Design) =>
301
392
  `The user adjusted their camera in the card. Design ${d.designId}, revision ${d.rev}: ` +
302
- `shell ${paintText(d.colors.shell)}, button ${paintText(d.colors.button)}; ` +
393
+ `shell ${paintText(d.colors.shell, d.gradientAngles.shell)}, button ${paintText(d.colors.button, d.gradientAngles.button)}; ` +
303
394
  d.options.map((o) => `${o.label} ${o.display}`).join(", ") +
304
395
  ".";
305
396
 
@@ -379,7 +470,8 @@ function render() {
379
470
  $("price").textContent = `Full-colour print · ${design.price.label}`;
380
471
  $("order-summary").textContent =
381
472
  `Your camera, printed in full colour: ${design.price.label}. ` +
382
- `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)}.`;
383
475
  if (!fallback.hidden) showFallback();
384
476
  }
385
477
 
@@ -414,7 +506,12 @@ function renderStops(slot: Slot, paint: string[]) {
414
506
  const remove = iconButton("−", `${slot}: remove the selected gradient colour`, paint.length <= 2, () => {
415
507
  void setPaint(slot, paint.filter((_, i) => i !== activeStop[slot]));
416
508
  });
417
- 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);
418
515
  }
419
516
 
420
517
  function iconButton(text: string, label: string, disabled: boolean, onClick: () => void) {
@@ -458,7 +555,18 @@ async function setPaint(slot: Slot, paint: string[]) {
458
555
  design = { ...design, colors: { ...design.colors, [slot]: paint } };
459
556
  render();
460
557
  setStatus("Saving…");
461
- 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 } });
462
570
  }
463
571
 
464
572
  for (const slot of SLOTS) {
@@ -526,6 +634,7 @@ async function placeOrder() {
526
634
  setStatus("Placing your order…");
527
635
  try {
528
636
  // Order what is on screen: save any pending knob turn first.
637
+ await freshViewerParams();
529
638
  if (syncTimer !== undefined || syncing) await flushSync();
530
639
  const res = await callTool(app, "place_order", { designId: design.designId, rev: design.rev, writer, contact });
531
640
  const order = res.structuredContent as OrderResult | undefined;