@womp/mcp-ui 0.1.5 → 0.1.7

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/src/print/main.ts CHANGED
@@ -1,263 +1,846 @@
1
1
  import { $, callTool, createCard, openLink, type GenerationResult } from "../shared/bridge";
2
+ import { expandHexInput, nearestPantone, normalizeHex, pantoneChips } from "./pantone";
2
3
 
3
4
  /**
4
- * The print card — a live configurator, and the way out to checkout.
5
+ * The print card — the web print panel's configurator, compressed to a card.
5
6
  *
6
- * The whole point (why this is not a static receipt): the user turns the knobs
7
- * — material, size, quantity — and the card re-quotes THROUGH THE HOST by
8
- * calling `estimate_print` itself, so tuning a print never round-trips through
9
- * the conversation. The model seeds the starting settings simply by calling
10
- * the tool with them: the host announces the call's arguments (`ontoolinput`)
11
- * before any result exists, and the controls initialize from that.
7
+ * Same flow as the panel (NewPrintingPanel.tsx): Size → Material → Print style
8
+ * → review with quantity and price → the one primary action. Same controls
9
+ * where the panel has them: W/H/D in cm or inches, swatch tiles grouped
10
+ * Standard / Enterprise, a Matte/Glossy finish on clear resin, a colour picker
11
+ * with the nearest-Pantone readout on dyed materials, Solid vs Hollow with both
12
+ * prices, a quantity stepper, currency-formatted totals with the discount
13
+ * struck through. What the panel does to the 3D viewport (drain holes, wall
14
+ * thickness, X-ray) stays on womp.com, which is where Hollow hands off.
12
15
  *
13
- * Deliberately NO payment surface: "Add to cart" calls the app-callable
14
- * `add_to_cart` (creates a free project + a real cart item), and the button
15
- * then becomes "Checkout on womp.com" — a `ui/open-link` to the cart checkout
16
- * at /pay/<ids>, where address, shipping, promo codes and payment all run in
17
- * the one existing cart UI. Turning any knob after an add resets the button
18
- * to "Add to cart" so the checkout link always matches the shown settings.
16
+ * The card re-quotes THROUGH THE HOST by calling `estimate_print` itself, so
17
+ * tuning a print never round-trips through the conversation. The model seeds
18
+ * the starting settings simply by calling the tool with them: the host
19
+ * announces the call's arguments (`ontoolinput`) before any result exists.
19
20
  *
20
- * Static mode: a result that already carries `printUrl` (the model called
21
- * prepare_print directly) renders as a receipt — no controls, the button just
22
- * opens the link.
21
+ * Deliberately NO payment surface. "Add to cart" calls the app-callable
22
+ * `add_to_cart` (a free project + a real cart item) and then becomes "Checkout
23
+ * on womp.com" — a `ui/open-link` to /pay/<ids>, where address, shipping, promo
24
+ * codes and payment run in the one existing cart UI. Hollow never orders from
25
+ * here at all: escape holes are placed in the editor, so its button is
26
+ * "Continue on womp.com" via `prepare_print`.
27
+ *
28
+ * Colour: the panel snaps a pick to the nearest Pantone coated shade and sends
29
+ * the code; chat orders send a hex. The card snaps the same way and sends the
30
+ * snapped shade's own hex, so both paths print the same colour and show the
31
+ * same "Pantone 2386 C". Colour never changes the price, so it never re-quotes.
23
32
  */
24
33
 
25
- /** One priced variant, as swan-backend's QUOTE_ROW_SCHEMA emits it. */
26
34
  interface QuoteRow {
27
35
  salePrice: number;
28
36
  discount: number;
29
37
  unitPrice: number;
30
38
  productionDays: number;
31
39
  }
32
-
33
- /** structuredContent of estimate_print / prepare_print (api/mcp.ts §13). */
40
+ interface Dims {
41
+ width: number;
42
+ height: number;
43
+ depth: number;
44
+ }
45
+ interface PrintMaterialOption {
46
+ id: string;
47
+ label?: string;
48
+ group?: "standard" | "enterprise";
49
+ description?: string;
50
+ swatchUrl?: string;
51
+ available?: boolean;
52
+ unavailableReason?: string;
53
+ contactUrl?: string;
54
+ colorRequired?: boolean;
55
+ colorOptional?: boolean;
56
+ finishes?: string[];
57
+ pricedAs?: string;
58
+ }
59
+ /** structuredContent of estimate_print / prepare_print (swan-backend api/mcp.ts §13). */
34
60
  interface PrintQuoteResult {
61
+ /** The catalog key the card is keyed on — echoed unchanged by the backend. */
35
62
  material?: string;
63
+ pricedAs?: string;
64
+ materialLabel?: string;
65
+ finish?: string;
66
+ color?: string;
36
67
  quantity?: number;
37
68
  longestSideCm?: number;
38
- dimensionsCm?: { width: number; height: number; depth: number };
69
+ dimensionsCm?: Dims;
39
70
  solid?: QuoteRow;
40
71
  hollow?: QuoteRow;
41
72
  callerIsPro?: boolean;
42
- /** Which mesh was quoted — what re-quotes and prepare_print are keyed on. */
73
+ repaired?: boolean;
74
+ nonManifold?: boolean;
43
75
  source?: { generationId?: number; glbUrl?: string };
44
- /** Present on estimate results only — populates the material picker. */
45
- materials?: { id: string }[];
76
+ /** Present on estimate results only; role-aware for this account. */
77
+ materials?: PrintMaterialOption[];
46
78
  projectId?: number;
47
79
  /** Present on prepare_print results only. */
48
80
  printUrl?: string;
49
81
  }
50
82
 
51
- const usd = (n: number) => `$${n.toFixed(2)}`;
83
+ type Unit = "cm" | "in";
84
+ type Style = "solid" | "hollow";
85
+ type Axis = keyof Dims;
86
+
87
+ const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
88
+ const money = (n: number) => usd.format(n);
52
89
 
53
- const materialSelect = $("material") as HTMLSelectElement;
54
- const longestInput = $("longest") as HTMLInputElement;
55
- const quantityInput = $("quantity") as HTMLInputElement;
56
- const orderBtn = $("order") as HTMLButtonElement;
90
+ /** The bounds the panel enforces (usePrintingParams): a 60 × 60 × 40 cm ceiling. */
91
+ const HARD_MAX_CM: Dims = { width: 60, height: 60, depth: 40 };
92
+ /** The MCP contract's longest-side range. */
93
+ const MIN_LONGEST_CM = 2;
94
+ const MAX_LONGEST_CM = 60;
95
+ /** The blue the panel seeds when switching into Tinted clear (NPP:7133). */
96
+ const PANEL_DEFAULT_TINT = "#276ee2";
97
+ /** The panel's own re-price debounce. */
98
+ const REQUOTE_DEBOUNCE_MS = 500;
99
+ const AXES: Axis[] = ["width", "height", "depth"];
100
+
101
+ const state = {
102
+ materials: [] as PrintMaterialOption[],
103
+ material: "",
104
+ finish: undefined as string | undefined,
105
+ /** The user's exact pick, lowercase #rrggbb. What is SENT is its nearest Pantone's hex. */
106
+ color: undefined as string | undefined,
107
+ /** For dye-optional materials (Painted): "original" = full colour, "custom" = dyed. */
108
+ colorMode: "original" as "original" | "custom",
109
+ quantity: 1,
110
+ longestSideCm: undefined as number | undefined,
111
+ dims: undefined as Dims | undefined,
112
+ unit: "cm" as Unit,
113
+ style: "solid" as Style,
114
+ quote: undefined as PrintQuoteResult | undefined,
115
+ source: undefined as PrintQuoteResult["source"],
116
+ /** A prepare_print result rendered by a model call: a receipt, not a configurator. */
117
+ staticPrintUrl: "",
118
+ /** Set once the shown settings are in the cart; cleared by any knob turn. */
119
+ cartCheckoutUrl: "",
120
+ /** Set once a hollow hand-off project exists for the shown settings. */
121
+ preparedUrl: "",
122
+ quoting: false,
123
+ enterpriseOpen: false,
124
+ /** The gated Enterprise material the user last tapped, for the contact note. */
125
+ gatedTap: undefined as PrintMaterialOption | undefined,
126
+ sizeNotice: "",
127
+ };
57
128
 
58
- /** The mesh being quoted, and whether the card is a receipt or a configurator. */
59
- let source: { generationId?: number; glbUrl?: string } | undefined;
60
- let staticPrintUrl: string | undefined;
61
- /** Set once the shown settings are in the cart; cleared by any knob turn. */
62
- let cartCheckoutUrl = "";
63
129
  /** Monotonic guard: a slow older quote must never overwrite a newer one. */
64
130
  let quoteSeq = 0;
65
131
  let debounceTimer: number | undefined;
66
132
 
133
+ // --- helpers ---------------------------------------------------------------
134
+
135
+ const selectedMaterial = () => state.materials.find((m) => m.id === state.material);
136
+ const materialLabel = (m?: PrintMaterialOption) => m?.label || m?.id || state.quote?.materialLabel || state.material;
137
+ const isClear = (m?: PrintMaterialOption) => (m?.pricedAs || m?.id || "") === "resin-clear";
138
+ /** Whether the current selection carries a dye. */
139
+ function needsColor(m = selectedMaterial()): boolean {
140
+ if (!m) return false;
141
+ if (m.colorRequired) return true;
142
+ return !!m.colorOptional && state.colorMode === "custom";
143
+ }
144
+ /** The colour an order would carry: the nearest Pantone's hex of the user's pick. */
145
+ function snappedColor(): { code: string; hex: string } | undefined {
146
+ if (!needsColor()) return undefined;
147
+ return nearestPantone(state.color || PANEL_DEFAULT_TINT);
148
+ }
149
+ const toUnit = (cm: number, unit: Unit) => (unit === "in" ? cm / 2.54 : cm);
150
+ const fromUnit = (v: number, unit: Unit) => (unit === "in" ? v * 2.54 : v);
151
+ const fmtDim = (cm: number, unit: Unit) => toUnit(cm, unit).toFixed(unit === "in" ? 2 : 1);
152
+ const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
153
+ const textOf = (res: { content?: unknown }) =>
154
+ (res.content as { type: string; text?: string }[] | undefined)?.find((c) => c.type === "text")?.text || "";
155
+
67
156
  function setStatus(text: string, isError = false) {
68
157
  const el = $("status");
69
158
  el.textContent = text;
70
159
  el.classList.toggle("error", isError);
71
160
  }
72
161
 
73
- function renderQuote(data: PrintQuoteResult) {
74
- $("card").classList.remove("quoting");
162
+ function setQuoting(on: boolean) {
163
+ state.quoting = on;
164
+ $("card").classList.toggle("quoting", on);
165
+ renderCta();
166
+ }
75
167
 
76
- const d = data.dimensionsCm;
77
- if (d) $("size").textContent = `${d.width.toFixed(1)} x ${d.height.toFixed(1)} x ${d.depth.toFixed(1)} cm`;
168
+ /** Any knob turn: whatever was added or prepared no longer matches the card. */
169
+ function markDirty() {
170
+ state.cartCheckoutUrl = "";
171
+ state.preparedUrl = "";
172
+ }
78
173
 
79
- const solid = data.solid;
80
- if (!solid) return;
174
+ /**
175
+ * The tool's error text is written for the model; the person at the card needs
176
+ * the action, not the argument name. Anything unrecognised is shown as is —
177
+ * a real message beats a generic one.
178
+ */
179
+ function friendlyError(message: string): string {
180
+ if (/dyed material/i.test(message)) return "Pick a colour for this material first.";
181
+ if (/#rrggbb/i.test(message)) return "That colour couldn't be used. Pick another shade.";
182
+ if (/Enterprise material/i.test(message)) return "This material is available to Enterprise customers. Use Contact sales above.";
183
+ if (/escape holes/i.test(message)) return "Hollow prints are finished on womp.com, where escape holes are placed.";
184
+ if (/could not measure|degenerate/i.test(message)) return "The print engine couldn't measure this mesh.";
185
+ return message;
186
+ }
187
+
188
+ function svg(path: string, extra = ""): SVGElement {
189
+ const el = document.createElementNS("http://www.w3.org/2000/svg", "svg");
190
+ el.setAttribute("viewBox", "0 0 24 24");
191
+ el.setAttribute("fill", "none");
192
+ el.setAttribute("stroke", "currentColor");
193
+ el.setAttribute("stroke-width", "2");
194
+ el.setAttribute("stroke-linecap", "round");
195
+ el.setAttribute("stroke-linejoin", "round");
196
+ el.innerHTML = path + extra;
197
+ return el;
198
+ }
199
+ const ICON_INFO = '<circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/>';
200
+ const ICON_WARN = '<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z"/><path d="M12 9v4M12 17h.01"/>';
201
+ const ICON_CROWN = '<path d="M3 18h18l-1.5-9-4.5 4-3-6-3 6-4.5-4L3 18Z" fill="currentColor" stroke="none"/>';
202
+
203
+ function callout(kind: "info" | "warn" | "danger", body: (HTMLElement | string)[]): HTMLElement {
204
+ const el = document.createElement("div");
205
+ el.className = `callout ${kind}`;
206
+ el.append(svg(kind === "info" ? ICON_INFO : ICON_WARN));
207
+ const text = document.createElement("div");
208
+ text.append(...body);
209
+ el.append(text);
210
+ return el;
211
+ }
81
212
 
82
- const price = $("price");
83
- price.textContent = "";
84
- const amount = document.createElement("span");
85
- amount.className = "amount";
86
- amount.textContent = usd(solid.unitPrice);
87
- price.append(amount);
88
- if (solid.discount > 0) {
89
- const was = document.createElement("span");
90
- was.className = "was";
91
- was.textContent = usd(solid.salePrice);
92
- price.append(was);
213
+ function linkButton(label: string, url: string): HTMLElement {
214
+ const b = document.createElement("button");
215
+ b.type = "button";
216
+ b.className = "link";
217
+ b.textContent = label;
218
+ b.addEventListener("click", () => void openLink(card.app, url));
219
+ return b;
220
+ }
221
+
222
+ // --- rendering -------------------------------------------------------------
223
+
224
+ function render() {
225
+ renderHead();
226
+ renderMeshCallouts();
227
+ renderSize();
228
+ renderMaterials();
229
+ renderSubControls();
230
+ renderStyle();
231
+ renderReview();
232
+ renderCta();
233
+ }
234
+
235
+ function renderHead() {
236
+ const d = state.dims;
237
+ $("dims").textContent = d
238
+ ? `${fmtDim(d.width, state.unit)} × ${fmtDim(d.height, state.unit)} × ${fmtDim(d.depth, state.unit)} ${state.unit}`
239
+ : "";
240
+ }
241
+
242
+ function renderMeshCallouts() {
243
+ const holder = $("mesh-callouts");
244
+ holder.textContent = "";
245
+ const q = state.quote;
246
+ if (!q) return;
247
+ if (q.nonManifold) {
248
+ holder.append(
249
+ callout("warn", [
250
+ "Parts of this mesh aren't watertight" +
251
+ (q.repaired ? ", even after repair" : "") +
252
+ ". It can be ordered, but the print may have defects. Every order is reviewed before printing.",
253
+ ]),
254
+ );
255
+ } else if (q.repaired) {
256
+ holder.append(
257
+ callout("info", [
258
+ "This mesh wasn't watertight, so it was repaired automatically for printing. Repair can subtly change the shape — check the preview.",
259
+ ]),
260
+ );
93
261
  }
94
- const per = document.createElement("span");
95
- per.className = "per";
96
- per.textContent = data.quantity && data.quantity > 1 ? `per unit x ${data.quantity}` : "per unit";
97
- price.append(per);
98
-
99
- const rows = $("rows");
100
- rows.textContent = "";
101
- if (data.hollow) {
102
- const hollow = document.createElement("div");
103
- hollow.textContent = `hollow (Pro): ${usd(data.hollow.unitPrice)} per unit${data.callerIsPro ? "" : " — requires a subscription"}`;
104
- rows.append(hollow);
262
+ }
263
+
264
+ /** Any axis past the panel's ceiling blocks the order, as the panel does. */
265
+ function overHardLimit(): boolean {
266
+ const d = state.dims;
267
+ return !!d && AXES.some((a) => d[a] > HARD_MAX_CM[a] + 1e-6);
268
+ }
269
+
270
+ function renderSize() {
271
+ const d = state.dims;
272
+ for (const axis of AXES) {
273
+ const input = $(`dim-${axis}`) as HTMLInputElement;
274
+ if (document.activeElement === input) continue; // never fight the typist
275
+ input.value = d ? fmtDim(d[axis], state.unit) : "";
105
276
  }
106
- const days = document.createElement("div");
107
- days.textContent = `~${solid.productionDays} production day${solid.productionDays === 1 ? "" : "s"}`;
108
- rows.append(days);
277
+ document.querySelectorAll<HTMLButtonElement>("#unit [data-unit]").forEach((b) => {
278
+ b.setAttribute("aria-checked", String(b.dataset.unit === state.unit));
279
+ });
280
+ const holder = $("size-callouts");
281
+ holder.textContent = "";
282
+ if (overHardLimit()) {
283
+ const lim = HARD_MAX_CM;
284
+ holder.append(
285
+ callout("danger", [
286
+ `This exceeds the maximum printable size, ${fmtDim(lim.width, state.unit)} × ${fmtDim(lim.height, state.unit)} × ${fmtDim(lim.depth, state.unit)} ${state.unit}. Reduce the size to continue.`,
287
+ ]),
288
+ );
289
+ } else if (state.sizeNotice) {
290
+ holder.append(callout("info", [state.sizeNotice]));
291
+ }
292
+ }
109
293
 
110
- $("note").hidden = false;
111
- orderBtn.hidden = false;
112
- orderBtn.disabled = false;
113
- setStatus("");
294
+ function tile(m: PrintMaterialOption): HTMLElement {
295
+ const b = document.createElement("button");
296
+ b.type = "button";
297
+ b.className = "tile";
298
+ b.setAttribute("role", "radio");
299
+ b.setAttribute("aria-checked", String(m.id === state.material));
300
+ if (m.available === false) b.setAttribute("aria-disabled", "true");
301
+ b.title = m.description || m.label || m.id;
302
+ // The catalog's swatch when it has one and it loads; the label's initial in
303
+ // the same box otherwise — a broken-image glyph next to a picker reads as a
304
+ // broken card.
305
+ const placeholder = () => {
306
+ const ph = document.createElement("div");
307
+ ph.className = "tile-img";
308
+ ph.textContent = (m.label || m.id).charAt(0).toUpperCase();
309
+ return ph;
310
+ };
311
+ if (m.swatchUrl) {
312
+ const img = document.createElement("img");
313
+ img.className = "tile-img";
314
+ img.src = m.swatchUrl;
315
+ img.alt = "";
316
+ img.loading = "lazy";
317
+ img.addEventListener("error", () => img.replaceWith(placeholder()), { once: true });
318
+ b.append(img);
319
+ } else {
320
+ b.append(placeholder());
321
+ }
322
+ if (m.group === "enterprise") {
323
+ const crown = document.createElement("span");
324
+ crown.className = "crown";
325
+ crown.append(svg(ICON_CROWN));
326
+ b.append(crown);
327
+ }
328
+ const label = document.createElement("span");
329
+ label.className = "tile-label";
330
+ label.textContent = m.label || m.id;
331
+ b.append(label);
332
+ b.addEventListener("click", () => selectMaterial(m.id));
333
+ return b;
114
334
  }
115
335
 
116
- /** Fill the controls from settings without firing their change handlers. */
117
- function syncControls(data: PrintQuoteResult) {
118
- if (data.material) {
119
- // The picker may not exist yet on the very first input announcement; the
120
- // result's materials list (below) is what populates it.
121
- if (![...materialSelect.options].some((o) => o.value === data.material)) {
122
- const opt = document.createElement("option");
123
- opt.value = data.material;
124
- opt.textContent = data.material;
125
- materialSelect.append(opt);
336
+ function renderMaterials() {
337
+ const standard = state.materials.filter((m) => (m.group || "standard") !== "enterprise");
338
+ const enterprise = state.materials.filter((m) => m.group === "enterprise");
339
+
340
+ const std = $("tiles-standard");
341
+ std.textContent = "";
342
+ standard.forEach((m) => std.append(tile(m)));
343
+
344
+ const toggle = $("enterprise-toggle");
345
+ const ent = $("tiles-enterprise");
346
+ ent.textContent = "";
347
+ toggle.hidden = !enterprise.length;
348
+ if (enterprise.length) {
349
+ // Opens itself when the selection is an Enterprise material, so the ring is
350
+ // never hidden behind a closed disclosure.
351
+ if (selectedMaterial()?.group === "enterprise") state.enterpriseOpen = true;
352
+ toggle.setAttribute("aria-expanded", String(state.enterpriseOpen));
353
+ $("enterprise-label").textContent = `Enterprise materials (${enterprise.length})`;
354
+ ent.hidden = !state.enterpriseOpen;
355
+ enterprise.forEach((m) => ent.append(tile(m)));
356
+ }
357
+
358
+ const notes = $("material-callouts");
359
+ notes.textContent = "";
360
+ const gated = state.gatedTap;
361
+ if (gated) {
362
+ const body: (HTMLElement | string)[] = [`${gated.label || gated.id} prints are available to Enterprise customers. `];
363
+ if (gated.contactUrl) body.push(linkButton("Contact sales", gated.contactUrl));
364
+ notes.append(callout("info", body));
365
+ }
366
+ }
367
+
368
+ function renderSubControls() {
369
+ const m = selectedMaterial();
370
+ const finishes = m?.finishes || [];
371
+ const showFinish = finishes.length > 0;
372
+ const showMode = !!m?.colorOptional;
373
+ const showColor = needsColor(m);
374
+ $("sub").hidden = !(showFinish || showMode || showColor);
375
+
376
+ $("finish-row").hidden = !showFinish;
377
+ if (showFinish) {
378
+ const seg = $("finish");
379
+ seg.textContent = "";
380
+ if (!state.finish || !finishes.includes(state.finish)) state.finish = finishes[0];
381
+ for (const f of finishes) {
382
+ const b = document.createElement("button");
383
+ b.type = "button";
384
+ b.setAttribute("role", "radio");
385
+ b.setAttribute("aria-checked", String(f === state.finish));
386
+ b.textContent = capitalize(f);
387
+ b.addEventListener("click", () => {
388
+ if (state.finish === f) return;
389
+ state.finish = f;
390
+ markDirty();
391
+ renderSubControls();
392
+ renderCta();
393
+ });
394
+ seg.append(b);
395
+ }
396
+ }
397
+
398
+ $("color-mode-row").hidden = !showMode;
399
+ if (showMode) {
400
+ document.querySelectorAll<HTMLButtonElement>("#color-mode [data-mode]").forEach((b) => {
401
+ b.setAttribute("aria-checked", String(b.dataset.mode === state.colorMode));
402
+ });
403
+ }
404
+
405
+ $("color-block").hidden = !showColor;
406
+ if (showColor) renderColor();
407
+ }
408
+
409
+ function renderColor() {
410
+ const pick = state.color || PANEL_DEFAULT_TINT;
411
+ const snapped = nearestPantone(pick);
412
+
413
+ ($("color-dot") as HTMLElement).style.background = pick;
414
+ const native = $("color-native") as HTMLInputElement;
415
+ if (native.value !== pick) native.value = pick;
416
+ const hex = $("color-hex") as HTMLInputElement;
417
+ if (document.activeElement !== hex) hex.value = pick.slice(1).toUpperCase();
418
+
419
+ const readout = $("pantone");
420
+ readout.textContent = "";
421
+ const dot = document.createElement("i");
422
+ dot.style.background = snapped.hex;
423
+ readout.append(dot, `Pantone ${snapped.code}`);
424
+ readout.title = `Prints as Pantone ${snapped.code} (${snapped.hex.toUpperCase()})`;
425
+
426
+ const chips = $("chips");
427
+ if (!chips.childElementCount) {
428
+ for (const chip of pantoneChips()) {
429
+ const b = document.createElement("button");
430
+ b.type = "button";
431
+ b.className = "chip";
432
+ b.setAttribute("role", "option");
433
+ b.dataset.hex = chip.hex;
434
+ b.style.background = chip.hex;
435
+ b.title = `Pantone ${chip.code}`;
436
+ b.setAttribute("aria-label", `Pantone ${chip.code}`);
437
+ b.addEventListener("click", () => setColor(chip.hex));
438
+ chips.append(b);
126
439
  }
127
- materialSelect.value = data.material;
128
440
  }
129
- if (typeof data.longestSideCm === "number") longestInput.value = String(Math.round(data.longestSideCm * 10) / 10);
130
- if (typeof data.quantity === "number") quantityInput.value = String(data.quantity);
441
+ chips.querySelectorAll<HTMLButtonElement>(".chip").forEach((c) => {
442
+ c.setAttribute("aria-selected", String(c.dataset.hex === snapped.hex));
443
+ });
444
+ }
445
+
446
+ function renderStyle() {
447
+ const q = state.quote;
448
+ const section = $("style-section");
449
+ section.hidden = !q?.hollow || !!state.staticPrintUrl;
450
+ if (section.hidden) return;
451
+ if (!q?.hollow) return;
452
+ document.querySelectorAll<HTMLButtonElement>(".style[data-style]").forEach((b) => {
453
+ b.setAttribute("aria-checked", String(b.dataset.style === state.style));
454
+ });
455
+ $("price-solid").textContent = q.solid ? `${money(q.solid.unitPrice)} each` : "";
456
+ $("price-hollow").textContent = `${money(q.hollow.unitPrice)} each`;
457
+ $("hollow-pro").hidden = !!q.callerIsPro;
458
+
459
+ const notes = $("style-callouts");
460
+ notes.textContent = "";
461
+ if (state.style === "hollow") {
462
+ const bits: string[] = ["Hollow prints need escape holes placed in the Womp editor, so they're finished on womp.com."];
463
+ if (!q.callerIsPro) bits.push("Hollow pricing needs Womp Pro.");
464
+ if (isClear(selectedMaterial())) bits.push("Solid is best for clear prints: nothing inside shows through.");
465
+ notes.append(callout("info", [bits.join(" ")]));
466
+ }
467
+ }
468
+
469
+ function renderReview() {
470
+ const q = state.quote;
471
+ const review = $("review");
472
+ review.hidden = !q?.solid;
473
+ if (!q?.solid) return;
474
+
475
+ const qty = $("qty") as HTMLInputElement;
476
+ if (document.activeElement !== qty) qty.value = String(state.quantity);
477
+ ($("qty-dec") as HTMLButtonElement).disabled = state.quantity <= 1 || !!state.staticPrintUrl;
478
+ ($("qty-inc") as HTMLButtonElement).disabled = state.quantity >= 100 || !!state.staticPrintUrl;
479
+ qty.disabled = !!state.staticPrintUrl;
480
+
481
+ const row = state.style === "hollow" && q.hollow ? q.hollow : q.solid;
482
+ const n = state.quantity;
483
+ $("amount").textContent = money(row.unitPrice * n);
484
+ const was = $("was");
485
+ was.hidden = !(row.discount > 0);
486
+ if (row.discount > 0) was.textContent = money(row.salePrice * n);
487
+ // The saving as a number, not a label: the row's discount folds Pro,
488
+ // Enterprise and bulk savings together, and the card cannot tell which.
489
+ const pill = $("pill");
490
+ pill.hidden = !(row.discount > 0);
491
+ if (row.discount > 0) pill.textContent = `Save ${money(row.discount * n)}`;
492
+
493
+ const sub: string[] = [];
494
+ if (n > 1) sub.push(`${money(row.unitPrice)} each`);
495
+ sub.push(`~${row.productionDays} production day${row.productionDays === 1 ? "" : "s"}`);
496
+ $("price-sub").textContent = sub.join(" · ");
131
497
  }
132
498
 
133
- function populateMaterials(materials: { id: string }[], selected?: string) {
134
- materialSelect.textContent = "";
135
- for (const m of materials) {
136
- const opt = document.createElement("option");
137
- opt.value = m.id;
138
- opt.textContent = m.id;
139
- materialSelect.append(opt);
499
+ function renderCta() {
500
+ const cta = $("cta") as HTMLButtonElement;
501
+ const label = $("cta-label");
502
+ const q = state.quote;
503
+ cta.hidden = !q?.solid && !state.staticPrintUrl;
504
+ $("note").hidden = cta.hidden;
505
+ if (cta.hidden) return;
506
+
507
+ if (state.staticPrintUrl) {
508
+ label.textContent = "Complete order on womp.com";
509
+ cta.disabled = false;
510
+ return;
511
+ }
512
+ const gated = selectedMaterial()?.available === false;
513
+ cta.disabled = state.quoting || overHardLimit() || gated || !q?.solid;
514
+ if (state.style === "hollow") {
515
+ label.textContent = state.preparedUrl ? "Open on womp.com" : "Continue on womp.com";
516
+ } else {
517
+ label.textContent = state.cartCheckoutUrl ? "Checkout on womp.com" : "Add to cart";
140
518
  }
141
- if (selected) materialSelect.value = selected;
142
519
  }
143
520
 
144
- function currentSettings() {
521
+ // --- state changes ---------------------------------------------------------
522
+
523
+ function selectMaterial(id: string) {
524
+ const m = state.materials.find((x) => x.id === id);
525
+ if (!m) return;
526
+ if (m.available === false) {
527
+ // The panel keeps the selection and opens the contact dialog; the card keeps
528
+ // the selection and shows the contact note.
529
+ state.gatedTap = m;
530
+ renderMaterials();
531
+ return;
532
+ }
533
+ state.gatedTap = undefined;
534
+ if (m.id === state.material) return;
535
+ state.material = m.id;
536
+ state.finish = m.finishes?.length ? m.finishes[0] : undefined;
537
+ state.colorMode = "original";
538
+ // The panel seeds its blue when you switch into Tinted clear; a tint the user
539
+ // already chose on this card is kept, because they chose it.
540
+ if (m.colorRequired && !state.color) state.color = PANEL_DEFAULT_TINT;
541
+ markDirty();
542
+ render();
543
+ scheduleRequote();
544
+ }
545
+
546
+ function setColor(hex: string) {
547
+ const norm = normalizeHex(hex);
548
+ if (!norm || norm === state.color) return;
549
+ state.color = norm;
550
+ markDirty();
551
+ renderColor();
552
+ renderCta();
553
+ }
554
+
555
+ function setQuantity(n: number) {
556
+ const next = Math.min(100, Math.max(1, Math.round(n) || 1));
557
+ if (next === state.quantity) {
558
+ renderReview();
559
+ return;
560
+ }
561
+ state.quantity = next;
562
+ markDirty();
563
+ renderReview();
564
+ renderCta();
565
+ scheduleRequote(); // discounts can depend on quantity
566
+ }
567
+
568
+ /**
569
+ * One axis was edited: MCP prints scale uniformly, so the other two follow and
570
+ * the longest side is what gets sent. Clamped to the contract's range, with a
571
+ * notice rather than a silent snap.
572
+ */
573
+ function commitDim(axis: Axis, raw: string) {
574
+ const d = state.dims;
575
+ const entered = fromUnit(Number(raw), state.unit);
576
+ if (!d || !(entered > 0) || !(d[axis] > 0)) {
577
+ renderSize();
578
+ return;
579
+ }
580
+ const longest = Math.max(d.width, d.height, d.depth);
581
+ let nextLongest = (longest * entered) / d[axis];
582
+ state.sizeNotice = "";
583
+ if (nextLongest > MAX_LONGEST_CM) {
584
+ nextLongest = MAX_LONGEST_CM;
585
+ state.sizeNotice = `The longest side is capped at ${fmtDim(MAX_LONGEST_CM, state.unit)} ${state.unit}.`;
586
+ } else if (nextLongest < MIN_LONGEST_CM) {
587
+ nextLongest = MIN_LONGEST_CM;
588
+ state.sizeNotice = `The longest side can't go below ${fmtDim(MIN_LONGEST_CM, state.unit)} ${state.unit}.`;
589
+ }
590
+ const k = nextLongest / longest;
591
+ if (Math.abs(k - 1) < 1e-6) {
592
+ renderSize();
593
+ return;
594
+ }
595
+ state.dims = { width: d.width * k, height: d.height * k, depth: d.depth * k };
596
+ state.longestSideCm = nextLongest;
597
+ markDirty();
598
+ renderHead();
599
+ renderSize();
600
+ renderCta();
601
+ scheduleRequote();
602
+ }
603
+
604
+ // --- talking to the server -------------------------------------------------
605
+
606
+ function baseArgs() {
607
+ const m = selectedMaterial();
608
+ const snapped = snappedColor();
145
609
  return {
146
- ...(source?.generationId ? { generationId: source.generationId } : {}),
147
- ...(source?.glbUrl ? { glbUrl: source.glbUrl } : {}),
148
- material: materialSelect.value,
149
- quantity: Math.min(100, Math.max(1, Math.round(Number(quantityInput.value) || 1))),
150
- longestSideCm: Math.min(60, Math.max(2, Number(longestInput.value) || 10)),
610
+ ...(state.source?.generationId ? { generationId: state.source.generationId } : {}),
611
+ ...(state.source?.glbUrl ? { glbUrl: state.source.glbUrl } : {}),
612
+ material: state.material,
613
+ ...(m?.finishes?.length && state.finish ? { finish: state.finish } : {}),
614
+ ...(snapped ? { color: snapped.hex } : {}),
615
+ quantity: state.quantity,
616
+ ...(state.longestSideCm ? { longestSideCm: Math.round(state.longestSideCm * 100) / 100 } : {}),
151
617
  };
152
618
  }
153
619
 
620
+ /** Fold a quote into the state without fighting what the user is doing. */
621
+ function applyQuote(data: PrintQuoteResult) {
622
+ state.quote = data;
623
+ if (data.source) state.source = data.source;
624
+ if (data.materials?.length) state.materials = data.materials;
625
+ if (data.material) {
626
+ state.material = data.material;
627
+ if (!state.materials.some((m) => m.id === data.material)) {
628
+ // An id the catalog has not caught up with yet still has to be selectable.
629
+ state.materials.push({ id: data.material, label: data.materialLabel, available: true });
630
+ }
631
+ }
632
+ if (data.finish) state.finish = data.finish;
633
+ // The echo is what the card SENT (a snapped hex); the user's exact pick wins
634
+ // when there is one, so the dot never jumps under their finger.
635
+ if (data.color && !state.color) state.color = normalizeHex(data.color);
636
+ if (data.color && selectedMaterial()?.colorOptional) state.colorMode = "custom";
637
+ if (typeof data.quantity === "number") state.quantity = data.quantity;
638
+ if (typeof data.longestSideCm === "number") state.longestSideCm = data.longestSideCm;
639
+ if (data.dimensionsCm) state.dims = data.dimensionsCm;
640
+ state.sizeNotice = "";
641
+ render();
642
+ }
643
+
154
644
  async function requote() {
155
- if (!source || staticPrintUrl) return;
645
+ if (!state.source || state.staticPrintUrl || !state.material) return;
156
646
  const seq = ++quoteSeq;
157
- $("card").classList.add("quoting");
158
- orderBtn.disabled = true;
647
+ setQuoting(true);
159
648
  setStatus("Updating price…");
160
649
  try {
161
- const res = await callTool(card.app, "estimate_print", currentSettings());
650
+ const res = await callTool(card.app, "estimate_print", baseArgs());
162
651
  if (seq !== quoteSeq) return; // superseded by a newer knob turn
652
+ setQuoting(false);
163
653
  if (res.isError) {
164
- const text = (res.content as any[])?.find((c) => c.type === "text")?.text || "Could not update the quote.";
165
- $("card").classList.remove("quoting");
166
- orderBtn.disabled = false;
167
- setStatus(String(text), true);
654
+ setStatus(friendlyError(textOf(res) || "Could not update the price."), true);
168
655
  return;
169
656
  }
170
- renderQuote((res.structuredContent || {}) as PrintQuoteResult);
657
+ applyQuote((res.structuredContent || {}) as PrintQuoteResult);
658
+ setStatus("");
171
659
  } catch {
172
660
  if (seq !== quoteSeq) return;
173
- $("card").classList.remove("quoting");
174
- orderBtn.disabled = false;
175
- setStatus("Could not update the quote — check the connection and try again.", true);
661
+ setQuoting(false);
662
+ setStatus("Could not update the price. Check the connection and try again.", true);
176
663
  }
177
664
  }
178
665
 
179
666
  function scheduleRequote() {
180
- // Same idea as the web print panel's debounce: a slider being dragged must
181
- // not fire an engine estimate per pixel.
182
667
  if (debounceTimer !== undefined) clearTimeout(debounceTimer);
183
- // The settings changed: whatever was added to the cart no longer matches
184
- // what the card shows, so the button goes back to adding.
185
- cartCheckoutUrl = "";
186
- $("order-label").textContent = "Add to cart";
187
- debounceTimer = window.setTimeout(() => void requote(), 600);
668
+ debounceTimer = window.setTimeout(() => void requote(), REQUOTE_DEBOUNCE_MS);
669
+ }
670
+
671
+ async function addToCart() {
672
+ const cta = $("cta") as HTMLButtonElement;
673
+ cta.disabled = true;
674
+ $("cta-label").textContent = "Adding to cart…";
675
+ setStatus("");
676
+ try {
677
+ const res = await callTool(card.app, "add_to_cart", baseArgs());
678
+ const data = (res.structuredContent || {}) as { payUrl?: string; items?: unknown[]; subtotal?: number };
679
+ if (res.isError || !data.payUrl) {
680
+ setStatus(friendlyError(textOf(res) || "Could not add to the cart."), true);
681
+ return;
682
+ }
683
+ state.cartCheckoutUrl = data.payUrl;
684
+ const count = data.items?.length ?? 1;
685
+ setStatus(
686
+ `Added. Your cart has ${count} item${count === 1 ? "" : "s"}, ${money(data.subtotal ?? 0)} before shipping. ` +
687
+ "Shipping and promo codes apply at checkout.",
688
+ );
689
+ } catch {
690
+ setStatus("Could not add to the cart. Try again.", true);
691
+ } finally {
692
+ renderCta();
693
+ }
694
+ }
695
+
696
+ /** Hollow: create the print project and open it — the editor is where escape holes go. */
697
+ async function continueOnWomp() {
698
+ const cta = $("cta") as HTMLButtonElement;
699
+ cta.disabled = true;
700
+ $("cta-label").textContent = "Setting up…";
701
+ setStatus("");
702
+ try {
703
+ const res = await callTool(card.app, "prepare_print", baseArgs());
704
+ const data = (res.structuredContent || {}) as { printUrl?: string };
705
+ if (res.isError || !data.printUrl) {
706
+ setStatus(friendlyError(textOf(res) || "Could not set the print up."), true);
707
+ return;
708
+ }
709
+ state.preparedUrl = data.printUrl;
710
+ void openLink(card.app, data.printUrl);
711
+ setStatus("Opened on womp.com. Choose Hollow there, place the escape holes, and check out.");
712
+ } catch {
713
+ setStatus("Could not set the print up. Try again.", true);
714
+ } finally {
715
+ renderCta();
716
+ }
188
717
  }
189
718
 
190
- materialSelect.addEventListener("change", scheduleRequote);
191
- longestInput.addEventListener("change", scheduleRequote);
192
- quantityInput.addEventListener("change", scheduleRequote);
719
+ // --- wiring ----------------------------------------------------------------
193
720
 
194
721
  const card = createCard("Womp print", {
195
- onTimeout: () => setStatus("Still measuring — ask me to check on it."),
722
+ onTimeout: () => setStatus("Still measuring. Ask me to check on it."),
196
723
  onInput: (args) => {
197
724
  // Seed the controls from what the model asked for, before any result: the
198
- // user sees "resin-clear, 15cm" selected while the first quote computes.
199
- syncControls({
200
- material: typeof args.material === "string" ? args.material : undefined,
201
- longestSideCm: typeof args.longestSideCm === "number" ? args.longestSideCm : undefined,
202
- quantity: typeof args.quantity === "number" ? args.quantity : undefined,
203
- });
725
+ // user sees "Tinted clear, glossy, 15 cm, two copies" selected while the
726
+ // first quote computes.
727
+ if (typeof args.material === "string") state.material = args.material;
728
+ if (typeof args.finish === "string") state.finish = args.finish.toLowerCase();
729
+ if (typeof args.color === "string") state.color = normalizeHex(args.color) || state.color;
730
+ if (typeof args.quantity === "number") state.quantity = args.quantity;
731
+ if (typeof args.longestSideCm === "number") state.longestSideCm = args.longestSideCm;
732
+ if (args.hollow === true) state.style = "hollow";
204
733
  },
205
734
  onResult: (raw: GenerationResult) => {
206
735
  const data = raw as PrintQuoteResult & GenerationResult;
207
736
  // ontoolinput sends {status: "running"} while the engine measures; a print
208
737
  // result itself never carries `status`.
209
738
  if (data.status === "running") {
210
- $("card").classList.add("quoting");
739
+ setQuoting(true);
211
740
  setStatus("Measuring mesh…");
212
741
  return;
213
742
  }
214
- if (data.source) source = data.source;
743
+ setQuoting(false);
215
744
  if (data.printUrl) {
216
- // A model-initiated prepare_print: render a receipt, not a configurator.
217
- staticPrintUrl = data.printUrl;
218
- $("controls").hidden = true;
219
- $("order-label").textContent = "Complete order on womp.com";
220
- } else if (data.materials?.length) {
221
- populateMaterials(data.materials, data.material);
222
- $("controls").hidden = false;
745
+ // A model-initiated prepare_print: a receipt, not a configurator.
746
+ state.staticPrintUrl = data.printUrl;
747
+ applyQuote(data);
748
+ for (const id of ["size-section", "material-section", "style-section"]) $(id).hidden = true;
749
+ const m = selectedMaterial();
750
+ const bits = [materialLabel(m)];
751
+ if (state.finish && m?.finishes?.length) bits.push(capitalize(state.finish));
752
+ if (data.color) bits.push(`Pantone ${nearestPantone(data.color).code}`);
753
+ if (state.longestSideCm) bits.push(`${fmtDim(state.longestSideCm, state.unit)} ${state.unit} longest side`);
754
+ $("summary").textContent = bits.join(" · ");
755
+ $("summary").hidden = false;
756
+ setStatus("");
757
+ return;
223
758
  }
224
- syncControls(data);
225
- renderQuote(data);
759
+ applyQuote(data);
760
+ for (const id of ["size-section", "material-section"]) $(id).hidden = !state.materials.length;
761
+ setStatus("");
226
762
  },
227
763
  });
228
764
 
229
- orderBtn.addEventListener("click", async () => {
230
- if (staticPrintUrl) {
231
- void openLink(card.app, staticPrintUrl);
232
- return;
233
- }
234
- if (cartCheckoutUrl) {
235
- void openLink(card.app, cartCheckoutUrl);
236
- return;
765
+ document.querySelectorAll<HTMLButtonElement>("#unit [data-unit]").forEach((b) =>
766
+ b.addEventListener("click", () => {
767
+ state.unit = b.dataset.unit as Unit;
768
+ renderHead();
769
+ renderSize();
770
+ }),
771
+ );
772
+ for (const axis of AXES) {
773
+ const input = $(`dim-${axis}`) as HTMLInputElement;
774
+ input.addEventListener("change", () => commitDim(axis, input.value));
775
+ input.addEventListener("keydown", (e) => {
776
+ if (e.key === "Enter") input.blur();
777
+ });
778
+ }
779
+
780
+ $("enterprise-toggle").addEventListener("click", () => {
781
+ state.enterpriseOpen = !state.enterpriseOpen;
782
+ // Closing the group is how the note about a gated material is dismissed.
783
+ if (!state.enterpriseOpen) state.gatedTap = undefined;
784
+ renderMaterials();
785
+ });
786
+
787
+ document.querySelectorAll<HTMLButtonElement>("#color-mode [data-mode]").forEach((b) =>
788
+ b.addEventListener("click", () => {
789
+ const mode = b.dataset.mode as "original" | "custom";
790
+ if (state.colorMode === mode) return;
791
+ state.colorMode = mode;
792
+ if (mode === "custom" && !state.color) state.color = PANEL_DEFAULT_TINT;
793
+ markDirty();
794
+ renderSubControls();
795
+ renderCta();
796
+ }),
797
+ );
798
+
799
+ const native = $("color-native") as HTMLInputElement;
800
+ native.addEventListener("input", () => setColor(native.value));
801
+
802
+ const hexInput = $("color-hex") as HTMLInputElement;
803
+ hexInput.addEventListener("input", () => {
804
+ hexInput.value = hexInput.value.replace(/[^0-9a-fA-F]/g, "").toUpperCase().slice(0, 6);
805
+ if (hexInput.value.length === 6) setColor(`#${hexInput.value}`);
806
+ });
807
+ const commitHex = () => {
808
+ const expanded = expandHexInput(hexInput.value);
809
+ if (expanded) setColor(expanded);
810
+ renderColor();
811
+ };
812
+ hexInput.addEventListener("blur", commitHex);
813
+ hexInput.addEventListener("keydown", (e) => {
814
+ if (e.key === "Enter") {
815
+ commitHex();
816
+ hexInput.blur();
237
817
  }
238
- if (!source) return;
239
- orderBtn.disabled = true;
240
- $("order-label").textContent = "Adding to cart…";
241
- setStatus("");
242
- try {
243
- const res = await callTool(card.app, "add_to_cart", currentSettings());
244
- const data = (res.structuredContent || {}) as { payUrl?: string; items?: unknown[]; subtotal?: number };
245
- if (res.isError || !data.payUrl) {
246
- const text = (res.content as any[])?.find((c) => c.type === "text")?.text || "Could not add to the cart.";
247
- setStatus(String(text), true);
248
- $("order-label").textContent = "Add to cart";
249
- return;
250
- }
251
- cartCheckoutUrl = data.payUrl;
252
- $("order-label").textContent = "Checkout on womp.com";
253
- setStatus(
254
- `Added — cart has ${data.items?.length ?? 1} item(s), subtotal $${(data.subtotal ?? 0).toFixed(2)}. ` +
255
- `Shipping and promo codes apply at checkout.`,
256
- );
257
- } catch {
258
- setStatus("Could not add to the cart — try again.", true);
259
- $("order-label").textContent = "Add to cart";
260
- } finally {
261
- orderBtn.disabled = false;
818
+ });
819
+
820
+ document.querySelectorAll<HTMLButtonElement>(".style[data-style]").forEach((b) =>
821
+ b.addEventListener("click", () => {
822
+ const style = b.dataset.style as Style;
823
+ if (state.style === style) return;
824
+ state.style = style;
825
+ markDirty();
826
+ renderStyle();
827
+ renderReview();
828
+ renderCta();
829
+ }),
830
+ );
831
+
832
+ const qtyInput = $("qty") as HTMLInputElement;
833
+ qtyInput.addEventListener("change", () => setQuantity(Number(qtyInput.value)));
834
+ $("qty-dec").addEventListener("click", () => setQuantity(state.quantity - 1));
835
+ $("qty-inc").addEventListener("click", () => setQuantity(state.quantity + 1));
836
+
837
+ $("cta").addEventListener("click", () => {
838
+ if (state.staticPrintUrl) return void openLink(card.app, state.staticPrintUrl);
839
+ if (state.style === "hollow") {
840
+ if (state.preparedUrl) return void openLink(card.app, state.preparedUrl);
841
+ return void continueOnWomp();
262
842
  }
843
+ if (state.cartCheckoutUrl) return void openLink(card.app, state.cartCheckoutUrl);
844
+ if (!state.source) return;
845
+ void addToCart();
263
846
  });