vibespot 1.1.1 → 1.3.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 (56) hide show
  1. package/LICENSE +103 -33
  2. package/README.md +55 -6
  3. package/assets/blog-rules.md +251 -0
  4. package/assets/email-rules.md +390 -0
  5. package/assets/humanify-guide.md +300 -101
  6. package/assets/plan-templates/agency-services.md +42 -0
  7. package/assets/plan-templates/blog-content-hub.md +50 -0
  8. package/assets/plan-templates/ecommerce-product.md +42 -0
  9. package/assets/plan-templates/email-announcement.md +41 -0
  10. package/assets/plan-templates/email-event-invite.md +43 -0
  11. package/assets/plan-templates/email-newsletter.md +41 -0
  12. package/assets/plan-templates/email-re-engagement.md +42 -0
  13. package/assets/plan-templates/email-welcome.md +41 -0
  14. package/assets/plan-templates/event-registration.md +42 -0
  15. package/assets/plan-templates/portfolio.md +41 -0
  16. package/assets/plan-templates/restaurant.md +42 -0
  17. package/assets/plan-templates/saas-landing.md +42 -0
  18. package/dist/index.js +1485 -397
  19. package/dist/index.js.map +1 -1
  20. package/package.json +11 -7
  21. package/starters/01-saas-landing.json +43 -0
  22. package/starters/02-portfolio.json +39 -0
  23. package/starters/03-restaurant.json +39 -0
  24. package/starters/04-event.json +39 -0
  25. package/starters/05-coming-soon.json +32 -0
  26. package/starters/06-blog-content-hub.json +75 -0
  27. package/starters/06-email-welcome.json +60 -0
  28. package/starters/07-email-announcement.json +60 -0
  29. package/starters/08-email-newsletter.json +52 -0
  30. package/ui/chat.js +1604 -155
  31. package/ui/code-editor.js +49 -7
  32. package/ui/dashboard.js +551 -83
  33. package/ui/docs/docs.css +29 -0
  34. package/ui/docs/index.html +274 -117
  35. package/ui/docs/screenshots/brand-kit-preview.png +0 -0
  36. package/ui/docs/screenshots/content-type-dropdown.png +0 -0
  37. package/ui/docs/screenshots/editor-full-layout.png +0 -0
  38. package/ui/docs/screenshots/inline-wysiwyg-editing.png +0 -0
  39. package/ui/docs/screenshots/multi-page-tree.png +0 -0
  40. package/ui/docs/screenshots/onboarding-walkthrough.png +0 -0
  41. package/ui/docs/screenshots/split-pane-view.png +0 -0
  42. package/ui/docs/screenshots/visual-controls-toolbar.png +0 -0
  43. package/ui/docs/screenshots/workspace-tabs.png +0 -0
  44. package/ui/email-preview.js +109 -0
  45. package/ui/field-editor.js +71 -0
  46. package/ui/icons.js +120 -0
  47. package/ui/index.html +882 -515
  48. package/ui/inline-edit.js +710 -0
  49. package/ui/marketplace.js +218 -0
  50. package/ui/plan.js +0 -0
  51. package/ui/preview.js +219 -1
  52. package/ui/section-controls.js +628 -0
  53. package/ui/settings.js +84 -28
  54. package/ui/setup.js +1016 -118
  55. package/ui/styles.css +6119 -2456
  56. package/ui/upload-panel.js +47 -20
package/ui/chat.js CHANGED
@@ -18,11 +18,506 @@ let currentTemplateId = "";
18
18
  let renderScheduled = false;
19
19
  let scrollScheduled = false;
20
20
 
21
+ // Change tracking for the in-flight generation. `modulesBeforeRun` snapshots
22
+ // the module list when the user submits a prompt so we can tell which modules
23
+ // are brand new when the run finishes. `changedModulesInRun` accumulates names
24
+ // from per-module `module_progress` events. The flag is set on
25
+ // `generation_complete` so the *next* `modules_updated` (the post-run one)
26
+ // triggers the highlight pass instead of a plain refresh.
27
+ let modulesBeforeRun = null;
28
+ let changedModulesInRun = new Set();
29
+ let highlightOnNextModulesUpdated = false;
30
+ let changedListClearTimer = null;
31
+
21
32
  const messagesEl = document.getElementById("chat-messages");
22
33
  const inputEl = document.getElementById("chat-input");
23
34
  const sendBtn = document.getElementById("chat-send");
24
35
  const statusText = document.getElementById("status-text");
25
36
  const statusEngine = document.getElementById("status-engine");
37
+ const statusTheme = document.getElementById("status-theme");
38
+ const statusLastSaved = document.getElementById("status-last-saved");
39
+
40
+ let lastSavedAt = 0;
41
+
42
+ // Snapshot the welcome section before any init can destroy it
43
+ const _welcomeHtml = document.getElementById("chat-welcome")?.outerHTML || "";
44
+
45
+ function restoreWelcome() {
46
+ if (!_welcomeHtml || messagesEl.querySelector(".chat__welcome")) return;
47
+ messagesEl.insertAdjacentHTML("afterbegin", _welcomeHtml);
48
+ const el = messagesEl.querySelector("#conversation-starters");
49
+ if (el) el.addEventListener("click", handleConversationStarterClick);
50
+ }
51
+
52
+ function handleConversationStarterClick(e) {
53
+ const btn = e.target.closest(".starter-btn");
54
+ if (!btn) return;
55
+ const action = btn.dataset.action;
56
+ if (action === "describe") {
57
+ inputEl?.focus();
58
+ } else if (action === "figma") {
59
+ document.getElementById("btn-attach-file")?.click();
60
+ } else if (action === "hubspot-import") {
61
+ if (inputEl) {
62
+ inputEl.value = "Import this HubSpot page and adapt it: ";
63
+ inputEl.style.height = "auto";
64
+ inputEl.style.height = Math.min(inputEl.scrollHeight, 150) + "px";
65
+ inputEl.focus();
66
+ const end = inputEl.value.length;
67
+ inputEl.setSelectionRange(end, end);
68
+ }
69
+ }
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Page tabs (multi-page template switcher)
74
+ // ---------------------------------------------------------------------------
75
+
76
+ let currentTemplates = [];
77
+ let pageDragSourceId = null;
78
+
79
+ const PAGE_TYPE_BADGES = {
80
+ landing_page: "LP",
81
+ blog_post: "Blog",
82
+ website_page: "Web",
83
+ email: "Email",
84
+ module_only: "Sec",
85
+ };
86
+
87
+ // Inline SVG icons keyed by page type. Stroke-only so they inherit currentColor.
88
+ const PAGE_TYPE_ICONS = {
89
+ landing_page: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="2"/><line x1="4" y1="9" x2="20" y2="9"/><line x1="9" y1="13" x2="15" y2="13"/></svg>',
90
+ website_page: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><circle cx="6" cy="7" r="0.6" fill="currentColor"/><circle cx="8.4" cy="7" r="0.6" fill="currentColor"/></svg>',
91
+ blog_post: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 4h11l3 3v13H5z"/><line x1="8" y1="11" x2="16" y2="11"/><line x1="8" y1="15" x2="14" y2="15"/></svg>',
92
+ email: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><polyline points="3,7 12,13 21,7"/></svg>',
93
+ module_only: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="6" width="16" height="4" rx="1"/><rect x="4" y="14" width="10" height="4" rx="1"/></svg>',
94
+ };
95
+
96
+ const PAGE_TYPE_LABEL_LONG = {
97
+ landing_page: "Landing page",
98
+ website_page: "Website page",
99
+ blog_post: "Blog post",
100
+ email: "Email",
101
+ module_only: "Section",
102
+ };
103
+
104
+ function renderPageTabs(templates, activeTemplateId) {
105
+ const list = document.getElementById("page-tabs-list");
106
+ if (!list) return;
107
+
108
+ currentTemplates = templates || [];
109
+
110
+ list.innerHTML = currentTemplates.map((t) => {
111
+ const isActive = t.id === activeTemplateId;
112
+ const badge = PAGE_TYPE_BADGES[t.pageType] || "";
113
+ const icon = PAGE_TYPE_ICONS[t.pageType] || PAGE_TYPE_ICONS.website_page;
114
+ const typeLabel = PAGE_TYPE_LABEL_LONG[t.pageType] || t.pageType;
115
+ const titleAttr = `${t.label} — ${typeLabel} (${t.moduleCount} modules)`;
116
+ return `<div class="page-tabs__row${isActive ? " page-tabs__row--active" : ""}" role="treeitem" aria-selected="${isActive ? "true" : "false"}" data-template-id="${t.id}" draggable="true" title="${escapeHtml(titleAttr)}">
117
+ <span class="page-tabs__handle" aria-hidden="true" title="Drag to reorder"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="18" r="1"/></svg></span>
118
+ <button class="page-tabs__tab" type="button" data-template-id="${t.id}" data-action="activate">
119
+ <span class="page-tabs__icon" aria-hidden="true">${icon}</span>
120
+ ${badge ? `<span class="page-tabs__tab-badge">${badge}</span>` : ""}
121
+ <span class="page-tabs__tab-label">${escapeHtml(t.label)}</span>
122
+ <span class="page-tabs__tab-count">${t.moduleCount}</span>
123
+ </button>
124
+ <button class="page-tabs__menu-btn" type="button" data-template-id="${t.id}" data-action="menu" title="Page actions" aria-label="Page actions" aria-haspopup="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="6" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="18" r="1"/></svg></button>
125
+ </div>`;
126
+ }).join("");
127
+ }
128
+
129
+ function refreshPageTabs() {
130
+ return fetch("/api/templates")
131
+ .then((res) => (res.ok ? res.json() : null))
132
+ .then((data) => {
133
+ if (!data) return;
134
+ currentTemplateId = data.activeTemplateId || currentTemplateId;
135
+ renderPageTabs(data.templates || [], currentTemplateId);
136
+ })
137
+ .catch(() => {});
138
+ }
139
+
140
+ function handlePageTabClick(e) {
141
+ const menuBtn = e.target.closest(".page-tabs__menu-btn");
142
+ if (menuBtn) {
143
+ e.preventDefault();
144
+ e.stopPropagation();
145
+ openPageContextMenu(menuBtn.dataset.templateId, menuBtn);
146
+ return;
147
+ }
148
+ const tab = e.target.closest(".page-tabs__tab");
149
+ if (!tab) return;
150
+ const templateId = tab.dataset.templateId;
151
+ if (!templateId || templateId === currentTemplateId) return;
152
+ if (isStreaming) return;
153
+
154
+ tab.style.opacity = "0.5";
155
+ fetch("/api/templates/activate", {
156
+ method: "POST",
157
+ headers: { "Content-Type": "application/json" },
158
+ body: JSON.stringify({ templateId }),
159
+ }).then((res) => {
160
+ if (!res.ok) {
161
+ tab.style.opacity = "";
162
+ return;
163
+ }
164
+ connectWebSocket();
165
+ }).catch(() => {
166
+ tab.style.opacity = "";
167
+ });
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Page tree context menu (right-click / kebab) — Edit, Duplicate, Delete, Move
172
+ // ---------------------------------------------------------------------------
173
+
174
+ function handlePageTabContextMenu(e) {
175
+ const row = e.target.closest(".page-tabs__row");
176
+ if (!row) return;
177
+ e.preventDefault();
178
+ openPageContextMenu(row.dataset.templateId, e);
179
+ }
180
+
181
+ function openPageContextMenu(templateId, anchor) {
182
+ closePageContextMenu();
183
+ if (!templateId) return;
184
+ const template = currentTemplates.find((t) => t.id === templateId);
185
+ if (!template) return;
186
+
187
+ const idx = currentTemplates.findIndex((t) => t.id === templateId);
188
+ const canMoveUp = idx > 0;
189
+ const canMoveDown = idx >= 0 && idx < currentTemplates.length - 1;
190
+
191
+ const menu = document.createElement("div");
192
+ menu.className = "page-tree__context-menu";
193
+ menu.id = "page-context-menu";
194
+ menu.setAttribute("role", "menu");
195
+ menu.innerHTML = [
196
+ `<button type="button" role="menuitem" data-action="edit">Edit name…</button>`,
197
+ `<button type="button" role="menuitem" data-action="duplicate">Duplicate</button>`,
198
+ `<div class="page-tree__context-sep" role="separator"></div>`,
199
+ `<button type="button" role="menuitem" data-action="move-up"${canMoveUp ? "" : " disabled"}>Move up</button>`,
200
+ `<button type="button" role="menuitem" data-action="move-down"${canMoveDown ? "" : " disabled"}>Move down</button>`,
201
+ `<div class="page-tree__context-sep" role="separator"></div>`,
202
+ `<button type="button" role="menuitem" data-action="delete" class="page-tree__context-menu-danger">Delete…</button>`,
203
+ ].join("");
204
+ document.body.appendChild(menu);
205
+
206
+ // Position the menu. Anchor can be a MouseEvent or an HTMLElement.
207
+ let x = 0;
208
+ let y = 0;
209
+ if (anchor instanceof Event) {
210
+ x = anchor.clientX;
211
+ y = anchor.clientY;
212
+ } else if (anchor && anchor.getBoundingClientRect) {
213
+ const r = anchor.getBoundingClientRect();
214
+ x = r.right;
215
+ y = r.bottom;
216
+ }
217
+ // Constrain to viewport.
218
+ const menuRect = menu.getBoundingClientRect();
219
+ if (x + menuRect.width > window.innerWidth - 8) x = Math.max(8, window.innerWidth - menuRect.width - 8);
220
+ if (y + menuRect.height > window.innerHeight - 8) y = Math.max(8, window.innerHeight - menuRect.height - 8);
221
+ menu.style.left = `${x}px`;
222
+ menu.style.top = `${y}px`;
223
+
224
+ menu.addEventListener("click", (e) => {
225
+ const btn = e.target.closest("button[data-action]");
226
+ if (!btn || btn.disabled) return;
227
+ const action = btn.dataset.action;
228
+ closePageContextMenu();
229
+ runPageAction(templateId, action);
230
+ });
231
+
232
+ // Dismiss on outside click / scroll / Escape.
233
+ setTimeout(() => {
234
+ document.addEventListener("click", closePageContextMenu, { once: true });
235
+ document.addEventListener("contextmenu", closePageContextMenu, { once: true });
236
+ window.addEventListener("scroll", closePageContextMenu, { capture: true, once: true });
237
+ }, 0);
238
+ }
239
+
240
+ function closePageContextMenu() {
241
+ const menu = document.getElementById("page-context-menu");
242
+ if (menu) menu.remove();
243
+ }
244
+
245
+ document.addEventListener("keydown", (e) => {
246
+ if (e.key === "Escape") closePageContextMenu();
247
+ });
248
+
249
+ function runPageAction(templateId, action) {
250
+ if (isStreaming) return;
251
+ switch (action) {
252
+ case "edit": return renamePageAction(templateId);
253
+ case "duplicate": return duplicatePageAction(templateId);
254
+ case "delete": return deletePageAction(templateId);
255
+ case "move-up": return movePageAction(templateId, -1);
256
+ case "move-down": return movePageAction(templateId, 1);
257
+ }
258
+ }
259
+
260
+ function renamePageAction(templateId) {
261
+ const tpl = currentTemplates.find((t) => t.id === templateId);
262
+ if (!tpl) return;
263
+ // Inline rename: turn the label cell into a contenteditable.
264
+ const row = document.querySelector(`.page-tabs__row[data-template-id="${CSS.escape(templateId)}"]`);
265
+ const labelEl = row && row.querySelector(".page-tabs__tab-label");
266
+ if (!labelEl) {
267
+ // Fallback: native prompt.
268
+ const next = prompt("Rename page", tpl.label);
269
+ if (!next || next.trim() === tpl.label) return;
270
+ sendRename(templateId, next.trim());
271
+ return;
272
+ }
273
+ const oldLabel = tpl.label;
274
+ labelEl.contentEditable = "true";
275
+ labelEl.classList.add("page-tabs__tab-label--editing");
276
+ labelEl.focus();
277
+ const range = document.createRange();
278
+ range.selectNodeContents(labelEl);
279
+ const sel = window.getSelection();
280
+ sel.removeAllRanges();
281
+ sel.addRange(range);
282
+
283
+ let committed = false;
284
+ function commit() {
285
+ if (committed) return;
286
+ committed = true;
287
+ labelEl.contentEditable = "false";
288
+ labelEl.classList.remove("page-tabs__tab-label--editing");
289
+ const next = labelEl.textContent.trim();
290
+ if (!next || next === oldLabel) {
291
+ labelEl.textContent = oldLabel;
292
+ return;
293
+ }
294
+ sendRename(templateId, next);
295
+ }
296
+ labelEl.addEventListener("blur", commit, { once: true });
297
+ labelEl.addEventListener("keydown", function handler(e) {
298
+ if (e.key === "Enter") {
299
+ e.preventDefault();
300
+ labelEl.removeEventListener("keydown", handler);
301
+ commit();
302
+ } else if (e.key === "Escape") {
303
+ labelEl.removeEventListener("keydown", handler);
304
+ committed = true;
305
+ labelEl.textContent = oldLabel;
306
+ labelEl.contentEditable = "false";
307
+ labelEl.classList.remove("page-tabs__tab-label--editing");
308
+ }
309
+ });
310
+ }
311
+
312
+ function sendRename(templateId, newLabel) {
313
+ fetch("/api/templates/rename", {
314
+ method: "POST",
315
+ headers: { "Content-Type": "application/json" },
316
+ body: JSON.stringify({ templateId, newLabel }),
317
+ }).then((r) => r.json()).then((data) => {
318
+ if (data && data.ok) refreshPageTabs();
319
+ }).catch(() => {});
320
+ }
321
+
322
+ function duplicatePageAction(templateId) {
323
+ const tpl = currentTemplates.find((t) => t.id === templateId);
324
+ if (!tpl) return;
325
+ const label = prompt("Name for the duplicated page:", `${tpl.label} (Copy)`);
326
+ if (!label || !label.trim()) return;
327
+ fetch("/api/templates/clone", {
328
+ method: "POST",
329
+ headers: { "Content-Type": "application/json" },
330
+ body: JSON.stringify({ templateId, label: label.trim() }),
331
+ }).then((r) => r.json()).then((data) => {
332
+ if (data && data.ok && data.template && data.template.id) {
333
+ // Activate the clone so the user lands on it (mirrors createPage flow).
334
+ return fetch("/api/templates/activate", {
335
+ method: "POST",
336
+ headers: { "Content-Type": "application/json" },
337
+ body: JSON.stringify({ templateId: data.template.id }),
338
+ }).then(() => { refreshPageTabs(); connectWebSocket(); });
339
+ }
340
+ }).catch(() => {});
341
+ }
342
+
343
+ function deletePageAction(templateId) {
344
+ const tpl = currentTemplates.find((t) => t.id === templateId);
345
+ if (!tpl) return;
346
+ const msg = `Delete "${tpl.label}"? This removes the page and its template file. Sections (modules) are kept in the library.`;
347
+ if (!confirm(msg)) return;
348
+ fetch("/api/templates", {
349
+ method: "DELETE",
350
+ headers: { "Content-Type": "application/json" },
351
+ body: JSON.stringify({ templateId, deleteModules: false }),
352
+ }).then((r) => r.json()).then((data) => {
353
+ if (data && data.ok) {
354
+ refreshPageTabs();
355
+ // If we deleted the active page, the server picks a new active — reconnect.
356
+ if (templateId === currentTemplateId) connectWebSocket();
357
+ }
358
+ }).catch(() => {});
359
+ }
360
+
361
+ function movePageAction(templateId, delta) {
362
+ const ids = currentTemplates.map((t) => t.id);
363
+ const idx = ids.indexOf(templateId);
364
+ if (idx < 0) return;
365
+ const target = idx + delta;
366
+ if (target < 0 || target >= ids.length) return;
367
+ ids.splice(idx, 1);
368
+ ids.splice(target, 0, templateId);
369
+ sendReorder(ids);
370
+ }
371
+
372
+ function sendReorder(templateIds) {
373
+ // Optimistic re-render so the move feels instant.
374
+ const map = new Map(currentTemplates.map((t) => [t.id, t]));
375
+ const next = templateIds.map((id) => map.get(id)).filter(Boolean);
376
+ if (next.length === currentTemplates.length) {
377
+ renderPageTabs(next, currentTemplateId);
378
+ }
379
+ fetch("/api/templates/reorder", {
380
+ method: "POST",
381
+ headers: { "Content-Type": "application/json" },
382
+ body: JSON.stringify({ templateIds }),
383
+ }).then((r) => r.json()).then((data) => {
384
+ if (!data || !data.ok) refreshPageTabs();
385
+ }).catch(() => refreshPageTabs());
386
+ }
387
+
388
+ // ---------------------------------------------------------------------------
389
+ // Drag and drop reordering (HTML5)
390
+ // ---------------------------------------------------------------------------
391
+
392
+ function handlePageTabDragStart(e) {
393
+ const row = e.target.closest(".page-tabs__row");
394
+ if (!row) return;
395
+ pageDragSourceId = row.dataset.templateId || null;
396
+ row.classList.add("page-tabs__row--dragging");
397
+ if (e.dataTransfer) {
398
+ e.dataTransfer.effectAllowed = "move";
399
+ // Some browsers need data set to start the drag.
400
+ try { e.dataTransfer.setData("text/plain", pageDragSourceId || ""); } catch (_) {}
401
+ }
402
+ }
403
+
404
+ function handlePageTabDragEnd(e) {
405
+ const row = e.target.closest(".page-tabs__row");
406
+ if (row) row.classList.remove("page-tabs__row--dragging");
407
+ pageDragSourceId = null;
408
+ document.querySelectorAll(".page-tabs__row--drop-before, .page-tabs__row--drop-after")
409
+ .forEach((el) => el.classList.remove("page-tabs__row--drop-before", "page-tabs__row--drop-after"));
410
+ }
411
+
412
+ function handlePageTabDragOver(e) {
413
+ if (!pageDragSourceId) return;
414
+ const row = e.target.closest(".page-tabs__row");
415
+ if (!row || row.dataset.templateId === pageDragSourceId) return;
416
+ e.preventDefault();
417
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
418
+ const rect = row.getBoundingClientRect();
419
+ const before = e.clientY - rect.top < rect.height / 2;
420
+ document.querySelectorAll(".page-tabs__row--drop-before, .page-tabs__row--drop-after")
421
+ .forEach((el) => el.classList.remove("page-tabs__row--drop-before", "page-tabs__row--drop-after"));
422
+ row.classList.add(before ? "page-tabs__row--drop-before" : "page-tabs__row--drop-after");
423
+ }
424
+
425
+ function handlePageTabDrop(e) {
426
+ if (!pageDragSourceId) return;
427
+ const row = e.target.closest(".page-tabs__row");
428
+ if (!row) return;
429
+ const targetId = row.dataset.templateId;
430
+ if (!targetId || targetId === pageDragSourceId) return;
431
+ e.preventDefault();
432
+ const ids = currentTemplates.map((t) => t.id);
433
+ const from = ids.indexOf(pageDragSourceId);
434
+ const to = ids.indexOf(targetId);
435
+ if (from < 0 || to < 0) return;
436
+ const rect = row.getBoundingClientRect();
437
+ const before = e.clientY - rect.top < rect.height / 2;
438
+ ids.splice(from, 1);
439
+ // After removing the source, the target index may have shifted by one.
440
+ const adjustedTo = to > from ? to - 1 : to;
441
+ const insertAt = before ? adjustedTo : adjustedTo + 1;
442
+ ids.splice(insertAt, 0, pageDragSourceId);
443
+ sendReorder(ids);
444
+ }
445
+
446
+ function handleAddPage() {
447
+ if (isStreaming) return;
448
+ const inlineForm = document.getElementById("page-tree-add-inline");
449
+ if (inlineForm) {
450
+ inlineForm.classList.toggle("hidden");
451
+ if (!inlineForm.classList.contains("hidden")) {
452
+ document.getElementById("page-tree-name")?.focus();
453
+ }
454
+ return;
455
+ }
456
+ const label = prompt("Page name:");
457
+ if (!label || !label.trim()) return;
458
+ createPage("website_page", label.trim());
459
+ }
460
+
461
+ function handleInlineCreate() {
462
+ if (isStreaming) return;
463
+ const nameInput = document.getElementById("page-tree-name");
464
+ const typeSelect = document.getElementById("page-tree-type");
465
+ const label = nameInput?.value?.trim();
466
+ if (!label) { nameInput?.focus(); return; }
467
+ const pageType = typeSelect?.value || "website_page";
468
+ createPage(pageType, label);
469
+ nameInput.value = "";
470
+ document.getElementById("page-tree-add-inline")?.classList.add("hidden");
471
+ }
472
+
473
+ function createPage(pageType, label) {
474
+ fetch("/api/templates", {
475
+ method: "POST",
476
+ headers: { "Content-Type": "application/json" },
477
+ body: JSON.stringify({ pageType, label }),
478
+ }).then((res) => {
479
+ if (!res.ok) return null;
480
+ return res.json();
481
+ }).then((data) => {
482
+ const newId = data && data.template && data.template.id;
483
+ if (!newId) return;
484
+ // Server's addTemplate already marks the new template as active, but we
485
+ // call activate to sync the flat-field cache and let the WS reconnect
486
+ // re-broadcast the full session (modules + templates + active id).
487
+ return fetch("/api/templates/activate", {
488
+ method: "POST",
489
+ headers: { "Content-Type": "application/json" },
490
+ body: JSON.stringify({ templateId: newId }),
491
+ }).then((res) => {
492
+ if (!res || !res.ok) return;
493
+ // Re-render the tree immediately from the canonical list so the new
494
+ // page shows up even before the WS init message arrives.
495
+ refreshPageTabs();
496
+ connectWebSocket();
497
+ });
498
+ });
499
+ }
500
+
501
+ (function initPageTree() {
502
+ const list = document.getElementById("page-tabs-list");
503
+ const addBtn = document.getElementById("btn-add-page");
504
+ const createBtn = document.getElementById("page-tree-create");
505
+ const nameInput = document.getElementById("page-tree-name");
506
+ if (list) {
507
+ list.addEventListener("click", handlePageTabClick);
508
+ list.addEventListener("contextmenu", handlePageTabContextMenu);
509
+ list.addEventListener("dragstart", handlePageTabDragStart);
510
+ list.addEventListener("dragend", handlePageTabDragEnd);
511
+ list.addEventListener("dragover", handlePageTabDragOver);
512
+ list.addEventListener("drop", handlePageTabDrop);
513
+ }
514
+ if (addBtn) addBtn.addEventListener("click", handleAddPage);
515
+ if (createBtn) createBtn.addEventListener("click", handleInlineCreate);
516
+ if (nameInput) nameInput.addEventListener("keydown", (e) => {
517
+ if (e.key === "Enter") handleInlineCreate();
518
+ if (e.key === "Escape") document.getElementById("page-tree-add-inline")?.classList.add("hidden");
519
+ });
520
+ })();
26
521
 
27
522
  // ---------------------------------------------------------------------------
28
523
  // WebSocket connection
@@ -77,18 +572,32 @@ function handleWsMessage(msg) {
77
572
  currentSessionId = msg.sessionId || "";
78
573
  currentTemplateId = msg.templateId || "";
79
574
  document.getElementById("theme-name").textContent = msg.themeName || "—";
575
+ setStatusTheme(msg.themeName);
576
+ lastSavedAt = typeof msg.updatedAt === "number" ? msg.updatedAt : 0;
577
+ renderLastSaved();
578
+ if (msg.engine) statusEngine.textContent = msg.engine;
80
579
 
81
580
  // Clear previous project's chat and module list
82
581
  messagesEl.innerHTML = "";
83
- document.getElementById("module-items").innerHTML = "";
84
- document.getElementById("module-count").textContent = "0";
582
+ const _mi = document.getElementById("module-items"); if (_mi) _mi.innerHTML = "";
583
+ const _mc = document.getElementById("module-count") || document.getElementById("slideout-module-count"); if (_mc) _mc.textContent = "0";
584
+ hideChatSuggestions();
585
+ // Reset pipeline state — DOM nodes were detached by innerHTML clear
586
+ resetPipelineState();
587
+ stopPipelineTimer();
588
+ streamingMsgEl = null;
85
589
 
86
590
  if (msg.modules && msg.modules.length > 0) {
87
591
  updateModuleList(msg.modules);
88
592
  refreshPreview();
89
593
  }
594
+
595
+ // Render page tabs for multi-page projects
596
+ renderPageTabs(msg.templates, currentTemplateId);
597
+
90
598
  statusEngine.textContent = msg.engine || "";
91
599
  fetchHsAccountStatus();
600
+ if (typeof refreshPortalIndicator === "function") refreshPortalIndicator();
92
601
 
93
602
  // Populate chat header
94
603
  const chatHeaderTitle = document.getElementById("chat-header-title");
@@ -106,6 +615,8 @@ function handleWsMessage(msg) {
106
615
  }
107
616
  }
108
617
  scrollToBottom();
618
+ } else {
619
+ restoreWelcome();
109
620
  }
110
621
 
111
622
  // Show/hide version history button
@@ -114,10 +625,47 @@ function handleWsMessage(msg) {
114
625
  historyBtn.style.display = msg.gitAvailable ? "" : "none";
115
626
  }
116
627
 
628
+ // Initialize history timeline (compact strip above chat input)
629
+ historyGitAvailable = !!msg.gitAvailable;
630
+ if (historyGitAvailable) {
631
+ historyTimelineCursor = 0;
632
+ refreshHistoryTimeline();
633
+ } else {
634
+ const tl = document.getElementById("history-timeline");
635
+ if (tl) tl.classList.add("hidden");
636
+ }
637
+
117
638
  // Hydrate plan-mode state (toggle + Plan pane content)
118
639
  if (window.planController) {
119
640
  window.planController.setInitialState(msg);
120
641
  }
642
+
643
+ // If a pipeline is actively running (reconnect scenario), lock the
644
+ // input so the user can't double-submit. Replayed pipeline events
645
+ // that follow this init message will rebuild the progress UI.
646
+ if (msg.isGenerating) {
647
+ isStreaming = true;
648
+ sendBtn.disabled = true;
649
+ streamStartTime = Date.now();
650
+ if (typeof window.setSelectModeDisabled === "function") {
651
+ window.setSelectModeDisabled(true);
652
+ }
653
+ if (typeof window.setEditModeDisabled === "function") {
654
+ window.setEditModeDisabled(true);
655
+ }
656
+ }
657
+
658
+ // If setup handed us an initial prompt (describe-it path), send it now
659
+ // that the session is live. Skip if the project already has history
660
+ // (e.g. resumed session) to avoid double-submitting.
661
+ if (window.__pendingInitialPrompt) {
662
+ const pendingPrompt = window.__pendingInitialPrompt;
663
+ window.__pendingInitialPrompt = null;
664
+ const hasHistory = msg.messages && msg.messages.length > 0;
665
+ if (!hasHistory) {
666
+ setTimeout(() => sendMessage(pendingPrompt), 50);
667
+ }
668
+ }
121
669
  break;
122
670
 
123
671
  case "stream":
@@ -131,17 +679,36 @@ function handleWsMessage(msg) {
131
679
  case "generation_complete":
132
680
  clearStreamStatus();
133
681
  finishStreaming();
682
+ // The next `modules_updated` is the terminal one for this run — let it
683
+ // fire the change-highlight pass on the preview iframe.
684
+ highlightOnNextModulesUpdated = true;
685
+ // Refresh the Library tab's module list so it stays in sync
686
+ if (typeof refreshDashboard === "function") refreshDashboard();
134
687
  break;
135
688
 
136
689
  case "modules_updated":
137
690
  if (msg.modules) {
138
691
  updateModuleList(msg.modules);
139
692
  }
140
- refreshPreview();
693
+ if (msg.templates) {
694
+ if (msg.templateId) currentTemplateId = msg.templateId;
695
+ renderPageTabs(msg.templates, currentTemplateId);
696
+ }
697
+ if (highlightOnNextModulesUpdated) {
698
+ highlightOnNextModulesUpdated = false;
699
+ flushChangeHighlights(msg.modules || []);
700
+ } else {
701
+ refreshPreview();
702
+ }
703
+ markSaved(msg.updatedAt);
141
704
  break;
142
705
 
143
706
  case "version_created":
144
707
  if (historyPanelOpen) refreshHistoryPanel();
708
+ // New generation: reset timeline cursor to head and refresh.
709
+ historyTimelineCursor = 0;
710
+ refreshHistoryTimeline();
711
+ markSaved(msg.updatedAt);
145
712
  break;
146
713
 
147
714
  case "parse_warning":
@@ -150,11 +717,10 @@ function handleWsMessage(msg) {
150
717
 
151
718
  case "error":
152
719
  stopPipelineTimer();
720
+ stopPipelineEstimate();
153
721
  if (typeof clearAllModulesWorking === "function") clearAllModulesWorking();
154
722
  finishStreaming();
155
- pipelineBubbleEl = null;
156
- pipelineStepsEl = null;
157
- pipelineModulesEl = null;
723
+ resetPipelineState();
158
724
  appendAssistantError(msg.message);
159
725
  setStatus("Error");
160
726
  break;
@@ -202,8 +768,8 @@ function handleWsMessage(msg) {
202
768
  case "needs_setup":
203
769
  // Clear stale UI if shown
204
770
  messagesEl.innerHTML = "";
205
- document.getElementById("module-items").innerHTML = "";
206
- document.getElementById("module-count").textContent = "0";
771
+ { const _mi = document.getElementById("module-items"); if (_mi) _mi.innerHTML = ""; }
772
+ { const _mc = document.getElementById("module-count") || document.getElementById("slideout-module-count"); if (_mc) _mc.textContent = "0"; }
207
773
  break;
208
774
 
209
775
  case "plan_updated":
@@ -246,14 +812,73 @@ function handleWsMessage(msg) {
246
812
  const STEP_LABELS = {
247
813
  analyzing: "Analyzing",
248
814
  designing: "Designing",
249
- developing: "Developing",
815
+ developing: "Building",
250
816
  quality_check: "Quality Check",
251
817
  };
252
818
  const STEP_ORDER = ["analyzing", "designing", "developing", "quality_check"];
253
819
 
820
+ const STEP_ICONS = {
821
+ analyzing:
822
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>',
823
+ designing:
824
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2a10 10 0 1 0 0 20c1.4 0 2-.9 2-2 0-.5-.2-1-.5-1.4a1.5 1.5 0 0 1 1.2-2.4H17a5 5 0 0 0 5-5c0-5.5-4.5-9.2-10-9.2z"/><circle cx="7.5" cy="10.5" r="1.4"/><circle cx="12" cy="7.5" r="1.4"/><circle cx="16.5" cy="10.5" r="1.4"/></svg>',
825
+ developing:
826
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>',
827
+ quality_check:
828
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2 4 6v6c0 5 3.5 9.4 8 10 4.5-.6 8-5 8-10V6l-8-4z"/></svg>',
829
+ };
830
+
831
+ const CHECK_ICON =
832
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="5 13 10 18 20 7"/></svg>';
833
+
834
+ // Per-module wall-clock seconds. Rough heuristic for time-remaining display.
835
+ const ESTIMATED_SECONDS_PER_MODULE = 14;
836
+
254
837
  let pipelineBubbleEl = null;
255
- let pipelineStepsEl = null;
838
+ let pipelineStepperEl = null;
839
+ let pipelineDetailEl = null;
256
840
  let pipelineModulesEl = null;
841
+ let pipelineEstimateEl = null;
842
+ let pipelineCurrentStep = null;
843
+ let pipelineEstimateInterval = null;
844
+
845
+ function buildStepperHtml() {
846
+ const parts = [];
847
+ for (let i = 0; i < STEP_ORDER.length; i++) {
848
+ const step = STEP_ORDER[i];
849
+ parts.push(`
850
+ <div class="pipeline-stage pipeline-stage--pending" data-stage="${step}">
851
+ <div class="pipeline-stage__indicator">
852
+ <span class="pipeline-stage__icon">${STEP_ICONS[step]}</span>
853
+ <span class="pipeline-stage__check">${CHECK_ICON}</span>
854
+ </div>
855
+ <div class="pipeline-stage__label">${STEP_LABELS[step]}</div>
856
+ </div>`);
857
+ if (i < STEP_ORDER.length - 1) {
858
+ parts.push('<div class="pipeline-stage__connector" data-after="' + step + '"></div>');
859
+ }
860
+ }
861
+ return `<div class="pipeline-stepper" role="progressbar" aria-label="Generation progress">${parts.join("")}</div>`;
862
+ }
863
+
864
+ function buildPipelineBodyHtml() {
865
+ return `
866
+ ${buildStepperHtml()}
867
+ <div class="pipeline-detail" hidden></div>
868
+ <div class="pipeline-modules"></div>
869
+ <div class="pipeline-footer">
870
+ <div class="pipeline-timer"></div>
871
+ <div class="pipeline-estimate" hidden></div>
872
+ </div>`;
873
+ }
874
+
875
+ function captureBubbleRefs(bubbleEl, container) {
876
+ pipelineBubbleEl = container;
877
+ pipelineStepperEl = bubbleEl.querySelector(".pipeline-stepper");
878
+ pipelineDetailEl = bubbleEl.querySelector(".pipeline-detail");
879
+ pipelineModulesEl = bubbleEl.querySelector(".pipeline-modules");
880
+ pipelineEstimateEl = bubbleEl.querySelector(".pipeline-estimate");
881
+ }
257
882
 
258
883
  function ensurePipelineBubble() {
259
884
  if (pipelineBubbleEl) return;
@@ -270,13 +895,8 @@ function ensurePipelineBubble() {
270
895
  if (streamingMsgEl) {
271
896
  const existingDiv = streamingMsgEl.closest(".chat-msg");
272
897
  if (existingDiv) {
273
- streamingMsgEl.innerHTML = `
274
- <div class="pipeline-steps"></div>
275
- <div class="pipeline-modules"></div>
276
- <div class="pipeline-timer"></div>`;
277
- pipelineBubbleEl = existingDiv;
278
- pipelineStepsEl = streamingMsgEl.querySelector(".pipeline-steps");
279
- pipelineModulesEl = streamingMsgEl.querySelector(".pipeline-modules");
898
+ streamingMsgEl.innerHTML = buildPipelineBodyHtml();
899
+ captureBubbleRefs(streamingMsgEl, existingDiv);
280
900
  startPipelineTimer(streamingMsgEl.querySelector(".pipeline-timer"));
281
901
  scrollToBottom();
282
902
  return;
@@ -294,64 +914,80 @@ function ensurePipelineBubble() {
294
914
  <span class="chat-msg__time">${time}</span>
295
915
  </div>
296
916
  <div class="chat-msg__bubble">
297
- <div class="pipeline-steps"></div>
298
- <div class="pipeline-modules"></div>
299
- <div class="pipeline-timer"></div>
917
+ ${buildPipelineBodyHtml()}
300
918
  </div>
301
919
  </div>`;
302
920
  messagesEl.appendChild(div);
303
921
 
304
- pipelineBubbleEl = div;
305
- pipelineStepsEl = div.querySelector(".pipeline-steps");
306
- pipelineModulesEl = div.querySelector(".pipeline-modules");
307
922
  streamingMsgEl = div.querySelector(".chat-msg__bubble");
923
+ captureBubbleRefs(streamingMsgEl, div);
308
924
  startPipelineTimer(div.querySelector(".pipeline-timer"));
309
925
  scrollToBottom();
310
926
  }
311
927
 
312
- function markStepDone(el) {
313
- el.classList.add("pipeline-step--done");
314
- el.classList.remove("pipeline-step--active");
315
- const icon = el.querySelector(".pipeline-step__icon");
316
- if (icon) icon.textContent = "✓";
928
+ function setStageStatus(step, status) {
929
+ if (!pipelineStepperEl) return;
930
+ const stage = pipelineStepperEl.querySelector(`[data-stage="${step}"]`);
931
+ if (!stage) return;
932
+ stage.classList.remove(
933
+ "pipeline-stage--pending",
934
+ "pipeline-stage--active",
935
+ "pipeline-stage--done",
936
+ "pipeline-stage--failed",
937
+ );
938
+ stage.classList.add("pipeline-stage--" + status);
939
+
940
+ // Fill the connector behind this stage when it becomes active or done.
941
+ const idx = STEP_ORDER.indexOf(step);
942
+ if (idx > 0) {
943
+ const prev = STEP_ORDER[idx - 1];
944
+ const conn = pipelineStepperEl.querySelector(`[data-after="${prev}"]`);
945
+ if (conn) conn.classList.add("pipeline-stage__connector--filled");
946
+ }
947
+ }
948
+
949
+ function setPipelineDetail(text) {
950
+ if (!pipelineDetailEl) return;
951
+ if (!text) {
952
+ pipelineDetailEl.hidden = true;
953
+ pipelineDetailEl.textContent = "";
954
+ return;
955
+ }
956
+ pipelineDetailEl.hidden = false;
957
+ pipelineDetailEl.classList.remove("pipeline-detail--in");
958
+ // Force reflow so the animation restarts when text changes
959
+ void pipelineDetailEl.offsetWidth;
960
+ pipelineDetailEl.classList.add("pipeline-detail--in");
961
+ pipelineDetailEl.textContent = text;
317
962
  }
318
963
 
319
964
  function handleAgentStep(msg) {
320
965
  ensurePipelineBubble();
321
966
 
322
- // If the same step fires again (e.g., "designing" fires twice for design system + module planner),
323
- // update the existing step's label instead of creating a duplicate
324
- const existingStep = pipelineStepsEl.querySelector(`[data-step="${CSS.escape(msg.step)}"]:not(.pipeline-step--done)`);
325
- if (existingStep) {
326
- // Mark the current one as done and create a fresh one below
327
- markStepDone(existingStep);
328
- } else {
329
- // Mark all other active steps as done
330
- const existing = pipelineStepsEl.querySelectorAll(".pipeline-step");
331
- existing.forEach((el) => {
332
- if (!el.classList.contains("pipeline-step--done")) {
333
- markStepDone(el);
967
+ const incoming = msg.step;
968
+ const incomingIdx = STEP_ORDER.indexOf(incoming);
969
+
970
+ // Mark all prior stages done (handles forward jumps + repeated firings)
971
+ if (incomingIdx >= 0) {
972
+ for (let i = 0; i < incomingIdx; i++) {
973
+ const prevStage = pipelineStepperEl.querySelector(`[data-stage="${STEP_ORDER[i]}"]`);
974
+ if (prevStage && !prevStage.classList.contains("pipeline-stage--done")) {
975
+ setStageStatus(STEP_ORDER[i], "done");
334
976
  }
335
- });
977
+ }
336
978
  }
337
979
 
338
- // Add new step
339
- const step = document.createElement("div");
340
- step.className = "pipeline-step pipeline-step--active";
341
- step.dataset.step = msg.step;
342
- step.innerHTML = `<span class="pipeline-step__icon">⟳</span> <span class="pipeline-step__label">${msg.label || STEP_LABELS[msg.step] || msg.step}</span>`;
980
+ setStageStatus(incoming, "active");
981
+ pipelineCurrentStep = incoming;
343
982
 
344
- // Insert quality_check AFTER module cards so the visual order is:
345
- // developing → module cards → quality check
346
- if (msg.step === "quality_check" && pipelineModulesEl) {
347
- pipelineModulesEl.after(step);
348
- } else {
349
- pipelineStepsEl.appendChild(step);
350
- }
983
+ setPipelineDetail(msg.label || STEP_LABELS[incoming] || incoming);
351
984
 
352
- // Clear module cards when entering developing
353
- if (msg.step === "developing") {
985
+ if (incoming === "developing") {
354
986
  pipelineModulesEl.innerHTML = "";
987
+ startPipelineEstimate();
988
+ } else {
989
+ stopPipelineEstimate();
990
+ if (pipelineEstimateEl) pipelineEstimateEl.hidden = true;
355
991
  }
356
992
 
357
993
  scrollToBottom();
@@ -359,17 +995,7 @@ function handleAgentStep(msg) {
359
995
 
360
996
  function handleAgentDecision(msg) {
361
997
  if (!pipelineBubbleEl) return;
362
-
363
- // Find the step element — may be inside pipelineStepsEl or after pipelineModulesEl
364
- const bubble = streamingMsgEl || pipelineBubbleEl;
365
- const steps = bubble.querySelectorAll(".pipeline-step");
366
- const lastStep = steps[steps.length - 1];
367
- if (lastStep) {
368
- const detail = document.createElement("div");
369
- detail.className = "pipeline-step__decision";
370
- detail.textContent = msg.decision;
371
- lastStep.appendChild(detail);
372
- }
998
+ setPipelineDetail(msg.decision);
373
999
  scrollToBottom();
374
1000
  }
375
1001
 
@@ -379,9 +1005,18 @@ function handleModuleProgress(msg) {
379
1005
  let card = pipelineModulesEl.querySelector(`[data-module="${CSS.escape(msg.module)}"]`);
380
1006
  if (!card) {
381
1007
  card = document.createElement("div");
382
- card.className = "pipeline-module-card";
383
1008
  card.dataset.module = msg.module;
384
- card.innerHTML = `<span class="pipeline-module-card__name">${escapeHtml(msg.module)}</span> <span class="pipeline-module-card__status"></span>`;
1009
+ card.innerHTML = `
1010
+ <div class="pipeline-module-card__head">
1011
+ <span class="pipeline-module-card__name">${escapeHtml(msg.module)}</span>
1012
+ <span class="pipeline-module-card__status"></span>
1013
+ </div>
1014
+ <div class="pipeline-module-card__progress"><div class="pipeline-module-card__progress-bar"></div></div>
1015
+ <div class="pipeline-module-card__skeleton">
1016
+ <div class="pipeline-module-card__skeleton-row pipeline-module-card__skeleton-row--lg"></div>
1017
+ <div class="pipeline-module-card__skeleton-row pipeline-module-card__skeleton-row--md"></div>
1018
+ <div class="pipeline-module-card__skeleton-row pipeline-module-card__skeleton-row--sm"></div>
1019
+ </div>`;
385
1020
  pipelineModulesEl.appendChild(card);
386
1021
  }
387
1022
 
@@ -390,52 +1025,242 @@ function handleModuleProgress(msg) {
390
1025
 
391
1026
  const statusLabels = {
392
1027
  queued: "queued",
393
- generating: "generating...",
394
- validating: "validating...",
395
- retrying: "retrying...",
396
- complete: "",
397
- failed: "",
1028
+ generating: "generating",
1029
+ validating: "validating",
1030
+ retrying: "retrying",
1031
+ complete: "done",
1032
+ failed: "failed",
398
1033
  };
399
- statusEl.textContent = statusLabels[msg.status] || msg.status;
1034
+ if (statusEl) statusEl.textContent = statusLabels[msg.status] || msg.status;
400
1035
 
401
- // Mark/clear working overlay in the preview
402
1036
  if (msg.status === "generating" && typeof markModulesWorking === "function") {
403
1037
  markModulesWorking([msg.module]);
404
1038
  } else if ((msg.status === "complete" || msg.status === "failed") && typeof clearModuleWorking === "function") {
405
1039
  clearModuleWorking(msg.module);
406
1040
  }
407
1041
 
1042
+ updatePipelineEstimate();
1043
+
1044
+ if (msg.status === "complete" && msg.module) {
1045
+ changedModulesInRun.add(msg.module);
1046
+ }
1047
+
1048
+ // Render code snippet preview when a module finishes generating, and update
1049
+ // the global "Valid HubL" indicator based on a quick syntax check.
1050
+ if (msg.status === "complete" && msg.moduleFiles) {
1051
+ appendModuleCardSnippet(card, msg.module, msg.moduleFiles);
1052
+ if (typeof window.reportHublCheck === "function") {
1053
+ const issues = quickCheckHubl(msg.moduleFiles);
1054
+ window.reportHublCheck(msg.module, issues);
1055
+ }
1056
+ } else if (msg.status === "failed" && typeof window.reportHublCheck === "function") {
1057
+ window.reportHublCheck(msg.module, [{ kind: "generation_failed", message: "Module generation failed" }]);
1058
+ }
1059
+
408
1060
  scrollToBottom();
409
1061
  }
410
1062
 
1063
+ // ---------------------------------------------------------------------------
1064
+ // Code snippet preview inside a pipeline module card
1065
+ // ---------------------------------------------------------------------------
1066
+
1067
+ const HUBL_PREVIEW_MAX_LINES = 12;
1068
+ const HUBL_PREVIEW_MAX_CHARS = 800;
1069
+
1070
+ function appendModuleCardSnippet(card, moduleName, files) {
1071
+ if (card.querySelector(".pipeline-module-card__snippet")) return;
1072
+ const snippet = buildHublSnippet(files.moduleHtml || "");
1073
+ if (!snippet) return;
1074
+
1075
+ const wrap = document.createElement("div");
1076
+ wrap.className = "pipeline-module-card__snippet";
1077
+
1078
+ const header = document.createElement("div");
1079
+ header.className = "pipeline-module-card__snippet-head";
1080
+ header.innerHTML = `
1081
+ <span class="pipeline-module-card__snippet-label">module.html</span>
1082
+ <button class="pipeline-module-card__snippet-toggle" type="button" aria-expanded="false">
1083
+ <svg class="icon icon--sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
1084
+ <span class="pipeline-module-card__snippet-toggle-label">Show code</span>
1085
+ </button>`;
1086
+
1087
+ const codeWrap = document.createElement("div");
1088
+ codeWrap.className = "pipeline-module-card__snippet-code hidden";
1089
+ const pre = document.createElement("pre");
1090
+ const codeEl = document.createElement("code");
1091
+ codeEl.className = "language-hubl";
1092
+ codeEl.textContent = snippet.text;
1093
+ pre.appendChild(codeEl);
1094
+ codeWrap.appendChild(pre);
1095
+
1096
+ if (snippet.truncated) {
1097
+ const more = document.createElement("button");
1098
+ more.className = "pipeline-module-card__snippet-open";
1099
+ more.type = "button";
1100
+ more.textContent = "Open in code view →";
1101
+ more.addEventListener("click", () => openCodeViewForModule(moduleName));
1102
+ codeWrap.appendChild(more);
1103
+ }
1104
+
1105
+ header.querySelector("button").addEventListener("click", () => {
1106
+ const expanded = !codeWrap.classList.contains("hidden");
1107
+ codeWrap.classList.toggle("hidden", expanded);
1108
+ const toggle = header.querySelector(".pipeline-module-card__snippet-toggle");
1109
+ toggle.setAttribute("aria-expanded", expanded ? "false" : "true");
1110
+ const lbl = toggle.querySelector(".pipeline-module-card__snippet-toggle-label");
1111
+ if (lbl) lbl.textContent = expanded ? "Show code" : "Hide code";
1112
+ });
1113
+
1114
+ wrap.appendChild(header);
1115
+ wrap.appendChild(codeWrap);
1116
+ card.appendChild(wrap);
1117
+ }
1118
+
1119
+ function buildHublSnippet(html) {
1120
+ if (!html) return null;
1121
+ const trimmed = html.replace(/^\n+/, "").replace(/\n+$/, "");
1122
+ const lines = trimmed.split("\n");
1123
+ let out = lines.slice(0, HUBL_PREVIEW_MAX_LINES).join("\n");
1124
+ let truncated = lines.length > HUBL_PREVIEW_MAX_LINES;
1125
+ if (out.length > HUBL_PREVIEW_MAX_CHARS) {
1126
+ out = out.slice(0, HUBL_PREVIEW_MAX_CHARS);
1127
+ truncated = true;
1128
+ }
1129
+ return { text: out, truncated };
1130
+ }
1131
+
1132
+ function openCodeViewForModule(moduleName) {
1133
+ const codeBtn = document.querySelector('.view-toggle__btn[data-view="code"]');
1134
+ if (codeBtn) codeBtn.click();
1135
+ // Best-effort selection of the module's html file if the file list is ready.
1136
+ setTimeout(() => {
1137
+ const list = document.getElementById("code-file-list");
1138
+ if (!list) return;
1139
+ const target = Array.from(list.querySelectorAll("[data-file-path]")).find((el) => {
1140
+ const p = el.getAttribute("data-file-path") || "";
1141
+ return p.includes(`/${moduleName}.module/module.html`) || p.endsWith(`${moduleName}.module/module.html`);
1142
+ });
1143
+ if (target) target.click();
1144
+ }, 80);
1145
+ }
1146
+
1147
+ // Lightweight HubL validity check — flags the same balance issues the
1148
+ // server-side validator would auto-fix, plus reserved field/ deprecated type
1149
+ // markers that the server already strips. The badge is a hint, not a gate.
1150
+ function quickCheckHubl(files) {
1151
+ const issues = [];
1152
+ const html = files && typeof files.moduleHtml === "string" ? files.moduleHtml : "";
1153
+ if (!html) return issues;
1154
+
1155
+ const blockOpeners = ["if", "for", "block", "with", "macro", "filter", "raw", "scope_css"];
1156
+ const stack = [];
1157
+ const tagRe = /\{%-?\s*(end)?(\w+)/g;
1158
+ let match;
1159
+ while ((match = tagRe.exec(html))) {
1160
+ const isEnd = !!match[1];
1161
+ const name = match[2];
1162
+ if (isEnd) {
1163
+ if (!stack.length || stack[stack.length - 1] !== name) {
1164
+ issues.push({ kind: "unbalanced_close", tag: name, message: `Stray {% end${name} %}` });
1165
+ } else {
1166
+ stack.pop();
1167
+ }
1168
+ } else if (blockOpeners.includes(name)) {
1169
+ stack.push(name);
1170
+ }
1171
+ }
1172
+ for (const open of stack) {
1173
+ issues.push({ kind: "unbalanced_open", tag: open, message: `Missing {% end${open} %}` });
1174
+ }
1175
+ return issues;
1176
+ }
1177
+
1178
+ function startPipelineEstimate() {
1179
+ stopPipelineEstimate();
1180
+ if (!pipelineEstimateEl) return;
1181
+ pipelineEstimateEl.hidden = false;
1182
+ updatePipelineEstimate();
1183
+ pipelineEstimateInterval = setInterval(updatePipelineEstimate, 1000);
1184
+ }
1185
+
1186
+ function stopPipelineEstimate() {
1187
+ if (pipelineEstimateInterval) {
1188
+ clearInterval(pipelineEstimateInterval);
1189
+ pipelineEstimateInterval = null;
1190
+ }
1191
+ }
1192
+
1193
+ function updatePipelineEstimate() {
1194
+ if (!pipelineEstimateEl || pipelineCurrentStep !== "developing" || !pipelineModulesEl) return;
1195
+ const cards = pipelineModulesEl.querySelectorAll(".pipeline-module-card");
1196
+ if (!cards.length) {
1197
+ pipelineEstimateEl.textContent = "Estimating…";
1198
+ return;
1199
+ }
1200
+ let remaining = 0;
1201
+ let inFlight = 0;
1202
+ cards.forEach((c) => {
1203
+ if (c.classList.contains("pipeline-module-card--complete") || c.classList.contains("pipeline-module-card--failed")) return;
1204
+ remaining++;
1205
+ if (
1206
+ c.classList.contains("pipeline-module-card--generating") ||
1207
+ c.classList.contains("pipeline-module-card--validating") ||
1208
+ c.classList.contains("pipeline-module-card--retrying")
1209
+ ) {
1210
+ inFlight++;
1211
+ }
1212
+ });
1213
+ if (remaining === 0) {
1214
+ pipelineEstimateEl.textContent = "Wrapping up…";
1215
+ return;
1216
+ }
1217
+ // Default concurrency limiter is 20 modules, but most chats only have a
1218
+ // handful — assume 4-way effective parallelism for the estimate.
1219
+ const parallel = Math.max(1, Math.min(4, inFlight || 1));
1220
+ const seconds = Math.max(5, Math.ceil((remaining * ESTIMATED_SECONDS_PER_MODULE) / parallel));
1221
+ pipelineEstimateEl.textContent = `~${formatEstimate(seconds)} remaining`;
1222
+ }
1223
+
1224
+ function formatEstimate(totalSec) {
1225
+ if (totalSec < 60) return totalSec + "s";
1226
+ const m = Math.floor(totalSec / 60);
1227
+ const s = totalSec % 60;
1228
+ if (s === 0) return m + "m";
1229
+ return m + "m " + s + "s";
1230
+ }
1231
+
411
1232
  function handlePipelineComplete(msg) {
412
1233
  if (!pipelineBubbleEl) return;
413
1234
 
414
1235
  stopPipelineTimer();
1236
+ stopPipelineEstimate();
415
1237
  if (typeof clearAllModulesWorking === "function") clearAllModulesWorking();
416
1238
 
417
- // Remove the live timer element
418
- const timerEl = pipelineBubbleEl.querySelector(".pipeline-timer");
419
- if (timerEl) timerEl.remove();
1239
+ STEP_ORDER.forEach((s) => setStageStatus(s, "done"));
1240
+ pipelineCurrentStep = null;
1241
+ setPipelineDetail("");
1242
+
1243
+ const footer = pipelineBubbleEl.querySelector(".pipeline-footer");
1244
+ if (footer) footer.remove();
420
1245
 
421
- // Mark all steps as done (search whole bubble since quality_check is outside pipelineStepsEl)
422
1246
  const bubble = streamingMsgEl || pipelineBubbleEl;
423
- bubble.querySelectorAll(".pipeline-step").forEach((el) => markStepDone(el));
424
1247
 
425
- // Show answer text for question intents
426
1248
  if (msg.answer) {
427
1249
  const answerEl = document.createElement("div");
428
1250
  answerEl.className = "pipeline-answer";
429
1251
  answerEl.textContent = msg.answer;
430
1252
  bubble.appendChild(answerEl);
1253
+ } else if (msg.assistantMessage) {
1254
+ streamBuffer = msg.assistantMessage;
431
1255
  }
432
1256
 
433
- // Add completion stats after the last element in the bubble
1257
+ clearStreamStatus();
1258
+ finishStreaming();
1259
+
434
1260
  const stats = document.createElement("div");
435
1261
  stats.className = "pipeline-stats";
436
1262
  const duration = formatDuration(msg.durationMs);
437
1263
  if (msg.answer) {
438
- // For questions, just show duration
439
1264
  stats.textContent = `Answered in ${duration}`;
440
1265
  } else {
441
1266
  stats.textContent = `Generated ${msg.modulesGenerated} module${msg.modulesGenerated === 1 ? "" : "s"} in ${duration}`;
@@ -443,56 +1268,148 @@ function handlePipelineComplete(msg) {
443
1268
  stats.textContent += ` (${msg.modulesUnchanged} unchanged)`;
444
1269
  }
445
1270
  }
446
- // Place stats after quality_check step (or after modules if no quality step)
447
- const qualityStep = bubble.querySelector('[data-step="quality_check"]');
448
- if (qualityStep) {
449
- qualityStep.after(stats);
450
- } else if (pipelineModulesEl) {
451
- pipelineModulesEl.after(stats);
452
- } else {
453
- bubble.appendChild(stats);
454
- }
455
-
456
- clearStreamStatus();
457
- finishStreaming();
1271
+ bubble.appendChild(stats);
458
1272
 
459
- // Reset pipeline state
460
- pipelineBubbleEl = null;
461
- pipelineStepsEl = null;
462
- pipelineModulesEl = null;
1273
+ resetPipelineState();
463
1274
  }
464
1275
 
465
1276
  function handlePipelinePartial(msg) {
466
1277
  if (!pipelineBubbleEl) return;
467
1278
 
468
1279
  stopPipelineTimer();
1280
+ stopPipelineEstimate();
469
1281
  if (typeof clearAllModulesWorking === "function") clearAllModulesWorking();
470
1282
 
471
- const timerEl = pipelineBubbleEl.querySelector(".pipeline-timer");
472
- if (timerEl) timerEl.remove();
1283
+ STEP_ORDER.forEach((s) => setStageStatus(s, "done"));
1284
+ pipelineCurrentStep = null;
1285
+ setPipelineDetail("");
1286
+
1287
+ const footer = pipelineBubbleEl.querySelector(".pipeline-footer");
1288
+ if (footer) footer.remove();
473
1289
 
474
1290
  const bubble = streamingMsgEl || pipelineBubbleEl;
475
- bubble.querySelectorAll(".pipeline-step").forEach((el) => markStepDone(el));
1291
+
1292
+ clearStreamStatus();
1293
+ finishStreaming();
476
1294
 
477
1295
  const stats = document.createElement("div");
478
1296
  stats.className = "pipeline-stats pipeline-stats--partial";
479
1297
  const duration = formatDuration(msg.durationMs);
480
1298
  stats.textContent = `${msg.succeeded.length} modules succeeded, ${msg.failed.length} failed in ${duration}`;
481
- const qualityStep = bubble.querySelector('[data-step="quality_check"]');
482
- if (qualityStep) {
483
- qualityStep.after(stats);
484
- } else if (pipelineModulesEl) {
485
- pipelineModulesEl.after(stats);
486
- } else {
487
- pipelineStepsEl.appendChild(stats);
488
- }
1299
+ bubble.appendChild(stats);
489
1300
 
490
- clearStreamStatus();
491
- finishStreaming();
1301
+ resetPipelineState();
1302
+ }
492
1303
 
1304
+ function resetPipelineState() {
493
1305
  pipelineBubbleEl = null;
494
- pipelineStepsEl = null;
1306
+ pipelineStepperEl = null;
1307
+ pipelineDetailEl = null;
495
1308
  pipelineModulesEl = null;
1309
+ pipelineEstimateEl = null;
1310
+ pipelineCurrentStep = null;
1311
+
1312
+ renderChatSuggestions();
1313
+ }
1314
+
1315
+ // ---------------------------------------------------------------------------
1316
+ // Smart chat suggestions — contextual next-step chips after generation
1317
+ // ---------------------------------------------------------------------------
1318
+
1319
+ let suggestionsVisible = false;
1320
+
1321
+ function getCurrentModuleNames() {
1322
+ return Array.from(document.querySelectorAll("#module-items .module-item"))
1323
+ .map((el) => (el.dataset.module || "").toLowerCase());
1324
+ }
1325
+
1326
+ function pickContextualSuggestions(moduleNames) {
1327
+ const has = (kw) => moduleNames.some((n) => n.includes(kw));
1328
+ const additive = [];
1329
+
1330
+ if (!has("testimonial") && !has("review") && !has("quote")) {
1331
+ additive.push("Add a testimonials module");
1332
+ }
1333
+ if (!has("pricing") && !has("plan") && !has("tier")) {
1334
+ additive.push("Add a pricing table");
1335
+ }
1336
+ if (!has("faq") && !has("question")) {
1337
+ additive.push("Add an FAQ module");
1338
+ }
1339
+ if (!has("contact") && !has("form")) {
1340
+ additive.push("Add a contact form");
1341
+ }
1342
+ if (!has("hero") && !has("banner")) {
1343
+ additive.push("Add a hero module");
1344
+ }
1345
+ if (!has("feature") && !has("benefit")) {
1346
+ additive.push("Add a features grid with icons");
1347
+ }
1348
+ if (!has("footer")) {
1349
+ additive.push("Add a footer with social links");
1350
+ }
1351
+ if (!has("stat") && !has("metric") && !has("number")) {
1352
+ additive.push("Add a stats module with key numbers");
1353
+ }
1354
+ if (!has("cta") && !has("call")) {
1355
+ additive.push("Add a call-to-action module");
1356
+ }
1357
+
1358
+ const refinements = [
1359
+ has("hero") || has("banner")
1360
+ ? "Make the hero CTA more prominent"
1361
+ : "Strengthen the opening hook",
1362
+ "Change the color scheme to something bolder",
1363
+ "Refine the typography for a more modern feel",
1364
+ "Add subtle animations to bring it to life",
1365
+ ];
1366
+
1367
+ const out = [];
1368
+ for (const s of additive) {
1369
+ if (out.length >= 3) break;
1370
+ out.push(s);
1371
+ }
1372
+ for (const r of refinements) {
1373
+ if (out.length >= 3) break;
1374
+ out.push(r);
1375
+ }
1376
+ return out;
1377
+ }
1378
+
1379
+ function renderChatSuggestions() {
1380
+ const container = document.getElementById("chat-suggestions");
1381
+ if (!container) return;
1382
+
1383
+ if (isStreaming || (inputEl && inputEl.value && inputEl.value.length > 0)) {
1384
+ hideChatSuggestions();
1385
+ return;
1386
+ }
1387
+
1388
+ const suggestions = pickContextualSuggestions(getCurrentModuleNames());
1389
+ if (!suggestions.length) {
1390
+ hideChatSuggestions();
1391
+ return;
1392
+ }
1393
+
1394
+ container.innerHTML = "";
1395
+ for (const text of suggestions) {
1396
+ const btn = document.createElement("button");
1397
+ btn.type = "button";
1398
+ btn.className = "chat__suggestion-chip";
1399
+ btn.dataset.suggestion = text;
1400
+ btn.textContent = text;
1401
+ container.appendChild(btn);
1402
+ }
1403
+ container.classList.remove("hidden");
1404
+ suggestionsVisible = true;
1405
+ }
1406
+
1407
+ function hideChatSuggestions() {
1408
+ const container = document.getElementById("chat-suggestions");
1409
+ if (!container) return;
1410
+ container.classList.add("hidden");
1411
+ container.innerHTML = "";
1412
+ suggestionsVisible = false;
496
1413
  }
497
1414
 
498
1415
  async function handleAgenticPrompt() {
@@ -549,8 +1466,11 @@ function handleSuggestBrandExtraction() {
549
1466
  scrollToBottom();
550
1467
 
551
1468
  el.querySelector("#btn-accept-extraction").addEventListener("click", () => {
552
- el.querySelector(".brand-extraction-prompt__actions").innerHTML =
553
- '<span class="brand-extraction-prompt__status">Extracting...</span>';
1469
+ const container = el.querySelector(".chat-msg__system");
1470
+ if (container) {
1471
+ container.innerHTML =
1472
+ '<span class="brand-extraction-prompt__status">Extracting…</span>';
1473
+ }
554
1474
  if (ws && ws.readyState === WebSocket.OPEN) {
555
1475
  ws.send(JSON.stringify({ type: "extract_brand_assets" }));
556
1476
  }
@@ -757,6 +1677,16 @@ async function sendMessage(text) {
757
1677
  // Show user message with file chips
758
1678
  appendUserMessage(text, null, uploadedFiles);
759
1679
 
1680
+ // Snapshot the current module list so we can detect new vs. modified modules
1681
+ // when the generation finishes. Clears stale change-indicator dots from any
1682
+ // previous run so the user only sees indicators for the run they just kicked.
1683
+ modulesBeforeRun = new Set(
1684
+ Array.from(document.querySelectorAll(".module-item")).map((el) => el.dataset.module),
1685
+ );
1686
+ changedModulesInRun = new Set();
1687
+ clearModuleListChanged();
1688
+ if (typeof window.resetHublCheck === "function") window.resetHublCheck();
1689
+
760
1690
  // Start streaming indicator
761
1691
  startStreaming();
762
1692
 
@@ -808,6 +1738,15 @@ function startStreaming() {
808
1738
  lastStreamStatus = "";
809
1739
  sendBtn.disabled = true;
810
1740
  streamStartTime = Date.now();
1741
+ if (typeof window.setSelectModeDisabled === "function") {
1742
+ window.setSelectModeDisabled(true);
1743
+ }
1744
+ if (typeof window.setEditModeDisabled === "function") {
1745
+ window.setEditModeDisabled(true);
1746
+ }
1747
+
1748
+ hideChatSuggestions();
1749
+ if (typeof updateHistoryTimelineNavState === "function") updateHistoryTimelineNavState();
811
1750
 
812
1751
  // Don't show generating preview here — agentic mode keeps the page visible.
813
1752
  // For single-call mode, showGeneratingPreview() is called on first "stream" event.
@@ -950,6 +1889,13 @@ function finishStreaming() {
950
1889
  if (!isStreaming) return;
951
1890
  isStreaming = false;
952
1891
  sendBtn.disabled = false;
1892
+ if (typeof window.setSelectModeDisabled === "function") {
1893
+ window.setSelectModeDisabled(false);
1894
+ }
1895
+ if (typeof window.setEditModeDisabled === "function") {
1896
+ window.setEditModeDisabled(false);
1897
+ }
1898
+ if (typeof updateHistoryTimelineNavState === "function") updateHistoryTimelineNavState();
953
1899
 
954
1900
  // Stop the timer and capture duration
955
1901
  stopStreamTimer();
@@ -1071,6 +2017,46 @@ function setStatus(text) {
1071
2017
  statusText.textContent = text;
1072
2018
  }
1073
2019
 
2020
+ function setStatusTheme(name) {
2021
+ if (!statusTheme) return;
2022
+ const value = (name || "").trim();
2023
+ statusTheme.textContent = value && value !== "—" ? value : "";
2024
+ }
2025
+
2026
+ function formatRelativeSaved(ts) {
2027
+ if (!ts) return "";
2028
+ const diff = Math.max(0, Date.now() - ts);
2029
+ const sec = Math.round(diff / 1000);
2030
+ if (sec < 10) return "just now";
2031
+ if (sec < 60) return `${sec} sec ago`;
2032
+ const min = Math.round(sec / 60);
2033
+ if (min < 60) return `${min} min ago`;
2034
+ const hr = Math.round(min / 60);
2035
+ if (hr < 24) return `${hr} hr ago`;
2036
+ const day = Math.round(hr / 24);
2037
+ return `${day} day${day === 1 ? "" : "s"} ago`;
2038
+ }
2039
+
2040
+ function renderLastSaved() {
2041
+ if (!statusLastSaved) return;
2042
+ if (!lastSavedAt) {
2043
+ statusLastSaved.textContent = "";
2044
+ statusLastSaved.title = "";
2045
+ return;
2046
+ }
2047
+ statusLastSaved.textContent = formatRelativeSaved(lastSavedAt);
2048
+ try {
2049
+ statusLastSaved.title = new Date(lastSavedAt).toLocaleString();
2050
+ } catch { /* ignore */ }
2051
+ }
2052
+
2053
+ function markSaved(ts) {
2054
+ lastSavedAt = typeof ts === "number" && ts > 0 ? ts : Date.now();
2055
+ renderLastSaved();
2056
+ }
2057
+
2058
+ setInterval(renderLastSaved, 30000);
2059
+
1074
2060
  // ---------------------------------------------------------------------------
1075
2061
  // Restored / system messages
1076
2062
  // ---------------------------------------------------------------------------
@@ -1081,21 +2067,40 @@ function appendRestoredAssistantMessage(text, timestamp, pipeline) {
1081
2067
  div.className = "chat-msg chat-msg--assistant";
1082
2068
 
1083
2069
  if (pipeline && pipeline.steps && pipeline.steps.length > 0) {
1084
- // Render detailed pipeline structure
1085
- const stepsHtml = pipeline.steps.map((s) => {
1086
- const icon = "&#x2714;";
1087
- const decisionsHtml = (s.decisions || [])
1088
- .map((d) => `<div class="pipeline-step__decision">${escapeHtml(d)}</div>`)
1089
- .join("");
1090
- return `<div class="pipeline-step pipeline-step--done"><span class="pipeline-step__icon">${icon}</span> <span class="pipeline-step__label">${escapeHtml(s.label)}</span>${decisionsHtml}</div>`;
1091
- }).join("");
2070
+ // Finalized stepper every visited stage shown as done
2071
+ const stagesSeen = new Set(pipeline.steps.map((s) => s.step));
2072
+ const stepperParts = [];
2073
+ for (let i = 0; i < STEP_ORDER.length; i++) {
2074
+ const step = STEP_ORDER[i];
2075
+ const visited = stagesSeen.has(step);
2076
+ const stateClass = visited ? "pipeline-stage--done" : "pipeline-stage--pending";
2077
+ stepperParts.push(`
2078
+ <div class="pipeline-stage ${stateClass}" data-stage="${step}">
2079
+ <div class="pipeline-stage__indicator">
2080
+ <span class="pipeline-stage__icon">${STEP_ICONS[step]}</span>
2081
+ <span class="pipeline-stage__check">${CHECK_ICON}</span>
2082
+ </div>
2083
+ <div class="pipeline-stage__label">${STEP_LABELS[step]}</div>
2084
+ </div>`);
2085
+ if (i < STEP_ORDER.length - 1) {
2086
+ const filled = visited && stagesSeen.has(STEP_ORDER[i + 1]) ? "pipeline-stage__connector--filled" : "";
2087
+ stepperParts.push(`<div class="pipeline-stage__connector ${filled}" data-after="${step}"></div>`);
2088
+ }
2089
+ }
2090
+
2091
+ const allDecisions = pipeline.steps.flatMap((s) => s.decisions || []);
2092
+ const decisionHtml = allDecisions.length
2093
+ ? `<details class="pipeline-detail-summary"><summary>${allDecisions.length} decision${allDecisions.length === 1 ? "" : "s"} logged</summary>${allDecisions.map((d) => `<div class="pipeline-detail-summary__row">${escapeHtml(d)}</div>`).join("")}</details>`
2094
+ : "";
1092
2095
 
1093
2096
  const modulesHtml = pipeline.modules && pipeline.modules.length > 0
1094
- ? pipeline.modules.map((m) => {
1095
- const statusClass = m.status === "failed" ? "pipeline-module-card--failed" : "pipeline-module-card--done";
1096
- const statusIcon = m.status === "failed" ? "&#x2718;" : "&#x2714;";
1097
- return `<div class="pipeline-module-card ${statusClass}">${statusIcon} ${escapeHtml(m.name)}</div>`;
1098
- }).join("")
2097
+ ? `<div class="pipeline-modules pipeline-modules--restored">${pipeline.modules
2098
+ .map((m) => {
2099
+ const statusClass = m.status === "failed" ? "pipeline-module-card--failed" : "pipeline-module-card--complete";
2100
+ const statusLabel = m.status === "failed" ? "failed" : "done";
2101
+ return `<div class="pipeline-module-card ${statusClass}"><div class="pipeline-module-card__head"><span class="pipeline-module-card__name">${escapeHtml(m.name)}</span><span class="pipeline-module-card__status">${statusLabel}</span></div></div>`;
2102
+ })
2103
+ .join("")}</div>`
1099
2104
  : "";
1100
2105
 
1101
2106
  const duration = formatDuration(pipeline.stats.durationMs);
@@ -1110,7 +2115,10 @@ function appendRestoredAssistantMessage(text, timestamp, pipeline) {
1110
2115
  <div class="chat-msg__content">
1111
2116
  ${time ? `<div class="chat-msg__header"><span class="chat-msg__sender">vibeSpot AI</span><span class="chat-msg__time">${time}</span></div>` : ""}
1112
2117
  <div class="chat-msg__bubble">
1113
- <div class="pipeline-steps">${stepsHtml}${modulesHtml ? `<div class="pipeline-modules-restored">${modulesHtml}</div>` : ""}<div class="${statsClass}">${statsText}</div></div>
2118
+ <div class="pipeline-stepper">${stepperParts.join("")}</div>
2119
+ ${decisionHtml}
2120
+ ${modulesHtml}
2121
+ <div class="${statsClass}">${statsText}</div>
1114
2122
  </div>
1115
2123
  </div>`;
1116
2124
  } else {
@@ -1278,6 +2286,303 @@ function timeAgoShort(timestamp) {
1278
2286
  return days + "d";
1279
2287
  }
1280
2288
 
2289
+ // ---------------------------------------------------------------------------
2290
+ // History timeline — compact strip above chat input with undo/redo support
2291
+ // ---------------------------------------------------------------------------
2292
+
2293
+ let historyGitAvailable = false;
2294
+ // Index into historyTimelineEntries (filtered list, newest first) representing
2295
+ // the version the user is currently viewing. 0 = head, N = N steps back.
2296
+ let historyTimelineCursor = 0;
2297
+ let historyTimelineEntries = [];
2298
+ let historyTimelineRestoring = false;
2299
+
2300
+ function shortenCommitMessage(msg) {
2301
+ if (!msg) return "Update";
2302
+ let s = msg;
2303
+ // Strip [templateId] prefix
2304
+ const prefix = s.match(/^\[[^\]]+\]\s*/);
2305
+ if (prefix) s = s.slice(prefix[0].length);
2306
+ // Strip leading "Rollback to: "
2307
+ s = s.replace(/^Rollback to:\s*/i, "");
2308
+ if (s.length > 32) s = s.slice(0, 31) + "…";
2309
+ return s;
2310
+ }
2311
+
2312
+ function stripCommitPrefix(msg) {
2313
+ return (msg || "").replace(/^\[[^\]]+\]\s*/, "");
2314
+ }
2315
+
2316
+ // Compute which timeline entry represents the *currently visible* version.
2317
+ // HEAD may be a "Rollback to: <original>" commit (an internal marker we filter
2318
+ // out of the visible timeline); in that case, we map back to the original
2319
+ // entry by message. Otherwise the cursor sits at HEAD itself.
2320
+ function computeCursorFromHead(commits, entries) {
2321
+ if (!commits.length || !entries.length) return 0;
2322
+ const headMsg = stripCommitPrefix(commits[0].message);
2323
+ if (/^rollback to:\s*/i.test(headMsg)) {
2324
+ const orig = headMsg.replace(/^rollback to:\s*/i, "").trim().replace(/\.{3}$/, "");
2325
+ for (let i = 0; i < entries.length; i++) {
2326
+ const em = stripCommitPrefix(entries[i].message);
2327
+ if (em === orig || em.startsWith(orig)) return i;
2328
+ }
2329
+ return 0;
2330
+ }
2331
+ for (let i = 0; i < entries.length; i++) {
2332
+ if (entries[i].fullHash === commits[0].fullHash) return i;
2333
+ }
2334
+ return 0;
2335
+ }
2336
+
2337
+ async function refreshHistoryTimeline() {
2338
+ const tl = document.getElementById("history-timeline");
2339
+ const track = document.getElementById("history-timeline-track");
2340
+ if (!tl || !track) return;
2341
+
2342
+ if (!historyGitAvailable) {
2343
+ tl.classList.add("hidden");
2344
+ return;
2345
+ }
2346
+
2347
+ try {
2348
+ const useFilter = currentTemplateId;
2349
+ const url = useFilter
2350
+ ? `/api/history?templateId=${encodeURIComponent(currentTemplateId)}`
2351
+ : "/api/history";
2352
+ const res = await fetch(url);
2353
+ const data = await res.json();
2354
+ if (!data.available) {
2355
+ tl.classList.add("hidden");
2356
+ return;
2357
+ }
2358
+
2359
+ const allCommits = data.commits || [];
2360
+ // Filter out automatic "Rollback to:" commits — they're internal markers.
2361
+ // The user navigates the conceptual history of generation events.
2362
+ const entries = allCommits.filter((c) => {
2363
+ return !/^rollback to:\s*/i.test(stripCommitPrefix(c.message));
2364
+ });
2365
+ historyTimelineEntries = entries;
2366
+
2367
+ if (entries.length === 0) {
2368
+ tl.classList.add("hidden");
2369
+ return;
2370
+ }
2371
+
2372
+ historyTimelineCursor = computeCursorFromHead(allCommits, entries);
2373
+
2374
+ tl.classList.remove("hidden");
2375
+ track.innerHTML = "";
2376
+ const frag = document.createDocumentFragment();
2377
+ entries.forEach((commit, idx) => {
2378
+ const isInitial = (commit.message || "").startsWith("Initial ");
2379
+ const item = document.createElement("button");
2380
+ item.type = "button";
2381
+ item.className = "history-timeline__entry"
2382
+ + (idx === historyTimelineCursor ? " history-timeline__entry--current" : "")
2383
+ + (isInitial ? " history-timeline__entry--initial" : "");
2384
+ item.dataset.hash = commit.fullHash;
2385
+ item.dataset.index = String(idx);
2386
+ item.innerHTML = `<span class="history-timeline__entry-dot"></span>`
2387
+ + `<span class="history-timeline__entry-label"></span>`;
2388
+ item.querySelector(".history-timeline__entry-label").textContent = shortenCommitMessage(commit.message);
2389
+ item.title = ""; // we use a custom tooltip
2390
+ frag.appendChild(item);
2391
+ });
2392
+ track.appendChild(frag);
2393
+
2394
+ updateHistoryTimelineNavState();
2395
+
2396
+ // Scroll the current entry into view
2397
+ requestAnimationFrame(() => {
2398
+ const cur = track.querySelector(".history-timeline__entry--current");
2399
+ if (cur && typeof cur.scrollIntoView === "function") {
2400
+ cur.scrollIntoView({ block: "nearest", inline: "nearest" });
2401
+ }
2402
+ });
2403
+ } catch {
2404
+ tl.classList.add("hidden");
2405
+ }
2406
+ }
2407
+
2408
+ function updateHistoryTimelineNavState() {
2409
+ const undoBtn = document.getElementById("history-timeline-undo");
2410
+ const redoBtn = document.getElementById("history-timeline-redo");
2411
+ const topbarUndo = document.getElementById("btn-undo");
2412
+ const topbarRedo = document.getElementById("btn-redo");
2413
+ const max = historyTimelineEntries.length - 1;
2414
+ const busy = historyTimelineRestoring || (typeof isStreaming !== "undefined" && isStreaming);
2415
+ const undoDisabled = busy || !historyGitAvailable || historyTimelineCursor >= max;
2416
+ const redoDisabled = busy || !historyGitAvailable || historyTimelineCursor <= 0;
2417
+ if (undoBtn) undoBtn.disabled = undoDisabled;
2418
+ if (redoBtn) redoBtn.disabled = redoDisabled;
2419
+ if (topbarUndo) topbarUndo.disabled = undoDisabled;
2420
+ if (topbarRedo) topbarRedo.disabled = redoDisabled;
2421
+ }
2422
+
2423
+ async function restoreToTimelineIndex(idx) {
2424
+ if (historyTimelineRestoring) return;
2425
+ if (idx < 0 || idx >= historyTimelineEntries.length) return;
2426
+ if (typeof isStreaming !== "undefined" && isStreaming) return;
2427
+ const target = historyTimelineEntries[idx];
2428
+ if (!target) return;
2429
+
2430
+ historyTimelineRestoring = true;
2431
+ updateHistoryTimelineNavState();
2432
+ setStatus("Restoring…");
2433
+
2434
+ try {
2435
+ const payload = { hash: target.fullHash };
2436
+ if (currentTemplateId) payload.templateId = currentTemplateId;
2437
+ const res = await fetch("/api/rollback", {
2438
+ method: "POST",
2439
+ headers: { "Content-Type": "application/json" },
2440
+ body: JSON.stringify(payload),
2441
+ });
2442
+ const data = await res.json();
2443
+ if (data.error) {
2444
+ await vibeAlert(data.error, "Restore failed");
2445
+ setStatus("Ready");
2446
+ return;
2447
+ }
2448
+ if (data.modules) updateModuleList(data.modules);
2449
+ refreshPreview();
2450
+ // Refresh from server — the new HEAD is a "Rollback to:" commit pointing
2451
+ // at the entry we just restored; computeCursorFromHead resolves it back.
2452
+ await refreshHistoryTimeline();
2453
+ if (historyPanelOpen) refreshHistoryPanel();
2454
+ setStatus("Ready");
2455
+ } catch (err) {
2456
+ await vibeAlert(err.message || "Restore failed", "Restore failed");
2457
+ setStatus("Ready");
2458
+ } finally {
2459
+ historyTimelineRestoring = false;
2460
+ updateHistoryTimelineNavState();
2461
+ }
2462
+ }
2463
+
2464
+ function timelineUndo() {
2465
+ restoreToTimelineIndex(historyTimelineCursor + 1);
2466
+ }
2467
+
2468
+ function timelineRedo() {
2469
+ restoreToTimelineIndex(historyTimelineCursor - 1);
2470
+ }
2471
+
2472
+ // ---- Tooltip on hover ------------------------------------------------------
2473
+
2474
+ function showTimelineTooltip(entryEl) {
2475
+ const tooltip = document.getElementById("history-timeline-tooltip");
2476
+ const tl = document.getElementById("history-timeline");
2477
+ if (!tooltip || !tl || !entryEl) return;
2478
+ const idx = parseInt(entryEl.dataset.index || "-1", 10);
2479
+ const commit = historyTimelineEntries[idx];
2480
+ if (!commit) return;
2481
+
2482
+ const modules = (commit.changedModules || []).slice(0, 5);
2483
+ const more = (commit.changedModules || []).length - modules.length;
2484
+ const modulesLine = modules.length
2485
+ ? modules.join(", ") + (more > 0 ? ` +${more} more` : "")
2486
+ : "No module changes";
2487
+
2488
+ // Strip [templateId] prefix for display; keep "Rollback to:" prefix because
2489
+ // that case is filtered out earlier and won't reach here.
2490
+ let displayMsg = commit.message || "";
2491
+ const prefix = displayMsg.match(/^\[[^\]]+\]\s*/);
2492
+ if (prefix) displayMsg = displayMsg.slice(prefix[0].length);
2493
+
2494
+ tooltip.innerHTML = `
2495
+ <div class="history-timeline__tooltip-title"></div>
2496
+ <div class="history-timeline__tooltip-meta"></div>
2497
+ <div class="history-timeline__tooltip-modules"></div>
2498
+ `;
2499
+ tooltip.querySelector(".history-timeline__tooltip-title").textContent = displayMsg || "Update";
2500
+ tooltip.querySelector(".history-timeline__tooltip-meta").textContent =
2501
+ `${commit.hash} · ${timeAgoShort(commit.timestamp)} ago`;
2502
+ tooltip.querySelector(".history-timeline__tooltip-modules").textContent = modulesLine;
2503
+
2504
+ // Position above the entry, clamped within the timeline strip.
2505
+ tooltip.classList.remove("hidden");
2506
+ const tlRect = tl.getBoundingClientRect();
2507
+ const entryRect = entryEl.getBoundingClientRect();
2508
+ const left = Math.max(8, entryRect.left - tlRect.left);
2509
+ const tooltipW = tooltip.offsetWidth;
2510
+ const maxLeft = Math.max(8, tl.clientWidth - tooltipW - 8);
2511
+ tooltip.style.left = Math.min(left, maxLeft) + "px";
2512
+ }
2513
+
2514
+ function hideTimelineTooltip() {
2515
+ const tooltip = document.getElementById("history-timeline-tooltip");
2516
+ if (tooltip) tooltip.classList.add("hidden");
2517
+ }
2518
+
2519
+ // ---- Wire up DOM events ----------------------------------------------------
2520
+
2521
+ (function wireHistoryTimeline() {
2522
+ const track = document.getElementById("history-timeline-track");
2523
+ const undoBtn = document.getElementById("history-timeline-undo");
2524
+ const redoBtn = document.getElementById("history-timeline-redo");
2525
+
2526
+ if (track) {
2527
+ track.addEventListener("click", (e) => {
2528
+ const entry = e.target.closest(".history-timeline__entry");
2529
+ if (!entry) return;
2530
+ const idx = parseInt(entry.dataset.index || "-1", 10);
2531
+ if (idx >= 0) restoreToTimelineIndex(idx);
2532
+ });
2533
+
2534
+ let hoverTimer = null;
2535
+ track.addEventListener("mouseover", (e) => {
2536
+ const entry = e.target.closest(".history-timeline__entry");
2537
+ if (!entry) return;
2538
+ clearTimeout(hoverTimer);
2539
+ hoverTimer = setTimeout(() => showTimelineTooltip(entry), 200);
2540
+ });
2541
+ track.addEventListener("mouseout", (e) => {
2542
+ const entry = e.target.closest(".history-timeline__entry");
2543
+ if (!entry) return;
2544
+ clearTimeout(hoverTimer);
2545
+ hideTimelineTooltip();
2546
+ });
2547
+ }
2548
+
2549
+ if (undoBtn) undoBtn.addEventListener("click", timelineUndo);
2550
+ if (redoBtn) redoBtn.addEventListener("click", timelineRedo);
2551
+
2552
+ // Topbar undo/redo buttons mirror the timeline strip controls so the action
2553
+ // is discoverable without opening the history panel.
2554
+ const topbarUndo = document.getElementById("btn-undo");
2555
+ const topbarRedo = document.getElementById("btn-redo");
2556
+ if (topbarUndo) topbarUndo.addEventListener("click", timelineUndo);
2557
+ if (topbarRedo) topbarRedo.addEventListener("click", timelineRedo);
2558
+
2559
+ // Global Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z keyboard shortcuts. Skip when the
2560
+ // user is editing text so we never hijack native undo in inputs/editors.
2561
+ document.addEventListener("keydown", (e) => {
2562
+ const meta = e.metaKey || e.ctrlKey;
2563
+ if (!meta) return;
2564
+ const key = e.key.toLowerCase();
2565
+ if (key !== "z" && key !== "y") return;
2566
+
2567
+ const active = document.activeElement;
2568
+ if (active) {
2569
+ const tag = (active.tagName || "").toLowerCase();
2570
+ if (tag === "input" || tag === "textarea" || tag === "select") return;
2571
+ if (active.isContentEditable) return;
2572
+ }
2573
+
2574
+ if (!historyGitAvailable) return;
2575
+
2576
+ if (key === "z" && !e.shiftKey) {
2577
+ e.preventDefault();
2578
+ timelineUndo();
2579
+ } else if ((key === "z" && e.shiftKey) || key === "y") {
2580
+ e.preventDefault();
2581
+ timelineRedo();
2582
+ }
2583
+ });
2584
+ })();
2585
+
1281
2586
  // ---------------------------------------------------------------------------
1282
2587
  // Module list
1283
2588
  // ---------------------------------------------------------------------------
@@ -1289,17 +2594,28 @@ function updateModuleList(moduleNames) {
1289
2594
 
1290
2595
  if (barCountEl) barCountEl.textContent = moduleNames.length;
1291
2596
  if (slideoutCountEl) slideoutCountEl.textContent = moduleNames.length;
2597
+ const pageTreeCountEl = document.getElementById("page-tree-module-count");
2598
+ if (pageTreeCountEl) pageTreeCountEl.textContent = moduleNames.length;
2599
+
2600
+ // Preserve which items were marked as recently changed across re-renders so
2601
+ // the dots survive the per-module `modules_updated` events that happen
2602
+ // mid-run.
2603
+ const previouslyChanged = new Set(
2604
+ Array.from(itemsEl.querySelectorAll(".module-item--changed")).map((el) => el.dataset.module),
2605
+ );
1292
2606
  itemsEl.innerHTML = "";
1293
2607
 
1294
2608
  for (const name of moduleNames) {
1295
2609
  const item = document.createElement("div");
1296
2610
  item.className = "module-item";
2611
+ if (previouslyChanged.has(name)) item.classList.add("module-item--changed");
1297
2612
  item.dataset.module = name;
1298
2613
  item.innerHTML = `
1299
2614
  <span class="module-item__drag">⠿</span>
2615
+ <span class="module-item__changed-dot" aria-hidden="true"></span>
1300
2616
  <span class="module-item__name">${escapeHtml(name)}</span>
1301
- <span class="module-item__edit" title="Edit fields">⚙</span>
1302
- <span class="module-item__delete" title="Delete module">&times;</span>
2617
+ <span class="module-item__edit" title="Edit fields">${vsIcon("settings", {size: "sm"})}</span>
2618
+ <span class="module-item__delete" title="Delete section">&times;</span>
1303
2619
  `;
1304
2620
 
1305
2621
  item.querySelector(".module-item__edit").addEventListener("click", (e) => {
@@ -1325,12 +2641,79 @@ function highlightModuleItem(name) {
1325
2641
  });
1326
2642
  }
1327
2643
 
2644
+ /**
2645
+ * Flush change highlights at the end of a generation run: refresh the preview
2646
+ * with the changed/new module sets, mark the sidebar items, and schedule the
2647
+ * 30-second auto-clear of the dots.
2648
+ */
2649
+ function flushChangeHighlights(latestModules) {
2650
+ const changed = Array.from(changedModulesInRun);
2651
+ const before = modulesBeforeRun;
2652
+ changedModulesInRun = new Set();
2653
+ modulesBeforeRun = null;
2654
+
2655
+ let newModules = [];
2656
+ if (before) {
2657
+ if (changed.length > 0) {
2658
+ // Agentic pipeline: we know exactly which modules ran. New = ran AND
2659
+ // not in the pre-run snapshot.
2660
+ newModules = changed.filter((m) => !before.has(m));
2661
+ } else if (Array.isArray(latestModules)) {
2662
+ // Single-call mode emits no per-module events. Fall back to "names that
2663
+ // appeared since the run started" — we can't know which existing
2664
+ // modules were rewritten without server-side support.
2665
+ newModules = latestModules.filter((m) => !before.has(m));
2666
+ }
2667
+ }
2668
+
2669
+ refreshPreview({
2670
+ changedModules: changed.length > 0 ? changed : newModules,
2671
+ newModules,
2672
+ });
2673
+
2674
+ // Sidebar dots only fire when we have explicit per-module change info; the
2675
+ // single-call fallback above only knows about new modules, which already
2676
+ // get the slide-in animation in the preview.
2677
+ if (changed.length > 0) {
2678
+ markModuleListChanged(changed);
2679
+ } else if (newModules.length > 0) {
2680
+ markModuleListChanged(newModules);
2681
+ }
2682
+ }
2683
+
2684
+ /** Apply the change indicator dot to each named module in the sidebar. */
2685
+ function markModuleListChanged(names) {
2686
+ if (!names || names.length === 0) return;
2687
+ const itemsEl = document.getElementById("module-items");
2688
+ if (!itemsEl) return;
2689
+ const set = new Set(names);
2690
+ itemsEl.querySelectorAll(".module-item").forEach((el) => {
2691
+ if (set.has(el.dataset.module)) el.classList.add("module-item--changed");
2692
+ });
2693
+
2694
+ // Auto-clear after 30s per spec; next generation also clears via sendMessage.
2695
+ if (changedListClearTimer) clearTimeout(changedListClearTimer);
2696
+ changedListClearTimer = setTimeout(clearModuleListChanged, 30000);
2697
+ }
2698
+
2699
+ /** Remove all change indicator dots from the sidebar. */
2700
+ function clearModuleListChanged() {
2701
+ if (changedListClearTimer) {
2702
+ clearTimeout(changedListClearTimer);
2703
+ changedListClearTimer = null;
2704
+ }
2705
+ document.querySelectorAll(".module-item--changed").forEach((el) => {
2706
+ el.classList.remove("module-item--changed");
2707
+ });
2708
+ }
2709
+
1328
2710
  // ---------------------------------------------------------------------------
1329
2711
  // Module slideout
1330
2712
  // ---------------------------------------------------------------------------
1331
2713
 
1332
2714
  function openModuleSlideout() {
1333
2715
  const slideout = document.getElementById("module-slideout");
2716
+ slideout.classList.remove("hidden");
1334
2717
  document.getElementById("module-list-view").classList.remove("hidden");
1335
2718
  document.getElementById("module-editor-view").classList.add("hidden");
1336
2719
  slideout.classList.add("open");
@@ -1341,9 +2724,11 @@ function closeModuleSlideout() {
1341
2724
  }
1342
2725
 
1343
2726
  function showEditorView(moduleName) {
2727
+ const slideout = document.getElementById("module-slideout");
2728
+ slideout.classList.remove("hidden");
1344
2729
  document.getElementById("module-list-view").classList.add("hidden");
1345
2730
  document.getElementById("module-editor-view").classList.remove("hidden");
1346
- document.getElementById("module-slideout").classList.add("open");
2731
+ slideout.classList.add("open");
1347
2732
  }
1348
2733
 
1349
2734
  function showModuleListView() {
@@ -1374,7 +2759,7 @@ async function toggleModuleLibraryDropdown() {
1374
2759
  );
1375
2760
 
1376
2761
  if (available.length === 0) {
1377
- dropdown.innerHTML = `<div class="module-library-dropdown__empty">No other modules available</div>`;
2762
+ dropdown.innerHTML = `<div class="module-library-dropdown__empty">No other sections available</div>`;
1378
2763
  } else {
1379
2764
  dropdown.innerHTML = available.map((m) =>
1380
2765
  `<button class="module-library-dropdown__item" data-name="${escapeHtml(m.moduleName)}">
@@ -1424,8 +2809,8 @@ async function addModuleFromLibrary(moduleName) {
1424
2809
  }
1425
2810
  }
1426
2811
 
1427
- // Module bar button toggle slideout
1428
- document.getElementById("btn-modules")?.addEventListener("click", () => {
2812
+ // Toggle module slideout from page-tree header
2813
+ document.getElementById("btn-toggle-modules")?.addEventListener("click", () => {
1429
2814
  const slideout = document.getElementById("module-slideout");
1430
2815
  if (slideout.classList.contains("open")) {
1431
2816
  closeModuleSlideout();
@@ -1460,7 +2845,7 @@ function confirmDeleteModule(moduleName) {
1460
2845
  overlay.innerHTML = `
1461
2846
  <div class="confirm-dialog">
1462
2847
  <div class="confirm-dialog__title">Remove "${escapeHtml(moduleName)}"?</div>
1463
- <p class="confirm-dialog__detail">Module will be removed from this page but kept in your library.</p>
2848
+ <p class="confirm-dialog__detail">Section will be removed from this page but kept in your library.</p>
1464
2849
  <label class="confirm-dialog__toggle">
1465
2850
  <span class="confirm-dialog__toggle-switch">
1466
2851
  <input type="checkbox" data-role="toggle" />
@@ -1650,30 +3035,99 @@ inputEl.addEventListener("keydown", (e) => {
1650
3035
  }
1651
3036
  });
1652
3037
 
1653
- // Auto-grow textarea
1654
- inputEl.addEventListener("input", () => {
3038
+ // Auto-grow textarea — 3 rows default, expand up to 6 rows.
3039
+ // Line-height (1.5) × font-size (14px) × 6 rows + ~8px padding = ~134px.
3040
+ const CHAT_INPUT_MAX_HEIGHT = 134;
3041
+ function autoGrowChatInput() {
1655
3042
  inputEl.style.height = "auto";
1656
- inputEl.style.height = Math.min(inputEl.scrollHeight, 150) + "px";
3043
+ inputEl.style.height = Math.min(inputEl.scrollHeight, CHAT_INPUT_MAX_HEIGHT) + "px";
3044
+ }
3045
+ inputEl.addEventListener("input", () => {
3046
+ autoGrowChatInput();
3047
+
3048
+ // Hide suggestion chips as soon as the user starts typing
3049
+ if (suggestionsVisible && inputEl.value.length > 0) {
3050
+ hideChatSuggestions();
3051
+ }
1657
3052
  });
1658
3053
 
1659
- // Starter template buttons
1660
- document.getElementById("starter-templates").addEventListener("click", (e) => {
1661
- const btn = e.target.closest(".starter-btn");
1662
- if (btn) sendMessage(btn.dataset.prompt);
3054
+ // Contextual placeholder — adapts to plan mode and whether a page already exists.
3055
+ function updateChatPlaceholder() {
3056
+ if (!inputEl) return;
3057
+ const planActive = !!window.planModeActive;
3058
+ const moduleCountEl = document.getElementById("module-count");
3059
+ const moduleCount = moduleCountEl ? parseInt(moduleCountEl.textContent || "0", 10) || 0 : 0;
3060
+ let placeholder;
3061
+ if (planActive) {
3062
+ placeholder = "Describe what you want to plan...";
3063
+ } else if (moduleCount > 0) {
3064
+ placeholder = "Describe what you want to change...";
3065
+ } else {
3066
+ placeholder = "Describe your landing page...";
3067
+ }
3068
+ inputEl.placeholder = placeholder;
3069
+ }
3070
+
3071
+ // Re-evaluate placeholder when plan mode changes (plan.js dispatches this) or
3072
+ // when the module list updates.
3073
+ window.addEventListener("plan-mode-changed", updateChatPlaceholder);
3074
+ const _moduleCountEl = document.getElementById("module-count");
3075
+ if (_moduleCountEl && typeof MutationObserver !== "undefined") {
3076
+ new MutationObserver(updateChatPlaceholder).observe(_moduleCountEl, {
3077
+ childList: true,
3078
+ characterData: true,
3079
+ subtree: true,
3080
+ });
3081
+ }
3082
+ updateChatPlaceholder();
3083
+
3084
+ // Suggestion chip click — pre-fill input and focus, do not auto-send so the
3085
+ // user can edit before pressing Enter.
3086
+ document.getElementById("chat-suggestions")?.addEventListener("click", (e) => {
3087
+ const chip = e.target.closest(".chat__suggestion-chip");
3088
+ if (!chip) return;
3089
+ const text = chip.dataset.suggestion || chip.textContent || "";
3090
+ inputEl.value = text;
3091
+ inputEl.focus();
3092
+ inputEl.setSelectionRange(text.length, text.length);
3093
+ // Trigger the input event so the textarea resizes; this also collapses chips
3094
+ inputEl.dispatchEvent(new Event("input", { bubbles: true }));
3095
+ hideChatSuggestions();
1663
3096
  });
1664
3097
 
1665
- // Templates icon in input area toggle welcome section visibility
3098
+ // Pre-fill chat input from preview select-mode click
3099
+ window.prefillChatInput = function (text) {
3100
+ if (!text) return;
3101
+ const existing = inputEl.value;
3102
+ const prefix = existing.trim() ? existing.replace(/\s+$/, "") + "\n\n" : "";
3103
+ inputEl.value = prefix + text;
3104
+ autoGrowChatInput();
3105
+ inputEl.focus();
3106
+ const end = inputEl.value.length;
3107
+ inputEl.setSelectionRange(end, end);
3108
+ };
3109
+
3110
+ // Conversation starter buttons in chat welcome
3111
+ document.getElementById("conversation-starters")?.addEventListener("click", handleConversationStarterClick);
3112
+
3113
+ // Templates icon in input area — switch to Library tab where project assets live
1666
3114
  document.getElementById("btn-starter-templates")?.addEventListener("click", () => {
1667
- const welcome = document.getElementById("chat-welcome");
1668
- if (welcome) welcome.classList.toggle("hidden");
3115
+ const libraryTab = document.getElementById("ws-tab-library");
3116
+ if (libraryTab) {
3117
+ libraryTab.click();
3118
+ document.getElementById("library-project-assets")?.scrollIntoView({ behavior: "smooth", block: "start" });
3119
+ }
1669
3120
  });
1670
3121
 
1671
- // Version history
3122
+ // Version history (bottom panel)
1672
3123
  document.getElementById("btn-history")?.addEventListener("click", toggleHistoryPanel);
1673
3124
  document.getElementById("history-panel-close")?.addEventListener("click", () => {
1674
3125
  historyPanelOpen = false;
1675
3126
  document.getElementById("history-panel")?.classList.add("hidden");
1676
3127
  });
3128
+ document.getElementById("history-panel-collapse")?.addEventListener("click", () => {
3129
+ document.getElementById("history-panel")?.classList.toggle("collapsed");
3130
+ });
1677
3131
 
1678
3132
  // Import from GitHub is now on the setup screen (setup.js)
1679
3133
 
@@ -1721,11 +3175,6 @@ document.getElementById("responsive-toggle").addEventListener("click", (e) => {
1721
3175
  chrome.style.maxWidth = isFullWidth ? "none" : width;
1722
3176
  chrome.style.flex = isFullWidth ? "1" : "none";
1723
3177
  chrome.style.width = isFullWidth ? "" : width;
1724
-
1725
- // Update browser URL bar with theme name
1726
- const urlEl = document.getElementById("browser-url");
1727
- const themeName = document.getElementById("theme-name")?.textContent || "vibespot.app";
1728
- if (urlEl) urlEl.textContent = themeName + ".vibespot.app";
1729
3178
  });
1730
3179
 
1731
3180
  // ---------------------------------------------------------------------------
@@ -1742,11 +3191,10 @@ async function fetchHsAccountStatus() {
1742
3191
  const hs = data.environment?.tools?.hubspot;
1743
3192
 
1744
3193
  if (hs && hs.authenticated && hs.portalName) {
1745
- pill.innerHTML = `<span class="statusbar__dot statusbar__dot--ok"></span>${hs.portalName}${hs.portalId ? " (" + hs.portalId + ")" : ""}`;
1746
- pill.classList.add("statusbar__pill--visible");
3194
+ const idSuffix = hs.portalId ? ` (${hs.portalId})` : "";
3195
+ pill.innerHTML = `<span class="statusbar__dot statusbar__dot--ok"></span><span class="statusbar__item-text">${escapeHtml(hs.portalName)}${escapeHtml(idSuffix)}</span>`;
1747
3196
  } else {
1748
3197
  pill.textContent = "";
1749
- pill.classList.remove("statusbar__pill--visible");
1750
3198
  }
1751
3199
  } catch {
1752
3200
  // Silently ignore
@@ -1792,6 +3240,7 @@ document.getElementById("theme-name")?.addEventListener("dblclick", () => {
1792
3240
  .then((data) => {
1793
3241
  if (data.ok) {
1794
3242
  el.textContent = data.newName;
3243
+ setStatusTheme(data.newName);
1795
3244
  if (typeof currentAppTheme !== "undefined") currentAppTheme = data.newName;
1796
3245
  window.location.hash = "#/app/" + encodeURIComponent(data.newName);
1797
3246
  // Update rail item