@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/README.md +9 -6
- package/dist/index.d.ts +4 -0
- package/dist/index.js +17 -8
- package/dist/openai-camera/index.html +9 -91
- package/dist/openai-quote/index.html +223 -0
- package/package.json +4 -3
- package/src/cart/main.ts +1 -1
- package/src/openai-camera/index.html +2 -84
- package/src/openai-camera/main.ts +144 -102
- package/src/openai-quote/index.html +149 -0
- package/src/openai-quote/main.ts +131 -0
- package/src/print/main.ts +3 -3
- package/src/shared/bridge.ts +41 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@womp/mcp-ui",
|
|
3
|
-
"version": "0.1.
|
|
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 `/
|
|
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"
|
|
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
|
|
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…</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
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
...
|
|
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
|
|
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 ||
|
|
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 && !
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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("
|
|
591
|
+
setStatus("Getting your quote…");
|
|
537
592
|
try {
|
|
538
|
-
|
|
593
|
+
await freshViewerParams();
|
|
539
594
|
if (syncTimer !== undefined || syncing) await flushSync();
|
|
540
|
-
|
|
541
|
-
const
|
|
542
|
-
|
|
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("
|
|
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",
|
|
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(() => {
|