@womp/mcp-ui 0.1.12 → 0.1.14

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.12",
3
+ "version": "0.1.14",
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",
@@ -120,6 +120,102 @@
120
120
  outline-offset: 2px;
121
121
  }
122
122
 
123
+ /* Any colour: the native picker (Chromium's has an eyedropper of its own). */
124
+ .pick {
125
+ position: relative;
126
+ flex: none;
127
+ width: 22px;
128
+ height: 22px;
129
+ border-radius: 50%;
130
+ border: 1px solid rgba(0, 0, 0, 0.18);
131
+ background: conic-gradient(#ff5e5e, #ffd93d, #6bdc6b, #4fc3f7, #7b6cf6, #f06bd8, #ff5e5e);
132
+ cursor: pointer;
133
+ box-sizing: border-box;
134
+ }
135
+ .pick input {
136
+ position: absolute;
137
+ inset: 0;
138
+ width: 100%;
139
+ height: 100%;
140
+ opacity: 0;
141
+ padding: 0;
142
+ border: 0;
143
+ cursor: pointer;
144
+ }
145
+ .icon-button {
146
+ flex: none;
147
+ display: inline-grid;
148
+ place-items: center;
149
+ width: 24px;
150
+ height: 24px;
151
+ padding: 0;
152
+ border-radius: 50%;
153
+ border: 1px solid var(--color-border-primary, rgba(0, 0, 0, 0.14));
154
+ background: transparent;
155
+ color: var(--color-text-primary, #1b1b1a);
156
+ font: inherit;
157
+ font-size: 14px;
158
+ line-height: 1;
159
+ cursor: pointer;
160
+ }
161
+ .icon-button[hidden] { display: none; }
162
+ .icon-button:disabled { opacity: 0.4; cursor: default; }
163
+
164
+ .mode {
165
+ flex: none;
166
+ display: inline-flex;
167
+ gap: 2px;
168
+ padding: 2px;
169
+ border-radius: var(--border-radius-full, 999px);
170
+ border: 1px solid var(--color-border-primary, rgba(0, 0, 0, 0.14));
171
+ }
172
+ .mode button {
173
+ font: inherit;
174
+ font-size: var(--font-text-xs-size, 12px);
175
+ padding: 3px 10px;
176
+ border: 0;
177
+ border-radius: var(--border-radius-full, 999px);
178
+ background: transparent;
179
+ color: var(--color-text-secondary, rgba(27, 27, 26, 0.62));
180
+ cursor: pointer;
181
+ }
182
+ .mode button[aria-pressed="true"] {
183
+ background: var(--color-text-primary, #1b1b1a);
184
+ color: var(--color-background-primary, #ffffff);
185
+ }
186
+
187
+ /* A gradient: its colours on a track, the one being edited ringed. */
188
+ .stops {
189
+ display: flex;
190
+ align-items: center;
191
+ gap: 6px;
192
+ min-width: 0;
193
+ }
194
+ .stops[hidden] { display: none; }
195
+ .track {
196
+ position: relative;
197
+ width: 132px;
198
+ height: 22px;
199
+ border-radius: var(--border-radius-full, 999px);
200
+ border: 1px solid rgba(0, 0, 0, 0.18);
201
+ box-sizing: border-box;
202
+ }
203
+ .stop {
204
+ position: absolute;
205
+ top: 50%;
206
+ width: 18px;
207
+ height: 18px;
208
+ margin: -9px 0 0 -9px;
209
+ padding: 0;
210
+ border-radius: 50%;
211
+ border: 2px solid #ffffff;
212
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
213
+ cursor: pointer;
214
+ }
215
+ .stop[aria-pressed="true"] {
216
+ box-shadow: 0 0 0 2px var(--color-text-primary, #1b1b1a);
217
+ }
218
+
123
219
  .foot {
124
220
  display: flex;
125
221
  align-items: center;
@@ -212,8 +308,38 @@
212
308
 
213
309
  <div class="view" id="view-design">
214
310
  <div class="controls">
215
- <div class="row"><span>Shell</span><div class="swatches" id="sw-shell"></div></div>
216
- <div class="row"><span>Button</span><div class="swatches" id="sw-button"></div></div>
311
+ <div class="row">
312
+ <span>Shell</span>
313
+ <div class="mode" role="group" aria-label="Shell finish">
314
+ <button type="button" id="solid-shell" aria-pressed="true">Solid</button>
315
+ <button type="button" id="gradient-shell" aria-pressed="false">Gradient</button>
316
+ </div>
317
+ <div class="stops" id="stops-shell" hidden></div>
318
+ </div>
319
+ <div class="row">
320
+ <span></span>
321
+ <div class="swatches" id="sw-shell"></div>
322
+ <label class="pick" title="Any colour"><input type="color" id="pick-shell" aria-label="Shell: any colour" /></label>
323
+ <button class="icon-button" type="button" id="dropper-shell" title="Pick a colour from the screen" aria-label="Shell: pick a colour from the screen" hidden>
324
+ <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="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>
325
+ </button>
326
+ </div>
327
+ <div class="row">
328
+ <span>Button</span>
329
+ <div class="mode" role="group" aria-label="Button finish">
330
+ <button type="button" id="solid-button" aria-pressed="true">Solid</button>
331
+ <button type="button" id="gradient-button" aria-pressed="false">Gradient</button>
332
+ </div>
333
+ <div class="stops" id="stops-button" hidden></div>
334
+ </div>
335
+ <div class="row">
336
+ <span></span>
337
+ <div class="swatches" id="sw-button"></div>
338
+ <label class="pick" title="Any colour"><input type="color" id="pick-button" aria-label="Button: any colour" /></label>
339
+ <button class="icon-button" type="button" id="dropper-button" title="Pick a colour from the screen" aria-label="Button: pick a colour from the screen" hidden>
340
+ <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="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>
341
+ </button>
342
+ </div>
217
343
  </div>
218
344
  <div class="foot">
219
345
  <div class="price" id="price"></div>
@@ -17,7 +17,9 @@ import { $, callTool, createCard, pushModelContext, toggleFullscreen } from "../
17
17
  * 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.
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.
21
23
  *
22
24
  * Ordering happens in the card: Order (or a `checkout_camera` result, which
23
25
  * arrives with mode "checkout") opens the shipping form, and the form submits to
@@ -39,7 +41,8 @@ interface Design {
39
41
  viewerOrigin: string;
40
42
  sceneUrl: string;
41
43
  options: DesignOption[];
42
- colors: Record<Slot, string>;
44
+ /** Each part's colours: one is solid, more is a gradient. */
45
+ colors: Record<Slot, string[]>;
43
46
  palette: { name: string; hex: string }[];
44
47
  price: { amount: number; currency: string; label: string };
45
48
  lastChange?: { source: string; writer: string; fields: string[] };
@@ -55,6 +58,13 @@ type Slot = "shell" | "button";
55
58
  const SLOTS: Slot[] = ["shell", "button"];
56
59
  type View = "design" | "order" | "done";
57
60
 
61
+ /** The most colours a gradient takes (the server holds the same limit). */
62
+ const MAX_COLORS = 3;
63
+
64
+ /** Chromium's EyeDropper API; absent elsewhere. */
65
+ type EyeDropperCtor = new () => { open(): Promise<{ sRGBHex: string }> };
66
+ const EyeDropper = (window as Window & { EyeDropper?: EyeDropperCtor }).EyeDropper;
67
+
58
68
  const CONTACT_FIELDS = ["name", "email", "address", "city", "postcode", "country"] as const;
59
69
 
60
70
  /** Marks this card's own writes, so its polling does not reload the viewer for them. */
@@ -78,6 +88,8 @@ let readyTimer: number | undefined;
78
88
  let pollTimer: number | undefined;
79
89
  let lastActivity = Date.now();
80
90
  let statusTimer: number | undefined;
91
+ /** Which colour of a part's gradient the swatches, picker and eyedropper set. */
92
+ const activeStop: Record<Slot, number> = { shell: 0, button: 0 };
81
93
 
82
94
  function setStatus(text: string, clearAfterMs?: number) {
83
95
  clearTimeout(statusTimer);
@@ -165,8 +177,8 @@ function showFallback() {
165
177
  "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.";
166
178
  const list = document.createElement("dl");
167
179
  const rows: [string, string][] = [
168
- ["Shell colour", design.colors.shell],
169
- ["Button colour", design.colors.button],
180
+ ["Shell colour", paintText(design.colors.shell)],
181
+ ["Button colour", paintText(design.colors.button)],
170
182
  ...design.options.map((o) => [o.label, o.display] as [string, string]),
171
183
  ];
172
184
  for (const [k, v] of rows) {
@@ -192,8 +204,17 @@ function flatten(scripts: unknown): Record<string, number> {
192
204
  return out;
193
205
  }
194
206
 
207
+ /**
208
+ * Where the viewer's messages come from. A host that frames the card on an
209
+ * opaque origin (a sandbox without allow-same-origin) passes that sandbox down
210
+ * to the viewer's frame, which then speaks as "null" — still our frame, as
211
+ * `event.source` proves.
212
+ */
213
+ const fromViewer = (event: MessageEvent, d: Design) =>
214
+ event.source === viewer.contentWindow && (event.origin === d.viewerOrigin || event.origin === "null");
215
+
195
216
  window.addEventListener("message", (event) => {
196
- if (!design || event.source !== viewer.contentWindow || event.origin !== design.viewerOrigin) return;
217
+ if (!design || !fromViewer(event, design)) return;
197
218
  const msg = event.data as { type?: string; scripts?: unknown };
198
219
  if (msg?.type === "kakapo:ready") {
199
220
  viewerReady = true;
@@ -213,7 +234,8 @@ window.addEventListener("message", (event) => {
213
234
  // The viewer has no change event, so read its knobs on an interval while visible.
214
235
  window.setInterval(() => {
215
236
  if (!design || !viewerReady || document.visibilityState !== "visible") return;
216
- viewer.contentWindow?.postMessage({ type: "kakapo:getParams" }, design.viewerOrigin);
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" }, "*");
217
239
  }, 1_500);
218
240
 
219
241
  function onViewerParams(params: Record<string, number>) {
@@ -233,10 +255,10 @@ function onViewerParams(params: Record<string, number>) {
233
255
  // ---- saving -------------------------------------------------------------------
234
256
 
235
257
  /**
236
- * Save the viewer's knobs (and a colour, if one was picked). Serialised, so two
237
- * quick changes cannot race each other's baseRev.
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.
238
260
  */
239
- async function flushSync(colors?: Partial<Record<Slot, string>>) {
261
+ async function flushSync(colors?: Partial<Record<Slot, string[]>>) {
240
262
  clearTimeout(syncTimer);
241
263
  syncTimer = undefined;
242
264
  while (syncing) await syncing;
@@ -280,9 +302,14 @@ async function flushSync(colors?: Partial<Record<Slot, string>>) {
280
302
  }
281
303
  }
282
304
 
305
+ /** "coral", or "coral → violet gradient" */
306
+ function paintText(paint: string[]) {
307
+ return paint.length > 1 ? `${paint.join(" → ")} gradient` : paint[0];
308
+ }
309
+
283
310
  const contextLine = (d: Design) =>
284
311
  `The user adjusted their camera in the card. Design ${d.designId}, revision ${d.rev}: ` +
285
- `shell ${d.colors.shell}, button ${d.colors.button}; ` +
312
+ `shell ${paintText(d.colors.shell)}, button ${paintText(d.colors.button)}; ` +
286
313
  d.options.map((o) => `${o.label} ${o.display}`).join(", ") +
287
314
  ".";
288
315
 
@@ -321,15 +348,30 @@ async function poll() {
321
348
 
322
349
  // ---- controls -----------------------------------------------------------------
323
350
 
351
+ /** A colour as CSS: a palette name's swatch, or the hex itself. */
352
+ function cssColor(color: string) {
353
+ return design?.palette.find((p) => p.name === color)?.hex ?? color;
354
+ }
355
+
324
356
  function render() {
325
357
  if (!design) return;
326
358
  for (const slot of SLOTS) {
359
+ const paint = design.colors[slot];
360
+ const gradient = paint.length > 1;
361
+ activeStop[slot] = Math.min(activeStop[slot], paint.length - 1);
362
+ const chosen = paint[activeStop[slot]];
363
+
364
+ $(`solid-${slot}`).setAttribute("aria-pressed", String(!gradient));
365
+ $(`gradient-${slot}`).setAttribute("aria-pressed", String(gradient));
366
+ renderStops(slot, paint);
367
+
327
368
  const row = $(`sw-${slot}`);
328
369
  row.textContent = "";
329
- const chosen = design.colors[slot];
330
370
  const entries = [...design.palette];
331
- // A colour the model set as a hex has no swatch of its own — show it first.
332
- if (chosen.startsWith("#") && !entries.some((p) => p.name === chosen)) entries.unshift({ name: chosen, hex: chosen });
371
+ // A hex (from the model, the picker or the eyedropper) has no swatch of its own — show it first.
372
+ for (const color of [...new Set(paint)].reverse()) {
373
+ if (color.startsWith("#") && !entries.some((p) => p.name === color)) entries.unshift({ name: color, hex: color });
374
+ }
333
375
  for (const p of entries) {
334
376
  const b = document.createElement("button");
335
377
  b.type = "button";
@@ -341,18 +383,120 @@ function render() {
341
383
  b.addEventListener("click", () => void pickColor(slot, p.name));
342
384
  row.append(b);
343
385
  }
386
+ ($(`pick-${slot}`) as HTMLInputElement).value = cssColor(chosen);
387
+ $(`dropper-${slot}`).hidden = !EyeDropper;
344
388
  }
345
389
  $("price").textContent = `Full-colour print · ${design.price.label}`;
346
390
  $("order-summary").textContent =
347
- `Your camera, printed in full colour: ${design.price.label}. Shell ${design.colors.shell}, button ${design.colors.button}.`;
391
+ `Your camera, printed in full colour: ${design.price.label}. ` +
392
+ `Shell ${paintText(design.colors.shell)}, button ${paintText(design.colors.button)}.`;
348
393
  if (!fallback.hidden) showFallback();
349
394
  }
350
395
 
351
- async function pickColor(slot: Slot, name: string) {
352
- if (!design || design.colors[slot] === name) return;
396
+ /** A gradient's colours on a track, first to last; picking one makes it the one the swatches set. */
397
+ function renderStops(slot: Slot, paint: string[]) {
398
+ const box = $(`stops-${slot}`);
399
+ box.hidden = paint.length < 2;
400
+ box.textContent = "";
401
+ if (paint.length < 2) return;
402
+ const track = document.createElement("div");
403
+ track.className = "track";
404
+ track.style.background = `linear-gradient(90deg, ${paint.map(cssColor).join(", ")})`;
405
+ paint.forEach((color, i) => {
406
+ const stop = document.createElement("button");
407
+ stop.type = "button";
408
+ stop.className = "stop";
409
+ stop.style.left = `calc(11px + (100% - 22px) * ${i / (paint.length - 1)})`;
410
+ stop.style.background = cssColor(color);
411
+ stop.title = color;
412
+ stop.setAttribute("aria-label", `${slot} gradient colour ${i + 1} of ${paint.length}: ${color}`);
413
+ stop.setAttribute("aria-pressed", String(i === activeStop[slot]));
414
+ stop.addEventListener("click", () => {
415
+ activeStop[slot] = i;
416
+ render();
417
+ });
418
+ track.append(stop);
419
+ });
420
+ const add = iconButton("+", `${slot}: add a gradient colour`, paint.length >= MAX_COLORS, () => {
421
+ activeStop[slot] = paint.length;
422
+ void setPaint(slot, [...paint, partner(paint[paint.length - 1])]);
423
+ });
424
+ const remove = iconButton("−", `${slot}: remove the selected gradient colour`, paint.length <= 2, () => {
425
+ void setPaint(slot, paint.filter((_, i) => i !== activeStop[slot]));
426
+ });
427
+ box.append(track, add, remove);
428
+ }
429
+
430
+ function iconButton(text: string, label: string, disabled: boolean, onClick: () => void) {
431
+ const b = document.createElement("button");
432
+ b.type = "button";
433
+ b.className = "icon-button";
434
+ b.textContent = text;
435
+ b.title = label;
436
+ b.setAttribute("aria-label", label);
437
+ b.disabled = disabled;
438
+ b.addEventListener("click", onClick);
439
+ return b;
440
+ }
441
+
442
+ /** A colour that reads well next to `color`, for a gradient's new end. */
443
+ function partner(color: string) {
444
+ return color === "violet" ? "sky" : "violet";
445
+ }
446
+
447
+ /** The eyedropper's answer as #rrggbb; some Chromium versions say `rgb(r, g, b)`. */
448
+ function toHex(color: string) {
449
+ const rgb = /^rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(color);
450
+ if (!rgb) return color.toLowerCase();
451
+ return `#${rgb.slice(1, 4).map((c) => Number(c).toString(16).padStart(2, "0")).join("")}`;
452
+ }
453
+
454
+ /** Set the colour being edited: the part's only colour, or the selected one of its gradient. */
455
+ async function pickColor(slot: Slot, color: string) {
456
+ if (!design) return;
457
+ const paint = [...design.colors[slot]];
458
+ paint[activeStop[slot]] = color;
459
+ await setPaint(slot, paint);
460
+ }
461
+
462
+ async function setPaint(slot: Slot, paint: string[]) {
463
+ if (!design) return;
464
+ const current = design.colors[slot];
465
+ if (paint.length === current.length && paint.every((c, i) => c === current[i])) return;
353
466
  lastActivity = Date.now();
467
+ // Show it at once; the save's answer replaces it.
468
+ design = { ...design, colors: { ...design.colors, [slot]: paint } };
469
+ render();
354
470
  setStatus("Saving…");
355
- await flushSync({ [slot]: name });
471
+ await flushSync({ [slot]: paint });
472
+ }
473
+
474
+ for (const slot of SLOTS) {
475
+ $(`solid-${slot}`).addEventListener("click", () => {
476
+ if (!design || design.colors[slot].length < 2) return;
477
+ const keep = design.colors[slot][activeStop[slot]];
478
+ activeStop[slot] = 0;
479
+ void setPaint(slot, [keep]);
480
+ });
481
+ $(`gradient-${slot}`).addEventListener("click", () => {
482
+ if (!design || design.colors[slot].length > 1) return;
483
+ const [only] = design.colors[slot];
484
+ activeStop[slot] = 1;
485
+ void setPaint(slot, [only, partner(only)]);
486
+ });
487
+ // `change`, not `input`: the picker fires `input` on every drag, and each save reloads the viewer.
488
+ $(`pick-${slot}`).addEventListener("change", (event) => {
489
+ void pickColor(slot, (event.target as HTMLInputElement).value.toLowerCase());
490
+ });
491
+ $(`dropper-${slot}`).addEventListener("click", async () => {
492
+ if (!EyeDropper) return;
493
+ try {
494
+ const { sRGBHex } = await new EyeDropper().open();
495
+ await pickColor(slot, toHex(sRGBHex));
496
+ } catch {
497
+ // Escape cancels it; a host may also refuse it.
498
+ }
499
+ });
356
500
  }
357
501
 
358
502
  // ---- ordering -----------------------------------------------------------------