vibespot 1.6.5 → 1.8.0

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.
Files changed (40) hide show
  1. package/README.md +24 -3
  2. package/assets/whats-new.json +27 -0
  3. package/dist/index.js +579 -681
  4. package/dist/index.js.map +1 -1
  5. package/package.json +8 -5
  6. package/ui/chat.js +671 -9
  7. package/ui/dashboard.js +53 -12
  8. package/ui/docs/index.html +57 -2
  9. package/ui/email-preview.js +1 -5
  10. package/ui/escape-html.js +14 -0
  11. package/ui/field-editor.js +6 -12
  12. package/ui/field-save.js +82 -0
  13. package/ui/index.html +10 -4
  14. package/ui/inline-edit.js +116 -570
  15. package/ui/plan.js +22 -13
  16. package/ui/preview-agent.js +1050 -0
  17. package/ui/preview.js +248 -265
  18. package/ui/section-controls.js +16 -622
  19. package/ui/setup.js +73 -20
  20. package/ui/styles.css +424 -0
  21. package/ui/upload-panel.js +7 -8
  22. package/ui/whats-new.js +249 -0
  23. package/assets/readme/00-hero-banner.png +0 -0
  24. package/assets/readme/00-hero-banner.svg +0 -59
  25. package/assets/readme/01-vibe-coding-hero.png +0 -0
  26. package/assets/readme/02-plan-mode.png +0 -0
  27. package/assets/readme/03-figma-import.png +0 -0
  28. package/assets/readme/04-multi-page-sites.png +0 -0
  29. package/assets/readme/05-inline-wysiwyg.png +0 -0
  30. package/assets/readme/06-hubspot-upload.png +0 -0
  31. package/ui/docs/screenshots/brand-kit-preview.png +0 -0
  32. package/ui/docs/screenshots/content-type-dropdown.png +0 -0
  33. package/ui/docs/screenshots/editor-full-layout.png +0 -0
  34. package/ui/docs/screenshots/inline-wysiwyg-editing.png +0 -0
  35. package/ui/docs/screenshots/module-overview-slideout.png +0 -0
  36. package/ui/docs/screenshots/multi-page-tree.png +0 -0
  37. package/ui/docs/screenshots/onboarding-walkthrough.png +0 -0
  38. package/ui/docs/screenshots/split-pane-view.png +0 -0
  39. package/ui/docs/screenshots/visual-controls-toolbar.png +0 -0
  40. package/ui/docs/screenshots/workspace-tabs.png +0 -0
package/ui/chat.js CHANGED
@@ -8,6 +8,19 @@
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
+ // True while a checkpoint gate card is parked awaiting the user's decision.
16
+ // finishStreaming() clears isStreaming at the gate, so this flag is what
17
+ // actually locks the composer (VIB-1898).
18
+ let checkpointGateActive = false;
19
+ // Softened barge-in (VIB-1876): a message sent during an active build is QUEUED
20
+ // by default and dispatched when the build finishes. The queued chip carries an
21
+ // "Interrupt now" button that triggers the old cancel-and-replan barge-in.
22
+ let queuedMessage = null; // { text, opts }
23
+ let queuedChipEl = null;
11
24
  let streamingMsgEl = null;
12
25
  let streamBuffer = "";
13
26
  let streamStartTime = 0;
@@ -36,6 +49,9 @@ let lastGenerationBubbleEl = null;
36
49
  const messagesEl = document.getElementById("chat-messages");
37
50
  const inputEl = document.getElementById("chat-input");
38
51
  const sendBtn = document.getElementById("chat-send");
52
+ const oneShotBtn = document.getElementById("chat-send-oneshot");
53
+ // Remembered so the barge-in hint placeholder can be restored after a run.
54
+ const chatInputDefaultPlaceholder = (inputEl && inputEl.placeholder) || "";
39
55
  const statusText = document.getElementById("status-text");
40
56
  const statusEngine = document.getElementById("status-engine");
41
57
  const statusTheme = document.getElementById("status-theme");
@@ -662,6 +678,13 @@ function handleWsMessage(msg) {
662
678
  }
663
679
  }
664
680
 
681
+ // Rehydrate a parked checkpoint after a refresh / device sleep (VIB-1876):
682
+ // the server kept the gate, so re-render the card and let the user resume
683
+ // from where they were. Plan-mode parks are rehydrated by planController.
684
+ if (msg.pendingCheckpoint && msg.pendingCheckpoint.preview && msg.pendingCheckpoint.kind !== "plan") {
685
+ handleCheckpointRequested({ preview: msg.pendingCheckpoint.preview });
686
+ }
687
+
665
688
  // If setup handed us an initial prompt (describe-it path), send it now
666
689
  // that the session is live. Skip if the project already has history
667
690
  // (e.g. resumed session) to avoid double-submitting.
@@ -691,6 +714,7 @@ function handleWsMessage(msg) {
691
714
  highlightOnNextModulesUpdated = true;
692
715
  // Refresh the Library tab's module list so it stays in sync
693
716
  if (typeof refreshDashboard === "function") refreshDashboard();
717
+ flushQueuedMessage();
694
718
  break;
695
719
 
696
720
  case "modules_updated":
@@ -730,6 +754,9 @@ function handleWsMessage(msg) {
730
754
  resetPipelineState();
731
755
  appendAssistantError(msg.message);
732
756
  setStatus("Error");
757
+ // The run this message was queued behind is over — dispatch it rather
758
+ // than stranding the chip forever (VIB-1898).
759
+ flushQueuedMessage();
733
760
  break;
734
761
 
735
762
  case "pong":
@@ -743,14 +770,26 @@ function handleWsMessage(msg) {
743
770
  handleAgentDecision(msg);
744
771
  break;
745
772
  case "module_progress":
773
+ enableBargeIn();
746
774
  handleModuleProgress(msg);
747
775
  break;
776
+ case "generation_superseded":
777
+ // The server confirmed our barge-in cancelled the prior run; the fresh
778
+ // run's events follow. Nothing to do — supersedeCurrentRun already reset.
779
+ setStatus("Redirecting…");
780
+ break;
748
781
  case "pipeline_complete":
749
782
  handlePipelineComplete(msg);
750
783
  break;
751
784
  case "pipeline_partial":
752
785
  handlePipelinePartial(msg);
753
786
  break;
787
+ case "checkpoint_requested":
788
+ handleCheckpointRequested(msg);
789
+ break;
790
+ case "checkpoint_cancelled":
791
+ handleCheckpointCancelled();
792
+ break;
754
793
  case "generation_cost":
755
794
  handleGenerationCost(msg);
756
795
  break;
@@ -972,6 +1011,8 @@ function setPipelineDetail(text) {
972
1011
  }
973
1012
 
974
1013
  function handleAgentStep(msg) {
1014
+ // The expensive build stage is the barge-in window (VIB-1880).
1015
+ if (msg.step === "developing") enableBargeIn();
975
1016
  ensurePipelineBubble();
976
1017
 
977
1018
  const incoming = msg.step;
@@ -1285,6 +1326,7 @@ function handlePipelineComplete(msg) {
1285
1326
  lastGenerationBubbleEl = bubble;
1286
1327
 
1287
1328
  resetPipelineState();
1329
+ flushQueuedMessage();
1288
1330
  }
1289
1331
 
1290
1332
  function handlePipelinePartial(msg) {
@@ -1315,6 +1357,7 @@ function handlePipelinePartial(msg) {
1315
1357
  lastGenerationBubbleEl = bubble;
1316
1358
 
1317
1359
  resetPipelineState();
1360
+ flushQueuedMessage();
1318
1361
  }
1319
1362
 
1320
1363
  function resetPipelineState() {
@@ -1745,9 +1788,25 @@ fileInputEl.addEventListener("change", () => {
1745
1788
  // Sending messages
1746
1789
  // ---------------------------------------------------------------------------
1747
1790
 
1748
- async function sendMessage(text) {
1791
+ async function sendMessage(text, opts = {}) {
1749
1792
  const hasFiles = pendingFiles.length > 0;
1750
- if ((!text.trim() && !hasFiles) || isStreaming || !ws || ws.readyState !== WebSocket.OPEN) return;
1793
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
1794
+ if (!text.trim() && !hasFiles) return;
1795
+ // A parked checkpoint gate must be resolved through its card. Only the send
1796
+ // buttons were locked before — Enter still reached here (the streaming flags
1797
+ // are reset at the gate) and started a second run mid-park (VIB-1898).
1798
+ if (checkpointGateActive) return;
1799
+ // Block sends while busy — EXCEPT during an active agentic build. There, a new
1800
+ // message is QUEUED by default and runs when the build finishes (VIB-1876);
1801
+ // an explicit Interrupt (opts.interrupt) triggers the cancel-and-replan
1802
+ // barge-in (VIB-1880). Single-call streaming and checkpoint gates stay locked.
1803
+ if (isStreaming && !agenticRunning) return;
1804
+ const buildActive = isStreaming && agenticRunning;
1805
+ if (buildActive && !opts.interrupt) {
1806
+ queueMessage(text, opts);
1807
+ return;
1808
+ }
1809
+ if (buildActive && opts.interrupt) supersedeCurrentRun();
1751
1810
 
1752
1811
  // Remove welcome screen
1753
1812
  const welcome = messagesEl.querySelector(".chat__welcome");
@@ -1794,6 +1853,8 @@ async function sendMessage(text) {
1794
1853
  if (uploadedFiles.length > 0) {
1795
1854
  payload.fileIds = uploadedFiles.map((f) => f.id);
1796
1855
  }
1856
+ // One-shot: skip the design checkpoint and build the whole page (VIB-1877).
1857
+ if (opts.oneShot) payload.oneShot = true;
1797
1858
  ws.send(JSON.stringify(payload));
1798
1859
 
1799
1860
  // Clear input
@@ -1836,6 +1897,7 @@ function startStreaming() {
1836
1897
  streamBuffer = "";
1837
1898
  lastStreamStatus = "";
1838
1899
  sendBtn.disabled = true;
1900
+ if (oneShotBtn) oneShotBtn.disabled = true;
1839
1901
  streamStartTime = Date.now();
1840
1902
  if (typeof window.setSelectModeDisabled === "function") {
1841
1903
  window.setSelectModeDisabled(true);
@@ -1984,10 +2046,111 @@ function clearStreamStatus() {
1984
2046
  if (statusEl) statusEl.remove();
1985
2047
  }
1986
2048
 
2049
+ // Open the barge-in window: the build is running, so re-enable the composer and
2050
+ // let the user redirect the run mid-flight (VIB-1880).
2051
+ function enableBargeIn() {
2052
+ if (agenticRunning) return;
2053
+ agenticRunning = true;
2054
+ sendBtn.disabled = false;
2055
+ if (oneShotBtn) oneShotBtn.disabled = false;
2056
+ if (inputEl) inputEl.placeholder = "Building… your message will queue (or Interrupt to redirect now)";
2057
+ }
2058
+
2059
+ // --- Softened barge-in: queue-by-default with an Interrupt button (VIB-1876) ---
2060
+
2061
+ // Queue a message sent mid-build instead of cancelling the run. Latest wins.
2062
+ function queueMessage(text, opts) {
2063
+ if (!text.trim() && pendingFiles.length === 0) return;
2064
+ queuedMessage = { text, opts: { ...opts } };
2065
+ if (inputEl) {
2066
+ inputEl.value = "";
2067
+ inputEl.dispatchEvent(new Event("input"));
2068
+ }
2069
+ renderQueuedChip();
2070
+ setStatus("Message queued");
2071
+ }
2072
+
2073
+ function clearQueuedMessage() {
2074
+ queuedMessage = null;
2075
+ if (queuedChipEl) {
2076
+ queuedChipEl.remove();
2077
+ queuedChipEl = null;
2078
+ }
2079
+ }
2080
+
2081
+ // Trigger the real barge-in (cancel-and-replan) with the queued message.
2082
+ function interruptWithQueued() {
2083
+ if (!queuedMessage) return;
2084
+ const { text, opts } = queuedMessage;
2085
+ clearQueuedMessage();
2086
+ sendMessage(text, { ...opts, interrupt: true });
2087
+ }
2088
+
2089
+ // Dispatch the queued message once the current build has finished.
2090
+ function flushQueuedMessage() {
2091
+ if (!queuedMessage) return;
2092
+ // A parked gate blocks sendMessage — keep the message queued; the gate's
2093
+ // resolution path flushes again (VIB-1898).
2094
+ if (checkpointGateActive) return;
2095
+ const { text, opts } = queuedMessage;
2096
+ clearQueuedMessage();
2097
+ // Defer so the just-finished run's UI settles before the next one starts.
2098
+ setTimeout(() => sendMessage(text, opts), 0);
2099
+ }
2100
+
2101
+ function renderQueuedChip() {
2102
+ if (!queuedMessage) return clearQueuedMessage();
2103
+ const area = inputEl && inputEl.closest(".chat__input-area");
2104
+ if (!area) return;
2105
+ if (!queuedChipEl) {
2106
+ queuedChipEl = document.createElement("div");
2107
+ queuedChipEl.className = "queued-msg";
2108
+ queuedChipEl.style.cssText =
2109
+ "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;";
2110
+ area.insertBefore(queuedChipEl, area.firstChild);
2111
+ }
2112
+ const t = queuedMessage.text || "(files attached)";
2113
+ const preview = t.length > 90 ? t.slice(0, 90) + "…" : t;
2114
+ queuedChipEl.innerHTML =
2115
+ '<span aria-hidden="true">⏳</span>' +
2116
+ '<span style="opacity:.7;white-space:nowrap;">Queued — runs next:</span>' +
2117
+ '<span class="queued-msg__text" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span>' +
2118
+ '<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>' +
2119
+ '<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>';
2120
+ queuedChipEl.querySelector(".queued-msg__text").textContent = preview;
2121
+ queuedChipEl.querySelector(".queued-msg__interrupt").onclick = interruptWithQueued;
2122
+ queuedChipEl.querySelector(".queued-msg__cancel").onclick = clearQueuedMessage;
2123
+ }
2124
+
2125
+ // Tear down the superseded run's transient UI so the replacement run renders
2126
+ // fresh (called when a barge-in send cancels the active build).
2127
+ function supersedeCurrentRun() {
2128
+ stopStreamTimer();
2129
+ clearStreamStatus();
2130
+ const streamingEl = messagesEl.querySelector(".chat-msg--streaming");
2131
+ if (streamingEl) {
2132
+ streamingEl.classList.remove("chat-msg--streaming");
2133
+ const bubble = streamingEl.querySelector(".chat-msg__bubble");
2134
+ if (bubble && !bubble.textContent.trim()) {
2135
+ bubble.innerHTML = '<em class="message__placeholder">Superseded by your next message.</em>';
2136
+ }
2137
+ }
2138
+ removeCheckpointCard();
2139
+ checkpointGateActive = false;
2140
+ isStreaming = false;
2141
+ agenticRunning = false;
2142
+ streamingMsgEl = null;
2143
+ streamBuffer = "";
2144
+ resetPipelineState();
2145
+ }
2146
+
1987
2147
  function finishStreaming() {
1988
2148
  if (!isStreaming) return;
1989
2149
  isStreaming = false;
2150
+ agenticRunning = false;
2151
+ if (inputEl) inputEl.placeholder = chatInputDefaultPlaceholder;
1990
2152
  sendBtn.disabled = false;
2153
+ if (oneShotBtn) oneShotBtn.disabled = false;
1991
2154
  if (typeof window.setSelectModeDisabled === "function") {
1992
2155
  window.setSelectModeDisabled(false);
1993
2156
  }
@@ -2008,6 +2171,24 @@ function finishStreaming() {
2008
2171
  if (streamingEl) {
2009
2172
  streamingEl.classList.remove("chat-msg--streaming");
2010
2173
 
2174
+ // Agentic seams (a checkpoint gate, or a resume that re-parks / just builds
2175
+ // modules) leave a streaming bubble that never received any text or progress
2176
+ // stepper. Don't finalize it into an empty bubble with a lone duration —
2177
+ // drop it (VIB-1876 follow-up). A bubble carrying the pipeline stepper or
2178
+ // any rendered content has child nodes, so it's kept.
2179
+ const bubbleEl = streamingEl.querySelector(".chat-msg__bubble");
2180
+ const hasContent =
2181
+ !!bubbleEl && (bubbleEl.children.length > 0 || bubbleEl.textContent.trim().length > 0);
2182
+ const willRenderText = !!streamBuffer && streamBuffer.trim().length > 0;
2183
+ if (!hasContent && !willRenderText) {
2184
+ streamingEl.remove();
2185
+ streamingMsgEl = null;
2186
+ streamBuffer = "";
2187
+ setStatus("Ready");
2188
+ scrollToBottom();
2189
+ return;
2190
+ }
2191
+
2011
2192
  // Add duration metadata beneath the bubble (inside .chat-msg__content)
2012
2193
  const meta = document.createElement("div");
2013
2194
  meta.className = "chat-msg__meta";
@@ -2091,13 +2272,7 @@ function renderMarkdown(text) {
2091
2272
  // Helpers
2092
2273
  // ---------------------------------------------------------------------------
2093
2274
 
2094
- function escapeHtml(str) {
2095
- return str
2096
- .replace(/&/g, "&amp;")
2097
- .replace(/</g, "&lt;")
2098
- .replace(/>/g, "&gt;")
2099
- .replace(/"/g, "&quot;");
2100
- }
2275
+ // escapeHtml comes from the shared ui/escape-html.js (VIB-1902).
2101
2276
 
2102
2277
  function scrollToBottom() {
2103
2278
  if (scrollScheduled) return;
@@ -2246,6 +2421,486 @@ function appendSystemMessage(text) {
2246
2421
  scrollToBottom();
2247
2422
  }
2248
2423
 
2424
+ // ---------------------------------------------------------------------------
2425
+ // Checkpoint gate card (VIB-1877) — the design preview the user approves /
2426
+ // steers / skips / cancels before the expensive module build.
2427
+ // ---------------------------------------------------------------------------
2428
+
2429
+ let checkpointCardEl = null;
2430
+ let currentCheckpointKind = null;
2431
+ let currentCheckpointData = null;
2432
+
2433
+ function setSendDisabled(disabled) {
2434
+ sendBtn.disabled = disabled;
2435
+ if (oneShotBtn) oneShotBtn.disabled = disabled;
2436
+ }
2437
+
2438
+ function removeCheckpointCard() {
2439
+ if (checkpointCardEl && checkpointCardEl.parentNode) {
2440
+ checkpointCardEl.parentNode.removeChild(checkpointCardEl);
2441
+ }
2442
+ checkpointCardEl = null;
2443
+ }
2444
+
2445
+ // One-line record of what the user decided at a checkpoint (VIB-1876). Boris:
2446
+ // keep the decision in the transcript rather than dropping the turn entirely.
2447
+ function summarizeDecision(kind, action, note, extra) {
2448
+ const label =
2449
+ kind === "brand_intake" ? "Brand" : kind === "structure" ? "Structure" : kind === "plan" ? "Plan" : "Design";
2450
+ if (action === "cancel") return `${label}: cancelled — nothing was built.`;
2451
+ if (action === "steer") return `${label}: steered${note ? " — " + note : ""}.`;
2452
+ if (kind === "brand_intake") {
2453
+ if (action !== "approve") return "Brand: surprise me — generating a design system.";
2454
+ const ch =
2455
+ extra && extra.brandIntake
2456
+ ? Object.keys(extra.brandIntake).filter((k) => extra.brandIntake[k])
2457
+ : [];
2458
+ return `Brand: bringing your brand${ch.length ? " (" + ch.join(", ") + ")" : ""}.`;
2459
+ }
2460
+ if (action === "skip") return `${label}: skipped remaining checkpoints — building now.`;
2461
+ return `${label}: approved.`;
2462
+ }
2463
+
2464
+ // The substantive content/facts behind a decision (VIB-1876): the palette/type
2465
+ // approved, the module outline kept, or the brand inputs provided — so the
2466
+ // transcript records *what*, not just the verb. Returns "" when there's nothing.
2467
+ function decisionFacts(kind, action, note, extra, data) {
2468
+ if (action === "cancel") return "";
2469
+ if (action === "steer") return note ? "Note: " + note : "";
2470
+ const parts = [];
2471
+ const clip = (s) => String(s).replace(/\s+/g, " ").trim().slice(0, 80);
2472
+ if (kind === "design" || kind === "plan") {
2473
+ const pal = Array.isArray(data && data.palette)
2474
+ ? data.palette.map((p) => p && p.value).filter(Boolean).slice(0, 5)
2475
+ : [];
2476
+ if (pal.length) parts.push("Palette: " + pal.join(", "));
2477
+ const typo = (data && data.typography) || {};
2478
+ if (typo.heading || typo.body) parts.push("Type: " + [typo.heading, typo.body].filter(Boolean).join(" / "));
2479
+ } else if (kind === "structure") {
2480
+ const rows =
2481
+ extra && Array.isArray(extra.outline) && extra.outline.length
2482
+ ? extra.outline
2483
+ : data && Array.isArray(data.modules)
2484
+ ? data.modules
2485
+ : [];
2486
+ const names = rows.map((r) => r && (r.name || r.label)).filter(Boolean);
2487
+ if (names.length) parts.push(`Sections (${names.length}): ` + names.join(", "));
2488
+ } else if (kind === "brand_intake") {
2489
+ const bi = (extra && extra.brandIntake) || {};
2490
+ if (bi.colors) parts.push("Colors: " + clip(bi.colors));
2491
+ if (bi.siteUrl) parts.push("Site: " + clip(bi.siteUrl));
2492
+ if (bi.themePath) parts.push("Theme: " + clip(bi.themePath));
2493
+ if (bi.code) parts.push("Pasted code");
2494
+ if (bi.voice) parts.push("Voice: " + clip(bi.voice));
2495
+ }
2496
+ return parts.join(" · ");
2497
+ }
2498
+
2499
+ // Convert the live checkpoint card into a static decision record so the turn
2500
+ // keeps meaning in the transcript instead of leaving an empty bubble (VIB-1876).
2501
+ // Rebuilds a standard assistant bubble (the card renderers use bespoke markup).
2502
+ function finalizeCheckpointCard(action, note, extra) {
2503
+ if (!checkpointCardEl) return;
2504
+ const kind = currentCheckpointKind || "design";
2505
+ const text = summarizeDecision(kind, action, note, extra);
2506
+ const facts = decisionFacts(kind, action, note, extra, currentCheckpointData);
2507
+ const glyph = action === "cancel" ? "✕" : "✓";
2508
+ const time = formatMessageTime(Date.now());
2509
+ checkpointCardEl.className = "chat-msg chat-msg--assistant chat-msg--checkpoint-resolved";
2510
+ checkpointCardEl.innerHTML =
2511
+ '<div class="chat-msg__avatar chat-msg__avatar--ai">AI</div>' +
2512
+ '<div class="chat-msg__content">' +
2513
+ '<div class="chat-msg__header"><span class="chat-msg__sender">vibeSpot AI</span>' +
2514
+ '<span class="chat-msg__time">' + time + "</span></div>" +
2515
+ '<div class="chat-msg__bubble">' +
2516
+ '<div class="checkpoint-decision" style="opacity:.85;"></div>' +
2517
+ '<div class="checkpoint-decision__facts" style="opacity:.6;font-size:12px;margin-top:2px;"></div>' +
2518
+ "</div></div>";
2519
+ checkpointCardEl.querySelector(".checkpoint-decision").textContent = glyph + " " + text;
2520
+ const factsEl = checkpointCardEl.querySelector(".checkpoint-decision__facts");
2521
+ if (facts) factsEl.textContent = facts;
2522
+ else factsEl.remove();
2523
+ checkpointCardEl = null;
2524
+ }
2525
+
2526
+ // When a stepper card is left behind (the run parks at a gate), mark the stages
2527
+ // it actually reached as done so the older card reads "complete up to here"
2528
+ // rather than frozen mid-stage (VIB-1876).
2529
+ function finalizeStepperProgress() {
2530
+ if (!pipelineStepperEl) return;
2531
+ pipelineStepperEl.querySelectorAll(".pipeline-stage--active").forEach((el) => {
2532
+ const step = el.getAttribute("data-stage");
2533
+ if (step) setStageStatus(step, "done");
2534
+ });
2535
+ }
2536
+
2537
+ function handleCheckpointRequested(msg) {
2538
+ // Settle the in-flight progress bubble; the gate now waits on the user.
2539
+ clearStreamStatus();
2540
+ finalizeStepperProgress();
2541
+ finishStreaming();
2542
+ removeCheckpointCard();
2543
+
2544
+ const preview = msg.preview || {};
2545
+ const data = preview.data || {};
2546
+ currentCheckpointKind = preview.kind || "design";
2547
+ currentCheckpointData = data;
2548
+ checkpointCardEl = renderCheckpointCard(preview, data, msg.estCostNext);
2549
+ messagesEl.appendChild(checkpointCardEl);
2550
+ scrollToBottom();
2551
+
2552
+ // Hold the composer until the gate is resolved — buttons AND the Enter path
2553
+ // through sendMessage (VIB-1898).
2554
+ checkpointGateActive = true;
2555
+ setSendDisabled(true);
2556
+ setStatus("Awaiting your call");
2557
+ }
2558
+
2559
+ function handleCheckpointCancelled() {
2560
+ removeCheckpointCard();
2561
+ checkpointGateActive = false;
2562
+ setSendDisabled(false);
2563
+ appendSystemMessage("Cancelled — nothing was built.");
2564
+ flushQueuedMessage();
2565
+ }
2566
+
2567
+ function resolveCheckpoint(action, note, extra) {
2568
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
2569
+ checkpointGateActive = false;
2570
+ // Leave the decision in the transcript (card → one-line record), not an empty
2571
+ // bubble (VIB-1876).
2572
+ finalizeCheckpointCard(action, note, extra);
2573
+ if (action === "cancel") {
2574
+ setSendDisabled(false);
2575
+ } else {
2576
+ // Work resumes. Don't pre-create an empty bubble — clear the prior turn's
2577
+ // pipeline refs so the resume's first progress event builds its OWN stepper
2578
+ // bubble (ensurePipelineBubble returns early while pipelineBubbleEl is set).
2579
+ isStreaming = true;
2580
+ setSendDisabled(true);
2581
+ pipelineBubbleEl = null;
2582
+ pipelineStepperEl = null;
2583
+ pipelineDetailEl = null;
2584
+ pipelineModulesEl = null;
2585
+ pipelineEstimateEl = null;
2586
+ streamingMsgEl = null;
2587
+ streamStartTime = Date.now();
2588
+ setStatus("Generating...");
2589
+ }
2590
+ ws.send(JSON.stringify({
2591
+ type: "checkpoint_resolve",
2592
+ action,
2593
+ note: note || undefined,
2594
+ // Structure checkpoint (VIB-1879) outline; brand-intake channels (VIB-1878).
2595
+ outline: extra && extra.outline ? extra.outline : undefined,
2596
+ brandIntake: extra && extra.brandIntake ? extra.brandIntake : undefined,
2597
+ }));
2598
+ }
2599
+
2600
+ function renderCheckpointCard(preview, data, estCostNext) {
2601
+ // Brand-intake gate (VIB-1878) — the front-of-flow ask-back; different card.
2602
+ if (preview && preview.kind === "brand_intake") {
2603
+ return renderBrandIntakeCard(preview, data);
2604
+ }
2605
+ // Structure checkpoint (VIB-1879) renders an editable module outline instead
2606
+ // of the design palette/hero.
2607
+ if (preview && preview.kind === "structure") {
2608
+ return renderStructureCheckpointCard(preview, data, estCostNext);
2609
+ }
2610
+
2611
+ const div = document.createElement("div");
2612
+ div.className = "chat-msg chat-msg--assistant";
2613
+
2614
+ const palette = Array.isArray(data.palette) ? data.palette : [];
2615
+ const swatches = palette.map((p) =>
2616
+ `<div class="checkpoint__swatch" title="${escapeHtml(p.label)}: ${escapeHtml(p.value)}">
2617
+ <span class="checkpoint__swatch-chip" style="background:${escapeHtml(p.value)}"></span>
2618
+ <span class="checkpoint__swatch-label">${escapeHtml(p.label)}</span>
2619
+ </div>`).join("");
2620
+
2621
+ const typo = data.typography || {};
2622
+ const heroSrc = typeof data.heroHtml === "string" ? data.heroHtml : "";
2623
+ const costLine = (typeof estCostNext === "number" && estCostNext > 0)
2624
+ ? `<span class="checkpoint__cost">Building all modules will cost ~$${estCostNext.toFixed(2)}</span>`
2625
+ : "";
2626
+
2627
+ div.innerHTML = `
2628
+ <div class="chat-msg__avatar chat-msg__avatar--ai">AI</div>
2629
+ <div class="chat-msg__content">
2630
+ <div class="checkpoint">
2631
+ <div class="checkpoint__head">
2632
+ <span class="checkpoint__badge">Design checkpoint</span>
2633
+ <span class="checkpoint__headline">${escapeHtml(preview.headline || "Review the design before building")}</span>
2634
+ </div>
2635
+ <div class="checkpoint__hero">
2636
+ <iframe class="checkpoint__hero-frame" title="Hero preview" sandbox="" srcdoc="${escapeHtml(heroSrc)}"></iframe>
2637
+ </div>
2638
+ <div class="checkpoint__tokens">
2639
+ <div class="checkpoint__palette">${swatches || '<span class="checkpoint__muted">No palette tokens</span>'}</div>
2640
+ <div class="checkpoint__type">
2641
+ <div class="checkpoint__type-row" style="font-family:${escapeHtml(typo.heading || "")}">Aa — ${escapeHtml(typo.heading || "Heading font")}</div>
2642
+ <div class="checkpoint__type-row checkpoint__type-row--body" style="font-family:${escapeHtml(typo.body || "")}">Body — ${escapeHtml(typo.body || "Body font")}</div>
2643
+ </div>
2644
+ </div>
2645
+ ${costLine ? `<div class="checkpoint__meta">${costLine}</div>` : ""}
2646
+ <div class="checkpoint__steer hidden">
2647
+ <textarea class="checkpoint__steer-input" rows="2" placeholder="What should change? e.g. warmer palette, bigger headings..."></textarea>
2648
+ </div>
2649
+ <div class="checkpoint__actions">
2650
+ <button class="btn btn--primary checkpoint__btn" data-action="approve">Looks good — build it</button>
2651
+ <button class="btn checkpoint__btn" data-action="steer">Tweak the design</button>
2652
+ <button class="btn checkpoint__btn" data-action="skip" title="Build now and don't ask again this run">Skip checkpoints</button>
2653
+ <button class="btn btn--ghost checkpoint__btn" data-action="cancel">Cancel</button>
2654
+ </div>
2655
+ </div>
2656
+ </div>`;
2657
+
2658
+ const steerWrap = div.querySelector(".checkpoint__steer");
2659
+ const steerInput = div.querySelector(".checkpoint__steer-input");
2660
+ const steerBtn = div.querySelector('.checkpoint__btn[data-action="steer"]');
2661
+ let steerArmed = false;
2662
+
2663
+ div.querySelectorAll(".checkpoint__btn").forEach((btn) => {
2664
+ btn.addEventListener("click", () => {
2665
+ const action = btn.dataset.action;
2666
+ // Steer is two-step: first click reveals the note box, second submits.
2667
+ if (action === "steer" && !steerArmed) {
2668
+ steerArmed = true;
2669
+ steerWrap.classList.remove("hidden");
2670
+ steerBtn.textContent = "Apply tweak";
2671
+ if (steerInput) steerInput.focus();
2672
+ return;
2673
+ }
2674
+ const note = action === "steer" && steerInput ? steerInput.value.trim() : undefined;
2675
+ div.querySelectorAll(".checkpoint__btn").forEach((b) => (b.disabled = true));
2676
+ resolveCheckpoint(action, note);
2677
+ });
2678
+ });
2679
+
2680
+ return div;
2681
+ }
2682
+
2683
+ // Structure checkpoint card (VIB-1879) — the planned module skeleton as an
2684
+ // editable outline (reorder / cut / add / rename) before the parallel build.
2685
+ function renderStructureCheckpointCard(preview, data, estCostNext) {
2686
+ const div = document.createElement("div");
2687
+ div.className = "chat-msg chat-msg--assistant";
2688
+
2689
+ const modules = Array.isArray(data.modules) ? data.modules : [];
2690
+ const narrative = typeof data.narrative === "string" ? data.narrative : "";
2691
+ const costLine = (typeof estCostNext === "number" && estCostNext > 0)
2692
+ ? `<span class="checkpoint__cost">Building all modules will cost ~$${estCostNext.toFixed(2)}</span>`
2693
+ : "";
2694
+
2695
+ div.innerHTML = `
2696
+ <div class="chat-msg__avatar chat-msg__avatar--ai">AI</div>
2697
+ <div class="chat-msg__content">
2698
+ <div class="checkpoint checkpoint--structure">
2699
+ <div class="checkpoint__head">
2700
+ <span class="checkpoint__badge">Structure checkpoint</span>
2701
+ <span class="checkpoint__headline">${escapeHtml(preview.headline || "Review the structure before building")}</span>
2702
+ </div>
2703
+ ${narrative ? `<div class="checkpoint__narrative">${escapeHtml(narrative)}</div>` : ""}
2704
+ <div class="checkpoint__outline"></div>
2705
+ <button type="button" class="btn btn--ghost checkpoint__add">+ Add section</button>
2706
+ ${costLine ? `<div class="checkpoint__meta">${costLine}</div>` : ""}
2707
+ <div class="checkpoint__steer hidden">
2708
+ <textarea class="checkpoint__steer-input" rows="2" placeholder="What should change? e.g. add a pricing section, drop the FAQ..."></textarea>
2709
+ </div>
2710
+ <div class="checkpoint__actions">
2711
+ <button class="btn btn--primary checkpoint__btn" data-action="approve">Build these sections</button>
2712
+ <button class="btn checkpoint__btn" data-action="steer">Re-plan structure</button>
2713
+ <button class="btn checkpoint__btn" data-action="skip" title="Build now and don't ask again this run">Skip checkpoints</button>
2714
+ <button class="btn btn--ghost checkpoint__btn" data-action="cancel">Cancel</button>
2715
+ </div>
2716
+ </div>
2717
+ </div>`;
2718
+
2719
+ const listEl = div.querySelector(".checkpoint__outline");
2720
+
2721
+ function makeRow(name, description, sourceIndex) {
2722
+ const row = document.createElement("div");
2723
+ row.className = "checkpoint__row";
2724
+ if (sourceIndex != null) row.dataset.sourceIndex = String(sourceIndex);
2725
+ if (description) row.dataset.desc = description;
2726
+ row.innerHTML = `
2727
+ <div class="checkpoint__row-move">
2728
+ <button type="button" class="checkpoint__row-btn" data-move="up" title="Move up" aria-label="Move up">▲</button>
2729
+ <button type="button" class="checkpoint__row-btn" data-move="down" title="Move down" aria-label="Move down">▼</button>
2730
+ </div>
2731
+ <div class="checkpoint__row-main">
2732
+ <input class="checkpoint__row-name" value="${escapeHtml(name || "")}" placeholder="section-name" spellcheck="false" />
2733
+ <span class="checkpoint__row-desc">${escapeHtml(description || "")}</span>
2734
+ </div>
2735
+ <button type="button" class="checkpoint__row-remove" data-remove title="Remove section" aria-label="Remove section">✕</button>`;
2736
+
2737
+ row.querySelector('[data-move="up"]').addEventListener("click", () => {
2738
+ const prev = row.previousElementSibling;
2739
+ if (prev) listEl.insertBefore(row, prev);
2740
+ });
2741
+ row.querySelector('[data-move="down"]').addEventListener("click", () => {
2742
+ const next = row.nextElementSibling;
2743
+ if (next) listEl.insertBefore(next, row);
2744
+ });
2745
+ row.querySelector("[data-remove]").addEventListener("click", () => {
2746
+ row.remove();
2747
+ });
2748
+ return row;
2749
+ }
2750
+
2751
+ modules.forEach((m) =>
2752
+ listEl.appendChild(makeRow(m.name, m.description, m.sourceIndex)),
2753
+ );
2754
+
2755
+ div.querySelector(".checkpoint__add").addEventListener("click", () => {
2756
+ const row = makeRow("", "", null);
2757
+ listEl.appendChild(row);
2758
+ const input = row.querySelector(".checkpoint__row-name");
2759
+ if (input) input.focus();
2760
+ });
2761
+
2762
+ function collectOutline() {
2763
+ return [...listEl.querySelectorAll(".checkpoint__row")]
2764
+ .map((row) => {
2765
+ const name = (row.querySelector(".checkpoint__row-name").value || "").trim();
2766
+ const si = row.dataset.sourceIndex;
2767
+ return {
2768
+ name,
2769
+ description: row.dataset.desc || undefined,
2770
+ sourceIndex: si !== undefined && si !== "" ? Number(si) : undefined,
2771
+ };
2772
+ })
2773
+ .filter((it) => it.name);
2774
+ }
2775
+
2776
+ const steerWrap = div.querySelector(".checkpoint__steer");
2777
+ const steerInput = div.querySelector(".checkpoint__steer-input");
2778
+ const steerBtn = div.querySelector('.checkpoint__btn[data-action="steer"]');
2779
+ let steerArmed = false;
2780
+
2781
+ div.querySelectorAll(".checkpoint__btn").forEach((btn) => {
2782
+ btn.addEventListener("click", () => {
2783
+ const action = btn.dataset.action;
2784
+ // Steer is two-step: first click reveals the note box, second submits.
2785
+ if (action === "steer" && !steerArmed) {
2786
+ steerArmed = true;
2787
+ steerWrap.classList.remove("hidden");
2788
+ steerBtn.textContent = "Apply re-plan";
2789
+ if (steerInput) steerInput.focus();
2790
+ return;
2791
+ }
2792
+ const note = action === "steer" && steerInput ? steerInput.value.trim() : undefined;
2793
+ // approve/skip carry the edited outline; steer/cancel don't.
2794
+ const outline = action === "approve" || action === "skip" ? collectOutline() : undefined;
2795
+ div.querySelectorAll(".checkpoint__btn").forEach((b) => (b.disabled = true));
2796
+ resolveCheckpoint(action, note, { outline });
2797
+ });
2798
+ });
2799
+
2800
+ return div;
2801
+ }
2802
+
2803
+ // ---------------------------------------------------------------------------
2804
+ // Brand-intake gate card (VIB-1878) — the front-of-flow ask-back shown when a
2805
+ // new page has no style system yet: "Surprise me" (AI invents) vs "Bring your
2806
+ // brand" (paste colors/code/voice, or point at a site/theme → seed the design).
2807
+ // ---------------------------------------------------------------------------
2808
+
2809
+ function renderBrandIntakeCard(preview, data) {
2810
+ const div = document.createElement("div");
2811
+ div.className = "chat-msg chat-msg--assistant";
2812
+
2813
+ const channels = Array.isArray(data.channels) ? data.channels : [];
2814
+ const fields = channels.map((c) => {
2815
+ const id = `brand-${escapeHtml(c.key)}`;
2816
+ const input = c.multiline
2817
+ ? `<textarea class="brand-intake__input" data-key="${escapeHtml(c.key)}" rows="3" placeholder="${escapeHtml(c.placeholder || "")}"></textarea>`
2818
+ : `<input class="brand-intake__input" data-key="${escapeHtml(c.key)}" type="text" placeholder="${escapeHtml(c.placeholder || "")}" />`;
2819
+ return `<label class="brand-intake__field" for="${id}">
2820
+ <span class="brand-intake__label">${escapeHtml(c.label || c.key)}</span>
2821
+ ${input}
2822
+ </label>`;
2823
+ }).join("");
2824
+
2825
+ div.innerHTML = `
2826
+ <div class="chat-msg__avatar chat-msg__avatar--ai">AI</div>
2827
+ <div class="chat-msg__content">
2828
+ <div class="checkpoint brand-intake">
2829
+ <div class="checkpoint__head">
2830
+ <span class="checkpoint__badge">Brand checkpoint</span>
2831
+ <span class="checkpoint__headline">${escapeHtml(preview.headline || "Match your brand, or surprise you?")}</span>
2832
+ </div>
2833
+ <div class="brand-intake__choices">
2834
+ <button class="btn btn--primary brand-intake__choice" data-choice="surprise">
2835
+ <span class="brand-intake__choice-title">${escapeHtml(data.surpriseLabel || "Surprise me")}</span>
2836
+ <span class="brand-intake__choice-hint">${escapeHtml(data.surpriseHint || "")}</span>
2837
+ </button>
2838
+ <button class="btn brand-intake__choice" data-choice="bring">
2839
+ <span class="brand-intake__choice-title">${escapeHtml(data.bringLabel || "Bring your brand")}</span>
2840
+ <span class="brand-intake__choice-hint">${escapeHtml(data.bringHint || "")}</span>
2841
+ </button>
2842
+ </div>
2843
+ <div class="brand-intake__form hidden">
2844
+ ${fields}
2845
+ <div class="brand-intake__form-actions">
2846
+ <button class="btn btn--primary brand-intake__submit">Use my brand</button>
2847
+ <button class="btn btn--ghost brand-intake__back">Back</button>
2848
+ </div>
2849
+ </div>
2850
+ <div class="checkpoint__actions brand-intake__top-actions">
2851
+ <button class="btn btn--ghost checkpoint__btn" data-action="cancel">Cancel</button>
2852
+ </div>
2853
+ </div>
2854
+ </div>`;
2855
+
2856
+ const form = div.querySelector(".brand-intake__form");
2857
+ const choices = div.querySelector(".brand-intake__choices");
2858
+
2859
+ div.querySelector('[data-choice="surprise"]').addEventListener("click", () => {
2860
+ div.querySelectorAll("button").forEach((b) => (b.disabled = true));
2861
+ // "Surprise me" = skip the brand step; the AI invents the design system.
2862
+ resolveCheckpoint("skip");
2863
+ });
2864
+
2865
+ div.querySelector('[data-choice="bring"]').addEventListener("click", () => {
2866
+ if (choices) choices.classList.add("hidden");
2867
+ if (form) form.classList.remove("hidden");
2868
+ const first = div.querySelector(".brand-intake__input");
2869
+ if (first) first.focus();
2870
+ });
2871
+
2872
+ const backBtn = div.querySelector(".brand-intake__back");
2873
+ if (backBtn) backBtn.addEventListener("click", () => {
2874
+ if (form) form.classList.add("hidden");
2875
+ if (choices) choices.classList.remove("hidden");
2876
+ });
2877
+
2878
+ const submitBtn = div.querySelector(".brand-intake__submit");
2879
+ if (submitBtn) submitBtn.addEventListener("click", () => {
2880
+ const brandIntake = {};
2881
+ div.querySelectorAll(".brand-intake__input").forEach((el) => {
2882
+ const v = (el.value || "").trim();
2883
+ if (v) brandIntake[el.dataset.key] = v;
2884
+ });
2885
+ if (Object.keys(brandIntake).length === 0) {
2886
+ // Nothing filled in → treat as "surprise me" rather than a no-op.
2887
+ div.querySelectorAll("button").forEach((b) => (b.disabled = true));
2888
+ resolveCheckpoint("skip");
2889
+ return;
2890
+ }
2891
+ div.querySelectorAll("button").forEach((b) => (b.disabled = true));
2892
+ resolveCheckpoint("approve", undefined, { brandIntake });
2893
+ });
2894
+
2895
+ const cancelBtn = div.querySelector('.checkpoint__btn[data-action="cancel"]');
2896
+ if (cancelBtn) cancelBtn.addEventListener("click", () => {
2897
+ div.querySelectorAll("button").forEach((b) => (b.disabled = true));
2898
+ resolveCheckpoint("cancel");
2899
+ });
2900
+
2901
+ return div;
2902
+ }
2903
+
2249
2904
  // ---------------------------------------------------------------------------
2250
2905
  // Version history panel
2251
2906
  // ---------------------------------------------------------------------------
@@ -3137,6 +3792,13 @@ sendBtn.addEventListener("click", () => {
3137
3792
  sendMessage(inputEl.value);
3138
3793
  });
3139
3794
 
3795
+ // One-shot button — build the whole page, skipping the design checkpoint.
3796
+ if (oneShotBtn) {
3797
+ oneShotBtn.addEventListener("click", () => {
3798
+ sendMessage(inputEl.value, { oneShot: true });
3799
+ });
3800
+ }
3801
+
3140
3802
  // Enter to send (Shift+Enter for newline)
3141
3803
  inputEl.addEventListener("keydown", (e) => {
3142
3804
  if (e.key === "Enter" && !e.shiftKey) {