vibespot 1.2.0 → 1.3.1

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 (49) hide show
  1. package/README.md +54 -5
  2. package/assets/blog-rules.md +251 -0
  3. package/assets/email-rules.md +390 -0
  4. package/assets/humanify-guide.md +300 -101
  5. package/assets/plan-templates/blog-content-hub.md +18 -9
  6. package/assets/plan-templates/email-announcement.md +41 -0
  7. package/assets/plan-templates/email-event-invite.md +43 -0
  8. package/assets/plan-templates/email-newsletter.md +41 -0
  9. package/assets/plan-templates/email-re-engagement.md +42 -0
  10. package/assets/plan-templates/email-welcome.md +41 -0
  11. package/dist/index.js +1460 -387
  12. package/dist/index.js.map +1 -1
  13. package/package.json +5 -5
  14. package/starters/06-blog-content-hub.json +75 -0
  15. package/starters/06-email-welcome.json +60 -0
  16. package/starters/07-email-announcement.json +60 -0
  17. package/starters/08-email-newsletter.json +52 -0
  18. package/ui/chat.js +777 -63
  19. package/ui/code-editor.js +49 -7
  20. package/ui/dashboard.js +379 -93
  21. package/ui/docs/docs.css +29 -0
  22. package/ui/docs/index.html +416 -119
  23. package/ui/docs/screenshots/asset-type-cards.png +0 -0
  24. package/ui/docs/screenshots/brand-kit-preview.png +0 -0
  25. package/ui/docs/screenshots/content-type-dropdown.png +0 -0
  26. package/ui/docs/screenshots/deploy-progress.png +0 -0
  27. package/ui/docs/screenshots/editor-full-layout.png +0 -0
  28. package/ui/docs/screenshots/email-client-preview.png +0 -0
  29. package/ui/docs/screenshots/inline-wysiwyg-editing.png +0 -0
  30. package/ui/docs/screenshots/module-overview-slideout.png +0 -0
  31. package/ui/docs/screenshots/multi-page-tree.png +0 -0
  32. package/ui/docs/screenshots/onboarding-walkthrough.png +0 -0
  33. package/ui/docs/screenshots/pipeline-progress.png +0 -0
  34. package/ui/docs/screenshots/project-overview-table.png +0 -0
  35. package/ui/docs/screenshots/split-pane-view.png +0 -0
  36. package/ui/docs/screenshots/visual-controls-toolbar.png +0 -0
  37. package/ui/docs/screenshots/workspace-tabs.png +0 -0
  38. package/ui/email-preview.js +109 -0
  39. package/ui/field-editor.js +72 -1
  40. package/ui/icons.js +120 -0
  41. package/ui/index.html +877 -629
  42. package/ui/inline-edit.js +710 -0
  43. package/ui/plan.js +0 -0
  44. package/ui/preview.js +101 -198
  45. package/ui/section-controls.js +628 -0
  46. package/ui/settings.js +58 -16
  47. package/ui/setup.js +750 -140
  48. package/ui/styles.css +3430 -952
  49. package/ui/upload-panel.js +47 -20
package/ui/chat.js CHANGED
@@ -34,6 +34,10 @@ const inputEl = document.getElementById("chat-input");
34
34
  const sendBtn = document.getElementById("chat-send");
35
35
  const statusText = document.getElementById("status-text");
36
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;
37
41
 
38
42
  // Snapshot the welcome section before any init can destroy it
39
43
  const _welcomeHtml = document.getElementById("chat-welcome")?.outerHTML || "";
@@ -41,15 +45,480 @@ const _welcomeHtml = document.getElementById("chat-welcome")?.outerHTML || "";
41
45
  function restoreWelcome() {
42
46
  if (!_welcomeHtml || messagesEl.querySelector(".chat__welcome")) return;
43
47
  messagesEl.insertAdjacentHTML("afterbegin", _welcomeHtml);
44
- const el = messagesEl.querySelector("#starter-templates");
45
- if (el) {
46
- el.addEventListener("click", (e) => {
47
- const btn = e.target.closest(".starter-btn");
48
- if (btn) sendMessage(btn.dataset.prompt);
49
- });
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;
50
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
+ });
51
499
  }
52
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
+ })();
521
+
53
522
  // ---------------------------------------------------------------------------
54
523
  // WebSocket connection
55
524
  // ---------------------------------------------------------------------------
@@ -103,11 +572,15 @@ function handleWsMessage(msg) {
103
572
  currentSessionId = msg.sessionId || "";
104
573
  currentTemplateId = msg.templateId || "";
105
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;
106
579
 
107
580
  // Clear previous project's chat and module list
108
581
  messagesEl.innerHTML = "";
109
- document.getElementById("module-items").innerHTML = "";
110
- 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";
111
584
  hideChatSuggestions();
112
585
  // Reset pipeline state — DOM nodes were detached by innerHTML clear
113
586
  resetPipelineState();
@@ -118,8 +591,13 @@ function handleWsMessage(msg) {
118
591
  updateModuleList(msg.modules);
119
592
  refreshPreview();
120
593
  }
594
+
595
+ // Render page tabs for multi-page projects
596
+ renderPageTabs(msg.templates, currentTemplateId);
597
+
121
598
  statusEngine.textContent = msg.engine || "";
122
599
  fetchHsAccountStatus();
600
+ if (typeof refreshPortalIndicator === "function") refreshPortalIndicator();
123
601
 
124
602
  // Populate chat header
125
603
  const chatHeaderTitle = document.getElementById("chat-header-title");
@@ -172,6 +650,9 @@ function handleWsMessage(msg) {
172
650
  if (typeof window.setSelectModeDisabled === "function") {
173
651
  window.setSelectModeDisabled(true);
174
652
  }
653
+ if (typeof window.setEditModeDisabled === "function") {
654
+ window.setEditModeDisabled(true);
655
+ }
175
656
  }
176
657
 
177
658
  // If setup handed us an initial prompt (describe-it path), send it now
@@ -201,18 +682,25 @@ function handleWsMessage(msg) {
201
682
  // The next `modules_updated` is the terminal one for this run — let it
202
683
  // fire the change-highlight pass on the preview iframe.
203
684
  highlightOnNextModulesUpdated = true;
685
+ // Refresh the Library tab's module list so it stays in sync
686
+ if (typeof refreshDashboard === "function") refreshDashboard();
204
687
  break;
205
688
 
206
689
  case "modules_updated":
207
690
  if (msg.modules) {
208
691
  updateModuleList(msg.modules);
209
692
  }
693
+ if (msg.templates) {
694
+ if (msg.templateId) currentTemplateId = msg.templateId;
695
+ renderPageTabs(msg.templates, currentTemplateId);
696
+ }
210
697
  if (highlightOnNextModulesUpdated) {
211
698
  highlightOnNextModulesUpdated = false;
212
699
  flushChangeHighlights(msg.modules || []);
213
700
  } else {
214
701
  refreshPreview();
215
702
  }
703
+ markSaved(msg.updatedAt);
216
704
  break;
217
705
 
218
706
  case "version_created":
@@ -220,10 +708,11 @@ function handleWsMessage(msg) {
220
708
  // New generation: reset timeline cursor to head and refresh.
221
709
  historyTimelineCursor = 0;
222
710
  refreshHistoryTimeline();
711
+ markSaved(msg.updatedAt);
223
712
  break;
224
713
 
225
714
  case "parse_warning":
226
- appendSystemMessage(msg.message || "Section changes could not be applied.");
715
+ appendSystemMessage(msg.message || "Module changes could not be applied.");
227
716
  break;
228
717
 
229
718
  case "error":
@@ -279,8 +768,8 @@ function handleWsMessage(msg) {
279
768
  case "needs_setup":
280
769
  // Clear stale UI if shown
281
770
  messagesEl.innerHTML = "";
282
- document.getElementById("module-items").innerHTML = "";
283
- 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"; }
284
773
  break;
285
774
 
286
775
  case "plan_updated":
@@ -556,9 +1045,136 @@ function handleModuleProgress(msg) {
556
1045
  changedModulesInRun.add(msg.module);
557
1046
  }
558
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
+
559
1060
  scrollToBottom();
560
1061
  }
561
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
+
562
1178
  function startPipelineEstimate() {
563
1179
  stopPipelineEstimate();
564
1180
  if (!pipelineEstimateEl) return;
@@ -634,24 +1250,26 @@ function handlePipelineComplete(msg) {
634
1250
  answerEl.className = "pipeline-answer";
635
1251
  answerEl.textContent = msg.answer;
636
1252
  bubble.appendChild(answerEl);
1253
+ } else if (msg.assistantMessage) {
1254
+ streamBuffer = msg.assistantMessage;
637
1255
  }
638
1256
 
1257
+ clearStreamStatus();
1258
+ finishStreaming();
1259
+
639
1260
  const stats = document.createElement("div");
640
1261
  stats.className = "pipeline-stats";
641
1262
  const duration = formatDuration(msg.durationMs);
642
1263
  if (msg.answer) {
643
1264
  stats.textContent = `Answered in ${duration}`;
644
1265
  } else {
645
- stats.textContent = `Generated ${msg.modulesGenerated} section${msg.modulesGenerated === 1 ? "" : "s"} in ${duration}`;
1266
+ stats.textContent = `Generated ${msg.modulesGenerated} module${msg.modulesGenerated === 1 ? "" : "s"} in ${duration}`;
646
1267
  if (msg.modulesUnchanged > 0) {
647
1268
  stats.textContent += ` (${msg.modulesUnchanged} unchanged)`;
648
1269
  }
649
1270
  }
650
1271
  bubble.appendChild(stats);
651
1272
 
652
- clearStreamStatus();
653
- finishStreaming();
654
-
655
1273
  resetPipelineState();
656
1274
  }
657
1275
 
@@ -671,15 +1289,15 @@ function handlePipelinePartial(msg) {
671
1289
 
672
1290
  const bubble = streamingMsgEl || pipelineBubbleEl;
673
1291
 
1292
+ clearStreamStatus();
1293
+ finishStreaming();
1294
+
674
1295
  const stats = document.createElement("div");
675
1296
  stats.className = "pipeline-stats pipeline-stats--partial";
676
1297
  const duration = formatDuration(msg.durationMs);
677
- stats.textContent = `${msg.succeeded.length} sections succeeded, ${msg.failed.length} failed in ${duration}`;
1298
+ stats.textContent = `${msg.succeeded.length} modules succeeded, ${msg.failed.length} failed in ${duration}`;
678
1299
  bubble.appendChild(stats);
679
1300
 
680
- clearStreamStatus();
681
- finishStreaming();
682
-
683
1301
  resetPipelineState();
684
1302
  }
685
1303
 
@@ -710,19 +1328,19 @@ function pickContextualSuggestions(moduleNames) {
710
1328
  const additive = [];
711
1329
 
712
1330
  if (!has("testimonial") && !has("review") && !has("quote")) {
713
- additive.push("Add a testimonials section");
1331
+ additive.push("Add a testimonials module");
714
1332
  }
715
1333
  if (!has("pricing") && !has("plan") && !has("tier")) {
716
1334
  additive.push("Add a pricing table");
717
1335
  }
718
1336
  if (!has("faq") && !has("question")) {
719
- additive.push("Add an FAQ section");
1337
+ additive.push("Add an FAQ module");
720
1338
  }
721
1339
  if (!has("contact") && !has("form")) {
722
1340
  additive.push("Add a contact form");
723
1341
  }
724
1342
  if (!has("hero") && !has("banner")) {
725
- additive.push("Add a hero section");
1343
+ additive.push("Add a hero module");
726
1344
  }
727
1345
  if (!has("feature") && !has("benefit")) {
728
1346
  additive.push("Add a features grid with icons");
@@ -731,10 +1349,10 @@ function pickContextualSuggestions(moduleNames) {
731
1349
  additive.push("Add a footer with social links");
732
1350
  }
733
1351
  if (!has("stat") && !has("metric") && !has("number")) {
734
- additive.push("Add a stats section with key numbers");
1352
+ additive.push("Add a stats module with key numbers");
735
1353
  }
736
1354
  if (!has("cta") && !has("call")) {
737
- additive.push("Add a call-to-action section");
1355
+ additive.push("Add a call-to-action module");
738
1356
  }
739
1357
 
740
1358
  const refinements = [
@@ -803,12 +1421,12 @@ async function handleAgenticPrompt() {
803
1421
  "vibeSpot can decompose AI generation into specialized agents:\n\n" +
804
1422
  "• Intent Analyzer — classifies your request\n" +
805
1423
  "• Page Architect — designs the page structure\n" +
806
- "• Section Developer — generates each section in parallel\n" +
1424
+ "• Module Developer — generates each module in parallel\n" +
807
1425
  "• Validator — checks and auto-fixes errors\n\n" +
808
1426
  "Tradeoffs:\n" +
809
1427
  "✓ Better quality — each agent is focused on one task\n" +
810
1428
  "✓ Structured output — eliminates JSON parsing failures\n" +
811
- "✓ Only changed sections regenerated on edits\n" +
1429
+ "✓ Only changed modules regenerated on edits\n" +
812
1430
  "✗ Uses more calls per request (API calls or CLI subprocess calls)\n\n" +
813
1431
  "You can change this anytime in Settings.",
814
1432
  "Use Agentic Pipeline",
@@ -848,8 +1466,11 @@ function handleSuggestBrandExtraction() {
848
1466
  scrollToBottom();
849
1467
 
850
1468
  el.querySelector("#btn-accept-extraction").addEventListener("click", () => {
851
- el.querySelector(".brand-extraction-prompt__actions").innerHTML =
852
- '<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
+ }
853
1474
  if (ws && ws.readyState === WebSocket.OPEN) {
854
1475
  ws.send(JSON.stringify({ type: "extract_brand_assets" }));
855
1476
  }
@@ -1064,6 +1685,7 @@ async function sendMessage(text) {
1064
1685
  );
1065
1686
  changedModulesInRun = new Set();
1066
1687
  clearModuleListChanged();
1688
+ if (typeof window.resetHublCheck === "function") window.resetHublCheck();
1067
1689
 
1068
1690
  // Start streaming indicator
1069
1691
  startStreaming();
@@ -1119,6 +1741,9 @@ function startStreaming() {
1119
1741
  if (typeof window.setSelectModeDisabled === "function") {
1120
1742
  window.setSelectModeDisabled(true);
1121
1743
  }
1744
+ if (typeof window.setEditModeDisabled === "function") {
1745
+ window.setEditModeDisabled(true);
1746
+ }
1122
1747
 
1123
1748
  hideChatSuggestions();
1124
1749
  if (typeof updateHistoryTimelineNavState === "function") updateHistoryTimelineNavState();
@@ -1267,6 +1892,9 @@ function finishStreaming() {
1267
1892
  if (typeof window.setSelectModeDisabled === "function") {
1268
1893
  window.setSelectModeDisabled(false);
1269
1894
  }
1895
+ if (typeof window.setEditModeDisabled === "function") {
1896
+ window.setEditModeDisabled(false);
1897
+ }
1270
1898
  if (typeof updateHistoryTimelineNavState === "function") updateHistoryTimelineNavState();
1271
1899
 
1272
1900
  // Stop the timer and capture duration
@@ -1293,7 +1921,7 @@ function finishStreaming() {
1293
1921
  if (streamingMsgEl && streamBuffer) {
1294
1922
  const rendered = renderMarkdown(streamBuffer);
1295
1923
  const visibleText = rendered.replace(/<[^>]*>/g, "").trim();
1296
- streamingMsgEl.innerHTML = visibleText ? rendered : "<em>Sections applied.</em>";
1924
+ streamingMsgEl.innerHTML = visibleText ? rendered : "<em>Modules applied.</em>";
1297
1925
  }
1298
1926
 
1299
1927
  streamingMsgEl = null;
@@ -1389,6 +2017,46 @@ function setStatus(text) {
1389
2017
  statusText.textContent = text;
1390
2018
  }
1391
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
+
1392
2060
  // ---------------------------------------------------------------------------
1393
2061
  // Restored / system messages
1394
2062
  // ---------------------------------------------------------------------------
@@ -1436,7 +2104,7 @@ function appendRestoredAssistantMessage(text, timestamp, pipeline) {
1436
2104
  : "";
1437
2105
 
1438
2106
  const duration = formatDuration(pipeline.stats.durationMs);
1439
- let statsText = `Generated ${pipeline.stats.modulesGenerated} section${pipeline.stats.modulesGenerated === 1 ? "" : "s"} in ${duration}`;
2107
+ let statsText = `Generated ${pipeline.stats.modulesGenerated} module${pipeline.stats.modulesGenerated === 1 ? "" : "s"} in ${duration}`;
1440
2108
  if (pipeline.stats.modulesUnchanged > 0) {
1441
2109
  statsText += ` (${pipeline.stats.modulesUnchanged} unchanged)`;
1442
2110
  }
@@ -1574,7 +2242,7 @@ function attachHistoryToggle() {
1574
2242
  async function doRollback(hash) {
1575
2243
  const scoped = currentTemplateId && !historyShowAll;
1576
2244
  const msg = scoped
1577
- ? "This template's sections will be restored to the selected version. Other templates are not affected."
2245
+ ? "This template's modules will be restored to the selected version. Other templates are not affected."
1578
2246
  : "All theme files will be replaced, but chat history is preserved.";
1579
2247
  const ok = await vibeConfirm("Restore this version?", msg, { confirmLabel: "Restore", confirmClass: "btn--primary" });
1580
2248
  if (!ok) return;
@@ -1740,10 +2408,16 @@ async function refreshHistoryTimeline() {
1740
2408
  function updateHistoryTimelineNavState() {
1741
2409
  const undoBtn = document.getElementById("history-timeline-undo");
1742
2410
  const redoBtn = document.getElementById("history-timeline-redo");
2411
+ const topbarUndo = document.getElementById("btn-undo");
2412
+ const topbarRedo = document.getElementById("btn-redo");
1743
2413
  const max = historyTimelineEntries.length - 1;
1744
2414
  const busy = historyTimelineRestoring || (typeof isStreaming !== "undefined" && isStreaming);
1745
- if (undoBtn) undoBtn.disabled = busy || historyTimelineCursor >= max;
1746
- if (redoBtn) redoBtn.disabled = busy || historyTimelineCursor <= 0;
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;
1747
2421
  }
1748
2422
 
1749
2423
  async function restoreToTimelineIndex(idx) {
@@ -1809,7 +2483,7 @@ function showTimelineTooltip(entryEl) {
1809
2483
  const more = (commit.changedModules || []).length - modules.length;
1810
2484
  const modulesLine = modules.length
1811
2485
  ? modules.join(", ") + (more > 0 ? ` +${more} more` : "")
1812
- : "No section changes";
2486
+ : "No module changes";
1813
2487
 
1814
2488
  // Strip [templateId] prefix for display; keep "Rollback to:" prefix because
1815
2489
  // that case is filtered out earlier and won't reach here.
@@ -1875,6 +2549,13 @@ function hideTimelineTooltip() {
1875
2549
  if (undoBtn) undoBtn.addEventListener("click", timelineUndo);
1876
2550
  if (redoBtn) redoBtn.addEventListener("click", timelineRedo);
1877
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
+
1878
2559
  // Global Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z keyboard shortcuts. Skip when the
1879
2560
  // user is editing text so we never hijack native undo in inputs/editors.
1880
2561
  document.addEventListener("keydown", (e) => {
@@ -1913,6 +2594,8 @@ function updateModuleList(moduleNames) {
1913
2594
 
1914
2595
  if (barCountEl) barCountEl.textContent = moduleNames.length;
1915
2596
  if (slideoutCountEl) slideoutCountEl.textContent = moduleNames.length;
2597
+ const pageTreeCountEl = document.getElementById("page-tree-module-count");
2598
+ if (pageTreeCountEl) pageTreeCountEl.textContent = moduleNames.length;
1916
2599
 
1917
2600
  // Preserve which items were marked as recently changed across re-renders so
1918
2601
  // the dots survive the per-module `modules_updated` events that happen
@@ -1931,7 +2614,7 @@ function updateModuleList(moduleNames) {
1931
2614
  <span class="module-item__drag">⠿</span>
1932
2615
  <span class="module-item__changed-dot" aria-hidden="true"></span>
1933
2616
  <span class="module-item__name">${escapeHtml(name)}</span>
1934
- <span class="module-item__edit" title="Edit fields">⚙</span>
2617
+ <span class="module-item__edit" title="Edit fields">${vsIcon("settings", {size: "sm"})}</span>
1935
2618
  <span class="module-item__delete" title="Delete section">&times;</span>
1936
2619
  `;
1937
2620
 
@@ -2030,6 +2713,7 @@ function clearModuleListChanged() {
2030
2713
 
2031
2714
  function openModuleSlideout() {
2032
2715
  const slideout = document.getElementById("module-slideout");
2716
+ slideout.classList.remove("hidden");
2033
2717
  document.getElementById("module-list-view").classList.remove("hidden");
2034
2718
  document.getElementById("module-editor-view").classList.add("hidden");
2035
2719
  slideout.classList.add("open");
@@ -2040,9 +2724,11 @@ function closeModuleSlideout() {
2040
2724
  }
2041
2725
 
2042
2726
  function showEditorView(moduleName) {
2727
+ const slideout = document.getElementById("module-slideout");
2728
+ slideout.classList.remove("hidden");
2043
2729
  document.getElementById("module-list-view").classList.add("hidden");
2044
2730
  document.getElementById("module-editor-view").classList.remove("hidden");
2045
- document.getElementById("module-slideout").classList.add("open");
2731
+ slideout.classList.add("open");
2046
2732
  }
2047
2733
 
2048
2734
  function showModuleListView() {
@@ -2123,8 +2809,8 @@ async function addModuleFromLibrary(moduleName) {
2123
2809
  }
2124
2810
  }
2125
2811
 
2126
- // Module bar button toggle slideout
2127
- document.getElementById("btn-modules")?.addEventListener("click", () => {
2812
+ // Toggle module slideout from page-tree header
2813
+ document.getElementById("btn-toggle-modules")?.addEventListener("click", () => {
2128
2814
  const slideout = document.getElementById("module-slideout");
2129
2815
  if (slideout.classList.contains("open")) {
2130
2816
  closeModuleSlideout();
@@ -2349,10 +3035,15 @@ inputEl.addEventListener("keydown", (e) => {
2349
3035
  }
2350
3036
  });
2351
3037
 
2352
- // Auto-grow textarea
2353
- 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() {
2354
3042
  inputEl.style.height = "auto";
2355
- 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();
2356
3047
 
2357
3048
  // Hide suggestion chips as soon as the user starts typing
2358
3049
  if (suggestionsVisible && inputEl.value.length > 0) {
@@ -2360,6 +3051,36 @@ inputEl.addEventListener("input", () => {
2360
3051
  }
2361
3052
  });
2362
3053
 
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
+
2363
3084
  // Suggestion chip click — pre-fill input and focus, do not auto-send so the
2364
3085
  // user can edit before pressing Enter.
2365
3086
  document.getElementById("chat-suggestions")?.addEventListener("click", (e) => {
@@ -2380,35 +3101,33 @@ window.prefillChatInput = function (text) {
2380
3101
  const existing = inputEl.value;
2381
3102
  const prefix = existing.trim() ? existing.replace(/\s+$/, "") + "\n\n" : "";
2382
3103
  inputEl.value = prefix + text;
2383
- inputEl.style.height = "auto";
2384
- inputEl.style.height = Math.min(inputEl.scrollHeight, 150) + "px";
3104
+ autoGrowChatInput();
2385
3105
  inputEl.focus();
2386
3106
  const end = inputEl.value.length;
2387
3107
  inputEl.setSelectionRange(end, end);
2388
3108
  };
2389
3109
 
2390
- // Starter template buttons
2391
- document.getElementById("starter-templates").addEventListener("click", (e) => {
2392
- const btn = e.target.closest(".starter-btn");
2393
- if (btn) sendMessage(btn.dataset.prompt);
2394
- });
3110
+ // Conversation starter buttons in chat welcome
3111
+ document.getElementById("conversation-starters")?.addEventListener("click", handleConversationStarterClick);
2395
3112
 
2396
- // Templates icon in input area — toggle welcome section visibility
3113
+ // Templates icon in input area — switch to Library tab where project assets live
2397
3114
  document.getElementById("btn-starter-templates")?.addEventListener("click", () => {
2398
- let welcome = document.getElementById("chat-welcome");
2399
- if (!welcome) {
2400
- restoreWelcome();
2401
- welcome = document.getElementById("chat-welcome");
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" });
2402
3119
  }
2403
- if (welcome) welcome.classList.toggle("hidden");
2404
3120
  });
2405
3121
 
2406
- // Version history
3122
+ // Version history (bottom panel)
2407
3123
  document.getElementById("btn-history")?.addEventListener("click", toggleHistoryPanel);
2408
3124
  document.getElementById("history-panel-close")?.addEventListener("click", () => {
2409
3125
  historyPanelOpen = false;
2410
3126
  document.getElementById("history-panel")?.classList.add("hidden");
2411
3127
  });
3128
+ document.getElementById("history-panel-collapse")?.addEventListener("click", () => {
3129
+ document.getElementById("history-panel")?.classList.toggle("collapsed");
3130
+ });
2412
3131
 
2413
3132
  // Import from GitHub is now on the setup screen (setup.js)
2414
3133
 
@@ -2456,11 +3175,6 @@ document.getElementById("responsive-toggle").addEventListener("click", (e) => {
2456
3175
  chrome.style.maxWidth = isFullWidth ? "none" : width;
2457
3176
  chrome.style.flex = isFullWidth ? "1" : "none";
2458
3177
  chrome.style.width = isFullWidth ? "" : width;
2459
-
2460
- // Update browser URL bar with theme name
2461
- const urlEl = document.getElementById("browser-url");
2462
- const themeName = document.getElementById("theme-name")?.textContent || "vibespot.app";
2463
- if (urlEl) urlEl.textContent = themeName + ".vibespot.app";
2464
3178
  });
2465
3179
 
2466
3180
  // ---------------------------------------------------------------------------
@@ -2477,11 +3191,10 @@ async function fetchHsAccountStatus() {
2477
3191
  const hs = data.environment?.tools?.hubspot;
2478
3192
 
2479
3193
  if (hs && hs.authenticated && hs.portalName) {
2480
- pill.innerHTML = `<span class="statusbar__dot statusbar__dot--ok"></span>${hs.portalName}${hs.portalId ? " (" + hs.portalId + ")" : ""}`;
2481
- 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>`;
2482
3196
  } else {
2483
3197
  pill.textContent = "";
2484
- pill.classList.remove("statusbar__pill--visible");
2485
3198
  }
2486
3199
  } catch {
2487
3200
  // Silently ignore
@@ -2527,6 +3240,7 @@ document.getElementById("theme-name")?.addEventListener("dblclick", () => {
2527
3240
  .then((data) => {
2528
3241
  if (data.ok) {
2529
3242
  el.textContent = data.newName;
3243
+ setStatusTheme(data.newName);
2530
3244
  if (typeof currentAppTheme !== "undefined") currentAppTheme = data.newName;
2531
3245
  window.location.hash = "#/app/" + encodeURIComponent(data.newName);
2532
3246
  // Update rail item