vibespot 1.6.4 → 1.7.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/ui/chat.js CHANGED
@@ -8,6 +8,15 @@
8
8
 
9
9
  let ws = null;
10
10
  let isStreaming = false;
11
+ // True while an agentic pipeline is actively building (Stage 3) — the window in
12
+ // which a new message barges in to cancel-and-replan (VIB-1880). False when
13
+ // parked at a checkpoint gate or running single-call mode.
14
+ let agenticRunning = false;
15
+ // Softened barge-in (VIB-1876): a message sent during an active build is QUEUED
16
+ // by default and dispatched when the build finishes. The queued chip carries an
17
+ // "Interrupt now" button that triggers the old cancel-and-replan barge-in.
18
+ let queuedMessage = null; // { text, opts }
19
+ let queuedChipEl = null;
11
20
  let streamingMsgEl = null;
12
21
  let streamBuffer = "";
13
22
  let streamStartTime = 0;
@@ -36,6 +45,9 @@ let lastGenerationBubbleEl = null;
36
45
  const messagesEl = document.getElementById("chat-messages");
37
46
  const inputEl = document.getElementById("chat-input");
38
47
  const sendBtn = document.getElementById("chat-send");
48
+ const oneShotBtn = document.getElementById("chat-send-oneshot");
49
+ // Remembered so the barge-in hint placeholder can be restored after a run.
50
+ const chatInputDefaultPlaceholder = (inputEl && inputEl.placeholder) || "";
39
51
  const statusText = document.getElementById("status-text");
40
52
  const statusEngine = document.getElementById("status-engine");
41
53
  const statusTheme = document.getElementById("status-theme");
@@ -662,6 +674,13 @@ function handleWsMessage(msg) {
662
674
  }
663
675
  }
664
676
 
677
+ // Rehydrate a parked checkpoint after a refresh / device sleep (VIB-1876):
678
+ // the server kept the gate, so re-render the card and let the user resume
679
+ // from where they were. Plan-mode parks are rehydrated by planController.
680
+ if (msg.pendingCheckpoint && msg.pendingCheckpoint.preview && msg.pendingCheckpoint.kind !== "plan") {
681
+ handleCheckpointRequested({ preview: msg.pendingCheckpoint.preview });
682
+ }
683
+
665
684
  // If setup handed us an initial prompt (describe-it path), send it now
666
685
  // that the session is live. Skip if the project already has history
667
686
  // (e.g. resumed session) to avoid double-submitting.
@@ -691,6 +710,7 @@ function handleWsMessage(msg) {
691
710
  highlightOnNextModulesUpdated = true;
692
711
  // Refresh the Library tab's module list so it stays in sync
693
712
  if (typeof refreshDashboard === "function") refreshDashboard();
713
+ flushQueuedMessage();
694
714
  break;
695
715
 
696
716
  case "modules_updated":
@@ -743,14 +763,26 @@ function handleWsMessage(msg) {
743
763
  handleAgentDecision(msg);
744
764
  break;
745
765
  case "module_progress":
766
+ enableBargeIn();
746
767
  handleModuleProgress(msg);
747
768
  break;
769
+ case "generation_superseded":
770
+ // The server confirmed our barge-in cancelled the prior run; the fresh
771
+ // run's events follow. Nothing to do — supersedeCurrentRun already reset.
772
+ setStatus("Redirecting…");
773
+ break;
748
774
  case "pipeline_complete":
749
775
  handlePipelineComplete(msg);
750
776
  break;
751
777
  case "pipeline_partial":
752
778
  handlePipelinePartial(msg);
753
779
  break;
780
+ case "checkpoint_requested":
781
+ handleCheckpointRequested(msg);
782
+ break;
783
+ case "checkpoint_cancelled":
784
+ handleCheckpointCancelled();
785
+ break;
754
786
  case "generation_cost":
755
787
  handleGenerationCost(msg);
756
788
  break;
@@ -972,6 +1004,8 @@ function setPipelineDetail(text) {
972
1004
  }
973
1005
 
974
1006
  function handleAgentStep(msg) {
1007
+ // The expensive build stage is the barge-in window (VIB-1880).
1008
+ if (msg.step === "developing") enableBargeIn();
975
1009
  ensurePipelineBubble();
976
1010
 
977
1011
  const incoming = msg.step;
@@ -1285,6 +1319,7 @@ function handlePipelineComplete(msg) {
1285
1319
  lastGenerationBubbleEl = bubble;
1286
1320
 
1287
1321
  resetPipelineState();
1322
+ flushQueuedMessage();
1288
1323
  }
1289
1324
 
1290
1325
  function handlePipelinePartial(msg) {
@@ -1315,6 +1350,7 @@ function handlePipelinePartial(msg) {
1315
1350
  lastGenerationBubbleEl = bubble;
1316
1351
 
1317
1352
  resetPipelineState();
1353
+ flushQueuedMessage();
1318
1354
  }
1319
1355
 
1320
1356
  function resetPipelineState() {
@@ -1745,9 +1781,21 @@ fileInputEl.addEventListener("change", () => {
1745
1781
  // Sending messages
1746
1782
  // ---------------------------------------------------------------------------
1747
1783
 
1748
- async function sendMessage(text) {
1784
+ async function sendMessage(text, opts = {}) {
1749
1785
  const hasFiles = pendingFiles.length > 0;
1750
- if ((!text.trim() && !hasFiles) || isStreaming || !ws || ws.readyState !== WebSocket.OPEN) return;
1786
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
1787
+ if (!text.trim() && !hasFiles) return;
1788
+ // Block sends while busy — EXCEPT during an active agentic build. There, a new
1789
+ // message is QUEUED by default and runs when the build finishes (VIB-1876);
1790
+ // an explicit Interrupt (opts.interrupt) triggers the cancel-and-replan
1791
+ // barge-in (VIB-1880). Single-call streaming and checkpoint gates stay locked.
1792
+ if (isStreaming && !agenticRunning) return;
1793
+ const buildActive = isStreaming && agenticRunning;
1794
+ if (buildActive && !opts.interrupt) {
1795
+ queueMessage(text, opts);
1796
+ return;
1797
+ }
1798
+ if (buildActive && opts.interrupt) supersedeCurrentRun();
1751
1799
 
1752
1800
  // Remove welcome screen
1753
1801
  const welcome = messagesEl.querySelector(".chat__welcome");
@@ -1794,6 +1842,8 @@ async function sendMessage(text) {
1794
1842
  if (uploadedFiles.length > 0) {
1795
1843
  payload.fileIds = uploadedFiles.map((f) => f.id);
1796
1844
  }
1845
+ // One-shot: skip the design checkpoint and build the whole page (VIB-1877).
1846
+ if (opts.oneShot) payload.oneShot = true;
1797
1847
  ws.send(JSON.stringify(payload));
1798
1848
 
1799
1849
  // Clear input
@@ -1836,6 +1886,7 @@ function startStreaming() {
1836
1886
  streamBuffer = "";
1837
1887
  lastStreamStatus = "";
1838
1888
  sendBtn.disabled = true;
1889
+ if (oneShotBtn) oneShotBtn.disabled = true;
1839
1890
  streamStartTime = Date.now();
1840
1891
  if (typeof window.setSelectModeDisabled === "function") {
1841
1892
  window.setSelectModeDisabled(true);
@@ -1984,10 +2035,107 @@ function clearStreamStatus() {
1984
2035
  if (statusEl) statusEl.remove();
1985
2036
  }
1986
2037
 
2038
+ // Open the barge-in window: the build is running, so re-enable the composer and
2039
+ // let the user redirect the run mid-flight (VIB-1880).
2040
+ function enableBargeIn() {
2041
+ if (agenticRunning) return;
2042
+ agenticRunning = true;
2043
+ sendBtn.disabled = false;
2044
+ if (oneShotBtn) oneShotBtn.disabled = false;
2045
+ if (inputEl) inputEl.placeholder = "Building… your message will queue (or Interrupt to redirect now)";
2046
+ }
2047
+
2048
+ // --- Softened barge-in: queue-by-default with an Interrupt button (VIB-1876) ---
2049
+
2050
+ // Queue a message sent mid-build instead of cancelling the run. Latest wins.
2051
+ function queueMessage(text, opts) {
2052
+ if (!text.trim() && pendingFiles.length === 0) return;
2053
+ queuedMessage = { text, opts: { ...opts } };
2054
+ if (inputEl) {
2055
+ inputEl.value = "";
2056
+ inputEl.dispatchEvent(new Event("input"));
2057
+ }
2058
+ renderQueuedChip();
2059
+ setStatus("Message queued");
2060
+ }
2061
+
2062
+ function clearQueuedMessage() {
2063
+ queuedMessage = null;
2064
+ if (queuedChipEl) {
2065
+ queuedChipEl.remove();
2066
+ queuedChipEl = null;
2067
+ }
2068
+ }
2069
+
2070
+ // Trigger the real barge-in (cancel-and-replan) with the queued message.
2071
+ function interruptWithQueued() {
2072
+ if (!queuedMessage) return;
2073
+ const { text, opts } = queuedMessage;
2074
+ clearQueuedMessage();
2075
+ sendMessage(text, { ...opts, interrupt: true });
2076
+ }
2077
+
2078
+ // Dispatch the queued message once the current build has finished.
2079
+ function flushQueuedMessage() {
2080
+ if (!queuedMessage) return;
2081
+ const { text, opts } = queuedMessage;
2082
+ clearQueuedMessage();
2083
+ // Defer so the just-finished run's UI settles before the next one starts.
2084
+ setTimeout(() => sendMessage(text, opts), 0);
2085
+ }
2086
+
2087
+ function renderQueuedChip() {
2088
+ if (!queuedMessage) return clearQueuedMessage();
2089
+ const area = inputEl && inputEl.closest(".chat__input-area");
2090
+ if (!area) return;
2091
+ if (!queuedChipEl) {
2092
+ queuedChipEl = document.createElement("div");
2093
+ queuedChipEl.className = "queued-msg";
2094
+ queuedChipEl.style.cssText =
2095
+ "display:flex;align-items:center;gap:8px;margin:0 0 8px;padding:8px 10px;border:1px solid var(--border,#e2e2e2);border-radius:8px;background:var(--surface-2,#faf7f5);font-size:13px;";
2096
+ area.insertBefore(queuedChipEl, area.firstChild);
2097
+ }
2098
+ const t = queuedMessage.text || "(files attached)";
2099
+ const preview = t.length > 90 ? t.slice(0, 90) + "…" : t;
2100
+ queuedChipEl.innerHTML =
2101
+ '<span aria-hidden="true">⏳</span>' +
2102
+ '<span style="opacity:.7;white-space:nowrap;">Queued — runs next:</span>' +
2103
+ '<span class="queued-msg__text" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span>' +
2104
+ '<button type="button" class="queued-msg__interrupt" style="flex:none;cursor:pointer;border:1px solid var(--border,#e2e2e2);background:var(--surface,#fff);border-radius:6px;padding:3px 8px;font-size:12px;">Interrupt now</button>' +
2105
+ '<button type="button" class="queued-msg__cancel" aria-label="Cancel queued message" style="flex:none;cursor:pointer;border:1px solid var(--border,#e2e2e2);background:var(--surface,#fff);border-radius:6px;padding:3px 8px;font-size:12px;">✕ Cancel</button>';
2106
+ queuedChipEl.querySelector(".queued-msg__text").textContent = preview;
2107
+ queuedChipEl.querySelector(".queued-msg__interrupt").onclick = interruptWithQueued;
2108
+ queuedChipEl.querySelector(".queued-msg__cancel").onclick = clearQueuedMessage;
2109
+ }
2110
+
2111
+ // Tear down the superseded run's transient UI so the replacement run renders
2112
+ // fresh (called when a barge-in send cancels the active build).
2113
+ function supersedeCurrentRun() {
2114
+ stopStreamTimer();
2115
+ clearStreamStatus();
2116
+ const streamingEl = messagesEl.querySelector(".chat-msg--streaming");
2117
+ if (streamingEl) {
2118
+ streamingEl.classList.remove("chat-msg--streaming");
2119
+ const bubble = streamingEl.querySelector(".chat-msg__bubble");
2120
+ if (bubble && !bubble.textContent.trim()) {
2121
+ bubble.innerHTML = '<em class="message__placeholder">Superseded by your next message.</em>';
2122
+ }
2123
+ }
2124
+ removeCheckpointCard();
2125
+ isStreaming = false;
2126
+ agenticRunning = false;
2127
+ streamingMsgEl = null;
2128
+ streamBuffer = "";
2129
+ resetPipelineState();
2130
+ }
2131
+
1987
2132
  function finishStreaming() {
1988
2133
  if (!isStreaming) return;
1989
2134
  isStreaming = false;
2135
+ agenticRunning = false;
2136
+ if (inputEl) inputEl.placeholder = chatInputDefaultPlaceholder;
1990
2137
  sendBtn.disabled = false;
2138
+ if (oneShotBtn) oneShotBtn.disabled = false;
1991
2139
  if (typeof window.setSelectModeDisabled === "function") {
1992
2140
  window.setSelectModeDisabled(false);
1993
2141
  }
@@ -2008,6 +2156,24 @@ function finishStreaming() {
2008
2156
  if (streamingEl) {
2009
2157
  streamingEl.classList.remove("chat-msg--streaming");
2010
2158
 
2159
+ // Agentic seams (a checkpoint gate, or a resume that re-parks / just builds
2160
+ // modules) leave a streaming bubble that never received any text or progress
2161
+ // stepper. Don't finalize it into an empty bubble with a lone duration —
2162
+ // drop it (VIB-1876 follow-up). A bubble carrying the pipeline stepper or
2163
+ // any rendered content has child nodes, so it's kept.
2164
+ const bubbleEl = streamingEl.querySelector(".chat-msg__bubble");
2165
+ const hasContent =
2166
+ !!bubbleEl && (bubbleEl.children.length > 0 || bubbleEl.textContent.trim().length > 0);
2167
+ const willRenderText = !!streamBuffer && streamBuffer.trim().length > 0;
2168
+ if (!hasContent && !willRenderText) {
2169
+ streamingEl.remove();
2170
+ streamingMsgEl = null;
2171
+ streamBuffer = "";
2172
+ setStatus("Ready");
2173
+ scrollToBottom();
2174
+ return;
2175
+ }
2176
+
2011
2177
  // Add duration metadata beneath the bubble (inside .chat-msg__content)
2012
2178
  const meta = document.createElement("div");
2013
2179
  meta.className = "chat-msg__meta";
@@ -2246,6 +2412,482 @@ function appendSystemMessage(text) {
2246
2412
  scrollToBottom();
2247
2413
  }
2248
2414
 
2415
+ // ---------------------------------------------------------------------------
2416
+ // Checkpoint gate card (VIB-1877) — the design preview the user approves /
2417
+ // steers / skips / cancels before the expensive module build.
2418
+ // ---------------------------------------------------------------------------
2419
+
2420
+ let checkpointCardEl = null;
2421
+ let currentCheckpointKind = null;
2422
+ let currentCheckpointData = null;
2423
+
2424
+ function setSendDisabled(disabled) {
2425
+ sendBtn.disabled = disabled;
2426
+ if (oneShotBtn) oneShotBtn.disabled = disabled;
2427
+ }
2428
+
2429
+ function removeCheckpointCard() {
2430
+ if (checkpointCardEl && checkpointCardEl.parentNode) {
2431
+ checkpointCardEl.parentNode.removeChild(checkpointCardEl);
2432
+ }
2433
+ checkpointCardEl = null;
2434
+ }
2435
+
2436
+ // One-line record of what the user decided at a checkpoint (VIB-1876). Boris:
2437
+ // keep the decision in the transcript rather than dropping the turn entirely.
2438
+ function summarizeDecision(kind, action, note, extra) {
2439
+ const label =
2440
+ kind === "brand_intake" ? "Brand" : kind === "structure" ? "Structure" : kind === "plan" ? "Plan" : "Design";
2441
+ if (action === "cancel") return `${label}: cancelled — nothing was built.`;
2442
+ if (action === "steer") return `${label}: steered${note ? " — " + note : ""}.`;
2443
+ if (kind === "brand_intake") {
2444
+ if (action !== "approve") return "Brand: surprise me — generating a design system.";
2445
+ const ch =
2446
+ extra && extra.brandIntake
2447
+ ? Object.keys(extra.brandIntake).filter((k) => extra.brandIntake[k])
2448
+ : [];
2449
+ return `Brand: bringing your brand${ch.length ? " (" + ch.join(", ") + ")" : ""}.`;
2450
+ }
2451
+ if (action === "skip") return `${label}: skipped remaining checkpoints — building now.`;
2452
+ return `${label}: approved.`;
2453
+ }
2454
+
2455
+ // The substantive content/facts behind a decision (VIB-1876): the palette/type
2456
+ // approved, the module outline kept, or the brand inputs provided — so the
2457
+ // transcript records *what*, not just the verb. Returns "" when there's nothing.
2458
+ function decisionFacts(kind, action, note, extra, data) {
2459
+ if (action === "cancel") return "";
2460
+ if (action === "steer") return note ? "Note: " + note : "";
2461
+ const parts = [];
2462
+ const clip = (s) => String(s).replace(/\s+/g, " ").trim().slice(0, 80);
2463
+ if (kind === "design" || kind === "plan") {
2464
+ const pal = Array.isArray(data && data.palette)
2465
+ ? data.palette.map((p) => p && p.value).filter(Boolean).slice(0, 5)
2466
+ : [];
2467
+ if (pal.length) parts.push("Palette: " + pal.join(", "));
2468
+ const typo = (data && data.typography) || {};
2469
+ if (typo.heading || typo.body) parts.push("Type: " + [typo.heading, typo.body].filter(Boolean).join(" / "));
2470
+ } else if (kind === "structure") {
2471
+ const rows =
2472
+ extra && Array.isArray(extra.outline) && extra.outline.length
2473
+ ? extra.outline
2474
+ : data && Array.isArray(data.modules)
2475
+ ? data.modules
2476
+ : [];
2477
+ const names = rows.map((r) => r && (r.name || r.label)).filter(Boolean);
2478
+ if (names.length) parts.push(`Sections (${names.length}): ` + names.join(", "));
2479
+ } else if (kind === "brand_intake") {
2480
+ const bi = (extra && extra.brandIntake) || {};
2481
+ if (bi.colors) parts.push("Colors: " + clip(bi.colors));
2482
+ if (bi.siteUrl) parts.push("Site: " + clip(bi.siteUrl));
2483
+ if (bi.themePath) parts.push("Theme: " + clip(bi.themePath));
2484
+ if (bi.code) parts.push("Pasted code");
2485
+ if (bi.voice) parts.push("Voice: " + clip(bi.voice));
2486
+ }
2487
+ return parts.join(" · ");
2488
+ }
2489
+
2490
+ // Convert the live checkpoint card into a static decision record so the turn
2491
+ // keeps meaning in the transcript instead of leaving an empty bubble (VIB-1876).
2492
+ // Rebuilds a standard assistant bubble (the card renderers use bespoke markup).
2493
+ function finalizeCheckpointCard(action, note, extra) {
2494
+ if (!checkpointCardEl) return;
2495
+ const kind = currentCheckpointKind || "design";
2496
+ const text = summarizeDecision(kind, action, note, extra);
2497
+ const facts = decisionFacts(kind, action, note, extra, currentCheckpointData);
2498
+ const glyph = action === "cancel" ? "✕" : "✓";
2499
+ const time = formatMessageTime(Date.now());
2500
+ checkpointCardEl.className = "chat-msg chat-msg--assistant chat-msg--checkpoint-resolved";
2501
+ checkpointCardEl.innerHTML =
2502
+ '<div class="chat-msg__avatar chat-msg__avatar--ai">AI</div>' +
2503
+ '<div class="chat-msg__content">' +
2504
+ '<div class="chat-msg__header"><span class="chat-msg__sender">vibeSpot AI</span>' +
2505
+ '<span class="chat-msg__time">' + time + "</span></div>" +
2506
+ '<div class="chat-msg__bubble">' +
2507
+ '<div class="checkpoint-decision" style="opacity:.85;"></div>' +
2508
+ '<div class="checkpoint-decision__facts" style="opacity:.6;font-size:12px;margin-top:2px;"></div>' +
2509
+ "</div></div>";
2510
+ checkpointCardEl.querySelector(".checkpoint-decision").textContent = glyph + " " + text;
2511
+ const factsEl = checkpointCardEl.querySelector(".checkpoint-decision__facts");
2512
+ if (facts) factsEl.textContent = facts;
2513
+ else factsEl.remove();
2514
+ checkpointCardEl = null;
2515
+ }
2516
+
2517
+ // When a stepper card is left behind (the run parks at a gate), mark the stages
2518
+ // it actually reached as done so the older card reads "complete up to here"
2519
+ // rather than frozen mid-stage (VIB-1876).
2520
+ function finalizeStepperProgress() {
2521
+ if (!pipelineStepperEl) return;
2522
+ pipelineStepperEl.querySelectorAll(".pipeline-stage--active").forEach((el) => {
2523
+ const step = el.getAttribute("data-stage");
2524
+ if (step) setStageStatus(step, "done");
2525
+ });
2526
+ }
2527
+
2528
+ function handleCheckpointRequested(msg) {
2529
+ // Settle the in-flight progress bubble; the gate now waits on the user.
2530
+ clearStreamStatus();
2531
+ finalizeStepperProgress();
2532
+ finishStreaming();
2533
+ removeCheckpointCard();
2534
+
2535
+ const preview = msg.preview || {};
2536
+ const data = preview.data || {};
2537
+ currentCheckpointKind = preview.kind || "design";
2538
+ currentCheckpointData = data;
2539
+ checkpointCardEl = renderCheckpointCard(preview, data, msg.estCostNext);
2540
+ messagesEl.appendChild(checkpointCardEl);
2541
+ scrollToBottom();
2542
+
2543
+ // Hold the send buttons until the gate is resolved.
2544
+ setSendDisabled(true);
2545
+ setStatus("Awaiting your call");
2546
+ }
2547
+
2548
+ function handleCheckpointCancelled() {
2549
+ removeCheckpointCard();
2550
+ setSendDisabled(false);
2551
+ appendSystemMessage("Cancelled — nothing was built.");
2552
+ flushQueuedMessage();
2553
+ }
2554
+
2555
+ function resolveCheckpoint(action, note, extra) {
2556
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
2557
+ // Leave the decision in the transcript (card → one-line record), not an empty
2558
+ // bubble (VIB-1876).
2559
+ finalizeCheckpointCard(action, note, extra);
2560
+ if (action === "cancel") {
2561
+ setSendDisabled(false);
2562
+ } else {
2563
+ // Work resumes. Don't pre-create an empty bubble — clear the prior turn's
2564
+ // pipeline refs so the resume's first progress event builds its OWN stepper
2565
+ // bubble (ensurePipelineBubble returns early while pipelineBubbleEl is set).
2566
+ isStreaming = true;
2567
+ setSendDisabled(true);
2568
+ pipelineBubbleEl = null;
2569
+ pipelineStepperEl = null;
2570
+ pipelineDetailEl = null;
2571
+ pipelineModulesEl = null;
2572
+ pipelineEstimateEl = null;
2573
+ streamingMsgEl = null;
2574
+ streamStartTime = Date.now();
2575
+ setStatus("Generating...");
2576
+ }
2577
+ ws.send(JSON.stringify({
2578
+ type: "checkpoint_resolve",
2579
+ action,
2580
+ note: note || undefined,
2581
+ // Structure checkpoint (VIB-1879) outline; brand-intake channels (VIB-1878).
2582
+ outline: extra && extra.outline ? extra.outline : undefined,
2583
+ brandIntake: extra && extra.brandIntake ? extra.brandIntake : undefined,
2584
+ }));
2585
+ }
2586
+
2587
+ function renderCheckpointCard(preview, data, estCostNext) {
2588
+ // Brand-intake gate (VIB-1878) — the front-of-flow ask-back; different card.
2589
+ if (preview && preview.kind === "brand_intake") {
2590
+ return renderBrandIntakeCard(preview, data);
2591
+ }
2592
+ // Structure checkpoint (VIB-1879) renders an editable module outline instead
2593
+ // of the design palette/hero.
2594
+ if (preview && preview.kind === "structure") {
2595
+ return renderStructureCheckpointCard(preview, data, estCostNext);
2596
+ }
2597
+
2598
+ const div = document.createElement("div");
2599
+ div.className = "chat-msg chat-msg--assistant";
2600
+
2601
+ const palette = Array.isArray(data.palette) ? data.palette : [];
2602
+ const swatches = palette.map((p) =>
2603
+ `<div class="checkpoint__swatch" title="${escapeHtml(p.label)}: ${escapeHtml(p.value)}">
2604
+ <span class="checkpoint__swatch-chip" style="background:${escapeHtml(p.value)}"></span>
2605
+ <span class="checkpoint__swatch-label">${escapeHtml(p.label)}</span>
2606
+ </div>`).join("");
2607
+
2608
+ const typo = data.typography || {};
2609
+ const heroSrc = typeof data.heroHtml === "string" ? data.heroHtml : "";
2610
+ const costLine = (typeof estCostNext === "number" && estCostNext > 0)
2611
+ ? `<span class="checkpoint__cost">Building all modules will cost ~$${estCostNext.toFixed(2)}</span>`
2612
+ : "";
2613
+
2614
+ div.innerHTML = `
2615
+ <div class="chat-msg__avatar chat-msg__avatar--ai">AI</div>
2616
+ <div class="chat-msg__content">
2617
+ <div class="checkpoint">
2618
+ <div class="checkpoint__head">
2619
+ <span class="checkpoint__badge">Design checkpoint</span>
2620
+ <span class="checkpoint__headline">${escapeHtml(preview.headline || "Review the design before building")}</span>
2621
+ </div>
2622
+ <div class="checkpoint__hero">
2623
+ <iframe class="checkpoint__hero-frame" title="Hero preview" sandbox="" srcdoc="${escapeHtml(heroSrc)}"></iframe>
2624
+ </div>
2625
+ <div class="checkpoint__tokens">
2626
+ <div class="checkpoint__palette">${swatches || '<span class="checkpoint__muted">No palette tokens</span>'}</div>
2627
+ <div class="checkpoint__type">
2628
+ <div class="checkpoint__type-row" style="font-family:${escapeHtml(typo.heading || "")}">Aa — ${escapeHtml(typo.heading || "Heading font")}</div>
2629
+ <div class="checkpoint__type-row checkpoint__type-row--body" style="font-family:${escapeHtml(typo.body || "")}">Body — ${escapeHtml(typo.body || "Body font")}</div>
2630
+ </div>
2631
+ </div>
2632
+ ${costLine ? `<div class="checkpoint__meta">${costLine}</div>` : ""}
2633
+ <div class="checkpoint__steer hidden">
2634
+ <textarea class="checkpoint__steer-input" rows="2" placeholder="What should change? e.g. warmer palette, bigger headings..."></textarea>
2635
+ </div>
2636
+ <div class="checkpoint__actions">
2637
+ <button class="btn btn--primary checkpoint__btn" data-action="approve">Looks good — build it</button>
2638
+ <button class="btn checkpoint__btn" data-action="steer">Tweak the design</button>
2639
+ <button class="btn checkpoint__btn" data-action="skip" title="Build now and don't ask again this run">Skip checkpoints</button>
2640
+ <button class="btn btn--ghost checkpoint__btn" data-action="cancel">Cancel</button>
2641
+ </div>
2642
+ </div>
2643
+ </div>`;
2644
+
2645
+ const steerWrap = div.querySelector(".checkpoint__steer");
2646
+ const steerInput = div.querySelector(".checkpoint__steer-input");
2647
+ const steerBtn = div.querySelector('.checkpoint__btn[data-action="steer"]');
2648
+ let steerArmed = false;
2649
+
2650
+ div.querySelectorAll(".checkpoint__btn").forEach((btn) => {
2651
+ btn.addEventListener("click", () => {
2652
+ const action = btn.dataset.action;
2653
+ // Steer is two-step: first click reveals the note box, second submits.
2654
+ if (action === "steer" && !steerArmed) {
2655
+ steerArmed = true;
2656
+ steerWrap.classList.remove("hidden");
2657
+ steerBtn.textContent = "Apply tweak";
2658
+ if (steerInput) steerInput.focus();
2659
+ return;
2660
+ }
2661
+ const note = action === "steer" && steerInput ? steerInput.value.trim() : undefined;
2662
+ div.querySelectorAll(".checkpoint__btn").forEach((b) => (b.disabled = true));
2663
+ resolveCheckpoint(action, note);
2664
+ });
2665
+ });
2666
+
2667
+ return div;
2668
+ }
2669
+
2670
+ // Structure checkpoint card (VIB-1879) — the planned module skeleton as an
2671
+ // editable outline (reorder / cut / add / rename) before the parallel build.
2672
+ function renderStructureCheckpointCard(preview, data, estCostNext) {
2673
+ const div = document.createElement("div");
2674
+ div.className = "chat-msg chat-msg--assistant";
2675
+
2676
+ const modules = Array.isArray(data.modules) ? data.modules : [];
2677
+ const narrative = typeof data.narrative === "string" ? data.narrative : "";
2678
+ const costLine = (typeof estCostNext === "number" && estCostNext > 0)
2679
+ ? `<span class="checkpoint__cost">Building all modules will cost ~$${estCostNext.toFixed(2)}</span>`
2680
+ : "";
2681
+
2682
+ div.innerHTML = `
2683
+ <div class="chat-msg__avatar chat-msg__avatar--ai">AI</div>
2684
+ <div class="chat-msg__content">
2685
+ <div class="checkpoint checkpoint--structure">
2686
+ <div class="checkpoint__head">
2687
+ <span class="checkpoint__badge">Structure checkpoint</span>
2688
+ <span class="checkpoint__headline">${escapeHtml(preview.headline || "Review the structure before building")}</span>
2689
+ </div>
2690
+ ${narrative ? `<div class="checkpoint__narrative">${escapeHtml(narrative)}</div>` : ""}
2691
+ <div class="checkpoint__outline"></div>
2692
+ <button type="button" class="btn btn--ghost checkpoint__add">+ Add section</button>
2693
+ ${costLine ? `<div class="checkpoint__meta">${costLine}</div>` : ""}
2694
+ <div class="checkpoint__steer hidden">
2695
+ <textarea class="checkpoint__steer-input" rows="2" placeholder="What should change? e.g. add a pricing section, drop the FAQ..."></textarea>
2696
+ </div>
2697
+ <div class="checkpoint__actions">
2698
+ <button class="btn btn--primary checkpoint__btn" data-action="approve">Build these sections</button>
2699
+ <button class="btn checkpoint__btn" data-action="steer">Re-plan structure</button>
2700
+ <button class="btn checkpoint__btn" data-action="skip" title="Build now and don't ask again this run">Skip checkpoints</button>
2701
+ <button class="btn btn--ghost checkpoint__btn" data-action="cancel">Cancel</button>
2702
+ </div>
2703
+ </div>
2704
+ </div>`;
2705
+
2706
+ const listEl = div.querySelector(".checkpoint__outline");
2707
+
2708
+ function makeRow(name, description, sourceIndex) {
2709
+ const row = document.createElement("div");
2710
+ row.className = "checkpoint__row";
2711
+ if (sourceIndex != null) row.dataset.sourceIndex = String(sourceIndex);
2712
+ if (description) row.dataset.desc = description;
2713
+ row.innerHTML = `
2714
+ <div class="checkpoint__row-move">
2715
+ <button type="button" class="checkpoint__row-btn" data-move="up" title="Move up" aria-label="Move up">▲</button>
2716
+ <button type="button" class="checkpoint__row-btn" data-move="down" title="Move down" aria-label="Move down">▼</button>
2717
+ </div>
2718
+ <div class="checkpoint__row-main">
2719
+ <input class="checkpoint__row-name" value="${escapeHtml(name || "")}" placeholder="section-name" spellcheck="false" />
2720
+ <span class="checkpoint__row-desc">${escapeHtml(description || "")}</span>
2721
+ </div>
2722
+ <button type="button" class="checkpoint__row-remove" data-remove title="Remove section" aria-label="Remove section">✕</button>`;
2723
+
2724
+ row.querySelector('[data-move="up"]').addEventListener("click", () => {
2725
+ const prev = row.previousElementSibling;
2726
+ if (prev) listEl.insertBefore(row, prev);
2727
+ });
2728
+ row.querySelector('[data-move="down"]').addEventListener("click", () => {
2729
+ const next = row.nextElementSibling;
2730
+ if (next) listEl.insertBefore(next, row);
2731
+ });
2732
+ row.querySelector("[data-remove]").addEventListener("click", () => {
2733
+ row.remove();
2734
+ });
2735
+ return row;
2736
+ }
2737
+
2738
+ modules.forEach((m) =>
2739
+ listEl.appendChild(makeRow(m.name, m.description, m.sourceIndex)),
2740
+ );
2741
+
2742
+ div.querySelector(".checkpoint__add").addEventListener("click", () => {
2743
+ const row = makeRow("", "", null);
2744
+ listEl.appendChild(row);
2745
+ const input = row.querySelector(".checkpoint__row-name");
2746
+ if (input) input.focus();
2747
+ });
2748
+
2749
+ function collectOutline() {
2750
+ return [...listEl.querySelectorAll(".checkpoint__row")]
2751
+ .map((row) => {
2752
+ const name = (row.querySelector(".checkpoint__row-name").value || "").trim();
2753
+ const si = row.dataset.sourceIndex;
2754
+ return {
2755
+ name,
2756
+ description: row.dataset.desc || undefined,
2757
+ sourceIndex: si !== undefined && si !== "" ? Number(si) : undefined,
2758
+ };
2759
+ })
2760
+ .filter((it) => it.name);
2761
+ }
2762
+
2763
+ const steerWrap = div.querySelector(".checkpoint__steer");
2764
+ const steerInput = div.querySelector(".checkpoint__steer-input");
2765
+ const steerBtn = div.querySelector('.checkpoint__btn[data-action="steer"]');
2766
+ let steerArmed = false;
2767
+
2768
+ div.querySelectorAll(".checkpoint__btn").forEach((btn) => {
2769
+ btn.addEventListener("click", () => {
2770
+ const action = btn.dataset.action;
2771
+ // Steer is two-step: first click reveals the note box, second submits.
2772
+ if (action === "steer" && !steerArmed) {
2773
+ steerArmed = true;
2774
+ steerWrap.classList.remove("hidden");
2775
+ steerBtn.textContent = "Apply re-plan";
2776
+ if (steerInput) steerInput.focus();
2777
+ return;
2778
+ }
2779
+ const note = action === "steer" && steerInput ? steerInput.value.trim() : undefined;
2780
+ // approve/skip carry the edited outline; steer/cancel don't.
2781
+ const outline = action === "approve" || action === "skip" ? collectOutline() : undefined;
2782
+ div.querySelectorAll(".checkpoint__btn").forEach((b) => (b.disabled = true));
2783
+ resolveCheckpoint(action, note, { outline });
2784
+ });
2785
+ });
2786
+
2787
+ return div;
2788
+ }
2789
+
2790
+ // ---------------------------------------------------------------------------
2791
+ // Brand-intake gate card (VIB-1878) — the front-of-flow ask-back shown when a
2792
+ // new page has no style system yet: "Surprise me" (AI invents) vs "Bring your
2793
+ // brand" (paste colors/code/voice, or point at a site/theme → seed the design).
2794
+ // ---------------------------------------------------------------------------
2795
+
2796
+ function renderBrandIntakeCard(preview, data) {
2797
+ const div = document.createElement("div");
2798
+ div.className = "chat-msg chat-msg--assistant";
2799
+
2800
+ const channels = Array.isArray(data.channels) ? data.channels : [];
2801
+ const fields = channels.map((c) => {
2802
+ const id = `brand-${escapeHtml(c.key)}`;
2803
+ const input = c.multiline
2804
+ ? `<textarea class="brand-intake__input" data-key="${escapeHtml(c.key)}" rows="3" placeholder="${escapeHtml(c.placeholder || "")}"></textarea>`
2805
+ : `<input class="brand-intake__input" data-key="${escapeHtml(c.key)}" type="text" placeholder="${escapeHtml(c.placeholder || "")}" />`;
2806
+ return `<label class="brand-intake__field" for="${id}">
2807
+ <span class="brand-intake__label">${escapeHtml(c.label || c.key)}</span>
2808
+ ${input}
2809
+ </label>`;
2810
+ }).join("");
2811
+
2812
+ div.innerHTML = `
2813
+ <div class="chat-msg__avatar chat-msg__avatar--ai">AI</div>
2814
+ <div class="chat-msg__content">
2815
+ <div class="checkpoint brand-intake">
2816
+ <div class="checkpoint__head">
2817
+ <span class="checkpoint__badge">Brand checkpoint</span>
2818
+ <span class="checkpoint__headline">${escapeHtml(preview.headline || "Match your brand, or surprise you?")}</span>
2819
+ </div>
2820
+ <div class="brand-intake__choices">
2821
+ <button class="btn btn--primary brand-intake__choice" data-choice="surprise">
2822
+ <span class="brand-intake__choice-title">${escapeHtml(data.surpriseLabel || "Surprise me")}</span>
2823
+ <span class="brand-intake__choice-hint">${escapeHtml(data.surpriseHint || "")}</span>
2824
+ </button>
2825
+ <button class="btn brand-intake__choice" data-choice="bring">
2826
+ <span class="brand-intake__choice-title">${escapeHtml(data.bringLabel || "Bring your brand")}</span>
2827
+ <span class="brand-intake__choice-hint">${escapeHtml(data.bringHint || "")}</span>
2828
+ </button>
2829
+ </div>
2830
+ <div class="brand-intake__form hidden">
2831
+ ${fields}
2832
+ <div class="brand-intake__form-actions">
2833
+ <button class="btn btn--primary brand-intake__submit">Use my brand</button>
2834
+ <button class="btn btn--ghost brand-intake__back">Back</button>
2835
+ </div>
2836
+ </div>
2837
+ <div class="checkpoint__actions brand-intake__top-actions">
2838
+ <button class="btn btn--ghost checkpoint__btn" data-action="cancel">Cancel</button>
2839
+ </div>
2840
+ </div>
2841
+ </div>`;
2842
+
2843
+ const form = div.querySelector(".brand-intake__form");
2844
+ const choices = div.querySelector(".brand-intake__choices");
2845
+
2846
+ div.querySelector('[data-choice="surprise"]').addEventListener("click", () => {
2847
+ div.querySelectorAll("button").forEach((b) => (b.disabled = true));
2848
+ // "Surprise me" = skip the brand step; the AI invents the design system.
2849
+ resolveCheckpoint("skip");
2850
+ });
2851
+
2852
+ div.querySelector('[data-choice="bring"]').addEventListener("click", () => {
2853
+ if (choices) choices.classList.add("hidden");
2854
+ if (form) form.classList.remove("hidden");
2855
+ const first = div.querySelector(".brand-intake__input");
2856
+ if (first) first.focus();
2857
+ });
2858
+
2859
+ const backBtn = div.querySelector(".brand-intake__back");
2860
+ if (backBtn) backBtn.addEventListener("click", () => {
2861
+ if (form) form.classList.add("hidden");
2862
+ if (choices) choices.classList.remove("hidden");
2863
+ });
2864
+
2865
+ const submitBtn = div.querySelector(".brand-intake__submit");
2866
+ if (submitBtn) submitBtn.addEventListener("click", () => {
2867
+ const brandIntake = {};
2868
+ div.querySelectorAll(".brand-intake__input").forEach((el) => {
2869
+ const v = (el.value || "").trim();
2870
+ if (v) brandIntake[el.dataset.key] = v;
2871
+ });
2872
+ if (Object.keys(brandIntake).length === 0) {
2873
+ // Nothing filled in → treat as "surprise me" rather than a no-op.
2874
+ div.querySelectorAll("button").forEach((b) => (b.disabled = true));
2875
+ resolveCheckpoint("skip");
2876
+ return;
2877
+ }
2878
+ div.querySelectorAll("button").forEach((b) => (b.disabled = true));
2879
+ resolveCheckpoint("approve", undefined, { brandIntake });
2880
+ });
2881
+
2882
+ const cancelBtn = div.querySelector('.checkpoint__btn[data-action="cancel"]');
2883
+ if (cancelBtn) cancelBtn.addEventListener("click", () => {
2884
+ div.querySelectorAll("button").forEach((b) => (b.disabled = true));
2885
+ resolveCheckpoint("cancel");
2886
+ });
2887
+
2888
+ return div;
2889
+ }
2890
+
2249
2891
  // ---------------------------------------------------------------------------
2250
2892
  // Version history panel
2251
2893
  // ---------------------------------------------------------------------------
@@ -3137,6 +3779,13 @@ sendBtn.addEventListener("click", () => {
3137
3779
  sendMessage(inputEl.value);
3138
3780
  });
3139
3781
 
3782
+ // One-shot button — build the whole page, skipping the design checkpoint.
3783
+ if (oneShotBtn) {
3784
+ oneShotBtn.addEventListener("click", () => {
3785
+ sendMessage(inputEl.value, { oneShot: true });
3786
+ });
3787
+ }
3788
+
3140
3789
  // Enter to send (Shift+Enter for newline)
3141
3790
  inputEl.addEventListener("keydown", (e) => {
3142
3791
  if (e.key === "Enter" && !e.shiftKey) {