@womp/mcp-ui 0.1.12 → 0.1.13
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.js +8 -8
- package/dist/openai-camera/index.html +145 -19
- package/package.json +1 -1
- package/src/openai-camera/index.html +128 -2
- package/src/openai-camera/main.ts +149 -15
package/package.json
CHANGED
|
@@ -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"
|
|
216
|
-
|
|
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
|
-
|
|
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) {
|
|
@@ -233,10 +245,10 @@ function onViewerParams(params: Record<string, number>) {
|
|
|
233
245
|
// ---- saving -------------------------------------------------------------------
|
|
234
246
|
|
|
235
247
|
/**
|
|
236
|
-
* Save the viewer's knobs (and a
|
|
237
|
-
* quick changes cannot race each other's baseRev.
|
|
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.
|
|
238
250
|
*/
|
|
239
|
-
async function flushSync(colors?: Partial<Record<Slot, string>>) {
|
|
251
|
+
async function flushSync(colors?: Partial<Record<Slot, string[]>>) {
|
|
240
252
|
clearTimeout(syncTimer);
|
|
241
253
|
syncTimer = undefined;
|
|
242
254
|
while (syncing) await syncing;
|
|
@@ -280,9 +292,14 @@ async function flushSync(colors?: Partial<Record<Slot, string>>) {
|
|
|
280
292
|
}
|
|
281
293
|
}
|
|
282
294
|
|
|
295
|
+
/** "coral", or "coral → violet gradient" */
|
|
296
|
+
function paintText(paint: string[]) {
|
|
297
|
+
return paint.length > 1 ? `${paint.join(" → ")} gradient` : paint[0];
|
|
298
|
+
}
|
|
299
|
+
|
|
283
300
|
const contextLine = (d: Design) =>
|
|
284
301
|
`The user adjusted their camera in the card. Design ${d.designId}, revision ${d.rev}: ` +
|
|
285
|
-
`shell ${d.colors.shell}, button ${d.colors.button}; ` +
|
|
302
|
+
`shell ${paintText(d.colors.shell)}, button ${paintText(d.colors.button)}; ` +
|
|
286
303
|
d.options.map((o) => `${o.label} ${o.display}`).join(", ") +
|
|
287
304
|
".";
|
|
288
305
|
|
|
@@ -321,15 +338,30 @@ async function poll() {
|
|
|
321
338
|
|
|
322
339
|
// ---- controls -----------------------------------------------------------------
|
|
323
340
|
|
|
341
|
+
/** A colour as CSS: a palette name's swatch, or the hex itself. */
|
|
342
|
+
function cssColor(color: string) {
|
|
343
|
+
return design?.palette.find((p) => p.name === color)?.hex ?? color;
|
|
344
|
+
}
|
|
345
|
+
|
|
324
346
|
function render() {
|
|
325
347
|
if (!design) return;
|
|
326
348
|
for (const slot of SLOTS) {
|
|
349
|
+
const paint = design.colors[slot];
|
|
350
|
+
const gradient = paint.length > 1;
|
|
351
|
+
activeStop[slot] = Math.min(activeStop[slot], paint.length - 1);
|
|
352
|
+
const chosen = paint[activeStop[slot]];
|
|
353
|
+
|
|
354
|
+
$(`solid-${slot}`).setAttribute("aria-pressed", String(!gradient));
|
|
355
|
+
$(`gradient-${slot}`).setAttribute("aria-pressed", String(gradient));
|
|
356
|
+
renderStops(slot, paint);
|
|
357
|
+
|
|
327
358
|
const row = $(`sw-${slot}`);
|
|
328
359
|
row.textContent = "";
|
|
329
|
-
const chosen = design.colors[slot];
|
|
330
360
|
const entries = [...design.palette];
|
|
331
|
-
// A
|
|
332
|
-
|
|
361
|
+
// A hex (from the model, the picker or the eyedropper) has no swatch of its own — show it first.
|
|
362
|
+
for (const color of [...new Set(paint)].reverse()) {
|
|
363
|
+
if (color.startsWith("#") && !entries.some((p) => p.name === color)) entries.unshift({ name: color, hex: color });
|
|
364
|
+
}
|
|
333
365
|
for (const p of entries) {
|
|
334
366
|
const b = document.createElement("button");
|
|
335
367
|
b.type = "button";
|
|
@@ -341,18 +373,120 @@ function render() {
|
|
|
341
373
|
b.addEventListener("click", () => void pickColor(slot, p.name));
|
|
342
374
|
row.append(b);
|
|
343
375
|
}
|
|
376
|
+
($(`pick-${slot}`) as HTMLInputElement).value = cssColor(chosen);
|
|
377
|
+
$(`dropper-${slot}`).hidden = !EyeDropper;
|
|
344
378
|
}
|
|
345
379
|
$("price").textContent = `Full-colour print · ${design.price.label}`;
|
|
346
380
|
$("order-summary").textContent =
|
|
347
|
-
`Your camera, printed in full colour: ${design.price.label}.
|
|
381
|
+
`Your camera, printed in full colour: ${design.price.label}. ` +
|
|
382
|
+
`Shell ${paintText(design.colors.shell)}, button ${paintText(design.colors.button)}.`;
|
|
348
383
|
if (!fallback.hidden) showFallback();
|
|
349
384
|
}
|
|
350
385
|
|
|
351
|
-
|
|
352
|
-
|
|
386
|
+
/** A gradient's colours on a track, first to last; picking one makes it the one the swatches set. */
|
|
387
|
+
function renderStops(slot: Slot, paint: string[]) {
|
|
388
|
+
const box = $(`stops-${slot}`);
|
|
389
|
+
box.hidden = paint.length < 2;
|
|
390
|
+
box.textContent = "";
|
|
391
|
+
if (paint.length < 2) return;
|
|
392
|
+
const track = document.createElement("div");
|
|
393
|
+
track.className = "track";
|
|
394
|
+
track.style.background = `linear-gradient(90deg, ${paint.map(cssColor).join(", ")})`;
|
|
395
|
+
paint.forEach((color, i) => {
|
|
396
|
+
const stop = document.createElement("button");
|
|
397
|
+
stop.type = "button";
|
|
398
|
+
stop.className = "stop";
|
|
399
|
+
stop.style.left = `calc(11px + (100% - 22px) * ${i / (paint.length - 1)})`;
|
|
400
|
+
stop.style.background = cssColor(color);
|
|
401
|
+
stop.title = color;
|
|
402
|
+
stop.setAttribute("aria-label", `${slot} gradient colour ${i + 1} of ${paint.length}: ${color}`);
|
|
403
|
+
stop.setAttribute("aria-pressed", String(i === activeStop[slot]));
|
|
404
|
+
stop.addEventListener("click", () => {
|
|
405
|
+
activeStop[slot] = i;
|
|
406
|
+
render();
|
|
407
|
+
});
|
|
408
|
+
track.append(stop);
|
|
409
|
+
});
|
|
410
|
+
const add = iconButton("+", `${slot}: add a gradient colour`, paint.length >= MAX_COLORS, () => {
|
|
411
|
+
activeStop[slot] = paint.length;
|
|
412
|
+
void setPaint(slot, [...paint, partner(paint[paint.length - 1])]);
|
|
413
|
+
});
|
|
414
|
+
const remove = iconButton("−", `${slot}: remove the selected gradient colour`, paint.length <= 2, () => {
|
|
415
|
+
void setPaint(slot, paint.filter((_, i) => i !== activeStop[slot]));
|
|
416
|
+
});
|
|
417
|
+
box.append(track, add, remove);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function iconButton(text: string, label: string, disabled: boolean, onClick: () => void) {
|
|
421
|
+
const b = document.createElement("button");
|
|
422
|
+
b.type = "button";
|
|
423
|
+
b.className = "icon-button";
|
|
424
|
+
b.textContent = text;
|
|
425
|
+
b.title = label;
|
|
426
|
+
b.setAttribute("aria-label", label);
|
|
427
|
+
b.disabled = disabled;
|
|
428
|
+
b.addEventListener("click", onClick);
|
|
429
|
+
return b;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** A colour that reads well next to `color`, for a gradient's new end. */
|
|
433
|
+
function partner(color: string) {
|
|
434
|
+
return color === "violet" ? "sky" : "violet";
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** The eyedropper's answer as #rrggbb; some Chromium versions say `rgb(r, g, b)`. */
|
|
438
|
+
function toHex(color: string) {
|
|
439
|
+
const rgb = /^rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(color);
|
|
440
|
+
if (!rgb) return color.toLowerCase();
|
|
441
|
+
return `#${rgb.slice(1, 4).map((c) => Number(c).toString(16).padStart(2, "0")).join("")}`;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Set the colour being edited: the part's only colour, or the selected one of its gradient. */
|
|
445
|
+
async function pickColor(slot: Slot, color: string) {
|
|
446
|
+
if (!design) return;
|
|
447
|
+
const paint = [...design.colors[slot]];
|
|
448
|
+
paint[activeStop[slot]] = color;
|
|
449
|
+
await setPaint(slot, paint);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function setPaint(slot: Slot, paint: string[]) {
|
|
453
|
+
if (!design) return;
|
|
454
|
+
const current = design.colors[slot];
|
|
455
|
+
if (paint.length === current.length && paint.every((c, i) => c === current[i])) return;
|
|
353
456
|
lastActivity = Date.now();
|
|
457
|
+
// Show it at once; the save's answer replaces it.
|
|
458
|
+
design = { ...design, colors: { ...design.colors, [slot]: paint } };
|
|
459
|
+
render();
|
|
354
460
|
setStatus("Saving…");
|
|
355
|
-
await flushSync({ [slot]:
|
|
461
|
+
await flushSync({ [slot]: paint });
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
for (const slot of SLOTS) {
|
|
465
|
+
$(`solid-${slot}`).addEventListener("click", () => {
|
|
466
|
+
if (!design || design.colors[slot].length < 2) return;
|
|
467
|
+
const keep = design.colors[slot][activeStop[slot]];
|
|
468
|
+
activeStop[slot] = 0;
|
|
469
|
+
void setPaint(slot, [keep]);
|
|
470
|
+
});
|
|
471
|
+
$(`gradient-${slot}`).addEventListener("click", () => {
|
|
472
|
+
if (!design || design.colors[slot].length > 1) return;
|
|
473
|
+
const [only] = design.colors[slot];
|
|
474
|
+
activeStop[slot] = 1;
|
|
475
|
+
void setPaint(slot, [only, partner(only)]);
|
|
476
|
+
});
|
|
477
|
+
// `change`, not `input`: the picker fires `input` on every drag, and each save reloads the viewer.
|
|
478
|
+
$(`pick-${slot}`).addEventListener("change", (event) => {
|
|
479
|
+
void pickColor(slot, (event.target as HTMLInputElement).value.toLowerCase());
|
|
480
|
+
});
|
|
481
|
+
$(`dropper-${slot}`).addEventListener("click", async () => {
|
|
482
|
+
if (!EyeDropper) return;
|
|
483
|
+
try {
|
|
484
|
+
const { sRGBHex } = await new EyeDropper().open();
|
|
485
|
+
await pickColor(slot, toHex(sRGBHex));
|
|
486
|
+
} catch {
|
|
487
|
+
// Escape cancels it; a host may also refuse it.
|
|
488
|
+
}
|
|
489
|
+
});
|
|
356
490
|
}
|
|
357
491
|
|
|
358
492
|
// ---- ordering -----------------------------------------------------------------
|