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/setup.js CHANGED
@@ -1,25 +1,106 @@
1
- /* Theme init — runs synchronously before DOM to prevent flash */
1
+ /* Theme init — runs synchronously before DOM to prevent flash.
2
+ Light is the default to match HubSpot's light-first ecosystem. */
3
+ const VIBESPOT_THEMES = ["dark", "light", "hubspot"];
4
+ const VIBESPOT_THEME_LABELS = {
5
+ dark: "Dark",
6
+ light: "Light",
7
+ hubspot: "HubSpot Light",
8
+ };
2
9
  (function initTheme() {
3
10
  const stored = localStorage.getItem("vibespot-theme");
4
- const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
5
- const theme = stored || (prefersDark ? "dark" : "light");
11
+ const theme = VIBESPOT_THEMES.includes(stored) ? stored : "light";
6
12
  document.documentElement.setAttribute("data-theme", theme);
7
13
  })();
8
14
 
15
+ function syncThemeToggleLabel() {
16
+ const btn = document.querySelector(".theme-toggle");
17
+ if (!btn) return;
18
+ const current = document.documentElement.getAttribute("data-theme") || "dark";
19
+ const idx = VIBESPOT_THEMES.indexOf(current);
20
+ const next = VIBESPOT_THEMES[(idx + 1) % VIBESPOT_THEMES.length];
21
+ const label = `Theme: ${VIBESPOT_THEME_LABELS[current] || current} — switch to ${VIBESPOT_THEME_LABELS[next] || next}`;
22
+ btn.setAttribute("title", label);
23
+ btn.setAttribute("aria-label", label);
24
+ }
25
+ document.addEventListener("DOMContentLoaded", syncThemeToggleLabel);
26
+
9
27
  function toggleTheme() {
10
28
  const current = document.documentElement.getAttribute("data-theme") || "dark";
11
- const next = current === "dark" ? "light" : "dark";
29
+ const idx = VIBESPOT_THEMES.indexOf(current);
30
+ const next = VIBESPOT_THEMES[((idx === -1 ? 0 : idx) + 1) % VIBESPOT_THEMES.length];
12
31
  document.documentElement.setAttribute("data-theme", next);
13
32
  localStorage.setItem("vibespot-theme", next);
33
+ syncThemeToggleLabel();
14
34
  }
15
35
 
36
+ /* ---------------------------------------------------------------------------
37
+ * HubSpot portal indicator (topbar) — visible in both Project Home and Editor
38
+ * Reads /api/settings/status and reflects the active HubSpot portal's
39
+ * connection state. Clicking opens Settings (HubSpot tab).
40
+ * ------------------------------------------------------------------------- */
41
+ async function refreshPortalIndicator() {
42
+ const link = document.getElementById("topbar-portal-indicator");
43
+ const label = document.getElementById("topbar-portal-label");
44
+ if (!link || !label) return;
45
+ try {
46
+ const res = await fetch("/api/settings/status");
47
+ const data = await res.json();
48
+ const hs = data && data.environment && data.environment.tools && data.environment.tools.hubspot;
49
+ if (hs && hs.authenticated && hs.portalName) {
50
+ const portal = hs.portalId ? `${hs.portalName} (${hs.portalId})` : hs.portalName;
51
+ link.classList.add("portal-indicator--connected");
52
+ link.classList.remove("portal-indicator--disconnected");
53
+ link.setAttribute("title", `Connected to HubSpot portal ${portal}`);
54
+ link.setAttribute("aria-label", `Connected to HubSpot portal ${portal}`);
55
+ label.textContent = portal;
56
+ } else {
57
+ link.classList.remove("portal-indicator--connected");
58
+ link.classList.add("portal-indicator--disconnected");
59
+ link.setAttribute("title", "No HubSpot portal connected — open Settings to add one");
60
+ link.setAttribute("aria-label", "No HubSpot portal connected — open Settings to add one");
61
+ label.textContent = "Not connected";
62
+ }
63
+ } catch {
64
+ // Server unreachable — leave the disconnected state in place.
65
+ }
66
+ }
67
+
68
+ function bindPortalIndicator() {
69
+ const link = document.getElementById("topbar-portal-indicator");
70
+ if (!link || link.dataset.bound === "1") return;
71
+ link.dataset.bound = "1";
72
+ link.addEventListener("click", (event) => {
73
+ event.preventDefault();
74
+ // In Editor mode, the workspace tab "settings" exists; otherwise the
75
+ // Project Home settings button opens the same overlay.
76
+ const editorTab = document.getElementById("ws-tab-settings");
77
+ const setupBtn = document.getElementById("btn-setup-settings");
78
+ const appBody = document.getElementById("app-body");
79
+ const isEditor = appBody && appBody.getAttribute("data-mode") === "editor";
80
+ const target = isEditor ? editorTab : setupBtn;
81
+ if (target && typeof target.click === "function") target.click();
82
+ else if (editorTab) editorTab.click();
83
+ else if (setupBtn) setupBtn.click();
84
+ });
85
+ }
86
+
87
+ document.addEventListener("DOMContentLoaded", () => {
88
+ bindPortalIndicator();
89
+ refreshPortalIndicator();
90
+ });
91
+
92
+ // Re-poll occasionally so a freshly-saved PAT shows up without reload.
93
+ setInterval(() => { refreshPortalIndicator(); }, 30000);
94
+
16
95
  /**
17
96
  * Setup screen — onboarding flow in the browser.
18
97
  * Handles theme creation, fetching, opening, and session resume.
19
98
  */
20
99
 
21
100
  const setupScreen = document.getElementById("setup-screen");
22
- const appScreen = document.getElementById("app-screen");
101
+ const appScreen = document.getElementById("editor");
102
+ const appBody = document.getElementById("app-body");
103
+ let _serverContentMode = "page";
23
104
 
24
105
  // ---------------------------------------------------------------------------
25
106
  // Load setup info on page load
@@ -36,11 +117,11 @@ const ENGINE_DISPLAY_NAMES = {
36
117
 
37
118
  async function initSetup() {
38
119
  try {
39
- // Show loading spinner in rail while fetching
40
- const railItems = document.getElementById("project-rail-items");
41
- if (railItems) {
42
- railItems.innerHTML = `
43
- <div class="project-rail__loading">
120
+ // Show loading spinner in switcher while fetching
121
+ const switcherList = document.getElementById("project-switcher-list");
122
+ if (switcherList) {
123
+ switcherList.innerHTML = `
124
+ <div class="project-switcher__loading">
44
125
  <div class="setup__spinner"></div>
45
126
  <span>Loading projects...</span>
46
127
  </div>`;
@@ -49,9 +130,12 @@ async function initSetup() {
49
130
  const res = await fetch("/api/setup");
50
131
  const info = await res.json();
51
132
 
52
- // Populate the project rail with all projects
133
+ // Populate the project switcher with all projects (used in editor mode)
53
134
  populateProjectRail(info);
54
135
 
136
+ // Show "Continue where you left off" cards above the create options
137
+ populateRecentProjects(info);
138
+
55
139
  // Auto-select engine if available but not yet chosen
56
140
  if (info.availableEngines && info.availableEngines.length > 0 && !info.activeEngine) {
57
141
  const engine = info.availableEngines[0];
@@ -93,22 +177,118 @@ async function initSetup() {
93
177
  }, 0);
94
178
  }
95
179
 
96
- // Check if we should show the walkthrough (fresh environment)
180
+ // First-visit product intro: 3-step walkthrough explaining vibeSpot.
181
+ // Add ?intro to URL to force-show it for testing.
182
+ const params = new URLSearchParams(location.search);
183
+ const introSeen = localStorage.getItem(INTRO_SEEN_KEY) === "1";
184
+ const isFreshUser = info.sessions.length === 0 && info.localThemes.length === 0;
185
+ if (params.has("intro") || (!introSeen && isFreshUser)) {
186
+ showIntroWalkthrough(info);
187
+ return;
188
+ }
189
+
190
+ // Check if we should show the engine-setup walkthrough (fresh environment).
97
191
  // Add ?walkthrough to URL to force-show it for testing
98
- if (new URLSearchParams(location.search).has("walkthrough") ||
99
- (!info.aiAvailable && info.sessions.length === 0 && info.localThemes.length === 0)) {
192
+ if (params.has("walkthrough") ||
193
+ (!info.aiAvailable && isFreshUser)) {
100
194
  showWalkthrough();
101
195
  return;
102
196
  }
103
197
 
198
+ // Track server content mode (email vs page)
199
+ _serverContentMode = info.contentMode || "page";
200
+
104
201
  // Reset panel state
105
202
  remoteThemesLoaded = false;
106
203
 
204
+ // Reset starter cache so each visit re-fetches from server
205
+ _startersCache = null;
206
+
207
+ // Set warm time-of-day greeting; show asset-type cards as the primary entry.
208
+ initGuidedEntry();
209
+
210
+ // Reset to cards-first state (no panel, prompt hidden).
211
+ activePanel = null;
212
+ showAssetTypeCards();
213
+
107
214
  } catch (err) {
108
215
  showError("Could not connect to server. Is vibeSpot running?");
109
216
  }
110
217
  }
111
218
 
219
+ // ---------------------------------------------------------------------------
220
+ // Guided entry — time-of-day greeting + asset-type card flow (VIB-255)
221
+ // ---------------------------------------------------------------------------
222
+
223
+ function initGuidedEntry() {
224
+ const textEl = document.getElementById("setup-greeting-text");
225
+ if (!textEl) return;
226
+ const hour = new Date().getHours();
227
+ let greeting = "Welcome";
228
+ if (hour < 5) greeting = "Working late";
229
+ else if (hour < 12) greeting = "Good morning";
230
+ else if (hour < 17) greeting = "Good afternoon";
231
+ else greeting = "Good evening";
232
+ textEl.textContent = greeting;
233
+ }
234
+
235
+ let _selectedAssetType = null;
236
+
237
+ function showAssetTypeCards() {
238
+ const cards = document.getElementById("setup-type-cards");
239
+ const promptCard = document.getElementById("setup-prompt-card");
240
+ const recent = document.getElementById("setup-recent");
241
+ const promptInput = document.getElementById("setup-prompt-input");
242
+ const question = document.getElementById("setup-question");
243
+ const importPanel = document.getElementById("setup-import-sources");
244
+ if (cards) cards.classList.remove("hidden");
245
+ if (question) question.classList.remove("hidden");
246
+ if (importPanel) importPanel.classList.add("hidden");
247
+ if (promptCard) {
248
+ promptCard.classList.add("hidden");
249
+ promptCard.dataset.assetType = "";
250
+ }
251
+ if (recent && recent.dataset.hasItems === "1") recent.classList.remove("hidden");
252
+ if (promptInput) {
253
+ promptInput.value = "";
254
+ const submit = document.getElementById("setup-prompt-submit");
255
+ if (submit) submit.disabled = true;
256
+ }
257
+ // Close any open advanced panel (e.g. starter grid) when returning to cards.
258
+ document.querySelectorAll(".setup__panel").forEach((p) => p.classList.add("hidden"));
259
+ document.querySelectorAll(".setup__action-btn").forEach((b) => b.classList.remove("active"));
260
+ activePanel = null;
261
+ _selectedAssetType = null;
262
+ }
263
+
264
+ function showScopedPrompt(card) {
265
+ const cards = document.getElementById("setup-type-cards");
266
+ const promptCard = document.getElementById("setup-prompt-card");
267
+ const recent = document.getElementById("setup-recent");
268
+ const eyebrow = document.getElementById("setup-prompt-eyebrow");
269
+ const input = document.getElementById("setup-prompt-input");
270
+ if (!promptCard || !input) return;
271
+
272
+ const assetType = card.dataset.assetType || "landing-page";
273
+ const placeholder = card.dataset.promptPlaceholder || "Describe what you want to build...";
274
+ const label = card.dataset.promptEyebrow || "Project";
275
+
276
+ _selectedAssetType = assetType;
277
+ promptCard.dataset.assetType = assetType;
278
+ if (eyebrow) eyebrow.textContent = label;
279
+ input.placeholder = placeholder;
280
+ input.setAttribute("aria-label", placeholder.replace(/\.\.\.$/, ""));
281
+
282
+ // Stash the selected type so downstream session/chat code can read it later.
283
+ window.__pendingAssetType = assetType;
284
+
285
+ if (cards) cards.classList.add("hidden");
286
+ if (recent) recent.classList.add("hidden");
287
+ promptCard.classList.remove("hidden");
288
+
289
+ setTimeout(() => input.focus(), 60);
290
+ }
291
+
112
292
  async function saveAlertApiKey(key) {
113
293
  if (!key) return;
114
294
  // Detect provider from key prefix
@@ -159,6 +339,9 @@ function deduplicateProjects(info) {
159
339
  updatedAt: s.updatedAt,
160
340
  moduleCount: s.moduleCount ?? null,
161
341
  templateCount: s.templateCount ?? null,
342
+ pageCount: s.pageCount ?? 0,
343
+ emailCount: s.emailCount ?? 0,
344
+ hasBrandAssets: s.hasBrandAssets ?? false,
162
345
  });
163
346
  }
164
347
  }
@@ -174,6 +357,9 @@ function deduplicateProjects(info) {
174
357
  updatedAt: null,
175
358
  moduleCount: typeof t === "object" ? t.moduleCount ?? null : null,
176
359
  templateCount: null,
360
+ pageCount: 0,
361
+ emailCount: 0,
362
+ hasBrandAssets: false,
177
363
  });
178
364
  }
179
365
  }
@@ -182,40 +368,115 @@ function deduplicateProjects(info) {
182
368
  }
183
369
 
184
370
  // ---------------------------------------------------------------------------
185
- // Collapsible Project Rail (expanded on setup, collapsed on dashboard/chat)
371
+ // "Continue where you left off" recent projects above the create options
372
+ // ---------------------------------------------------------------------------
373
+
374
+ const RECENT_PROJECTS_LIMIT = 4;
375
+ let _allProjects = [];
376
+
377
+ function populateRecentProjects(info) {
378
+ const section = document.getElementById("setup-recent");
379
+ const list = document.getElementById("setup-recent-list");
380
+ const viewAll = document.getElementById("setup-recent-all");
381
+ if (!section || !list) return;
382
+
383
+ const projects = deduplicateProjects(info);
384
+ if (projects.length === 0) {
385
+ _allProjects = [];
386
+ section.classList.add("hidden");
387
+ section.dataset.hasItems = "0";
388
+ list.innerHTML = "";
389
+ return;
390
+ }
391
+ section.dataset.hasItems = "1";
392
+
393
+ // Most recently updated first; locals (no updatedAt) follow
394
+ const withTime = projects.filter((p) => p.updatedAt).sort((a, b) => b.updatedAt - a.updatedAt);
395
+ const withoutTime = projects.filter((p) => !p.updatedAt);
396
+ const ordered = [...withTime, ...withoutTime];
397
+ _allProjects = ordered;
398
+ const top = ordered.slice(0, RECENT_PROJECTS_LIMIT);
399
+
400
+ list.innerHTML = "";
401
+ for (const p of top) {
402
+ const card = document.createElement("button");
403
+ card.type = "button";
404
+ card.className = "setup__recent-card";
405
+
406
+ const initial = p.name.charAt(0).toUpperCase();
407
+ const meta = p.updatedAt ? timeAgo(p.updatedAt) : "on disk";
408
+
409
+ card.innerHTML =
410
+ `<span class="setup__recent-card-bubble">${esc(initial)}</span>` +
411
+ `<span class="setup__recent-card-text">` +
412
+ `<span class="setup__recent-card-name">${esc(p.name)}</span>` +
413
+ `<span class="setup__recent-card-meta">${esc(meta)}</span>` +
414
+ `</span>`;
415
+
416
+ const delBtn = document.createElement("button");
417
+ delBtn.type = "button";
418
+ delBtn.className = "setup__recent-card-delete";
419
+ delBtn.innerHTML = "&times;";
420
+ delBtn.title = "Delete project";
421
+ delBtn.addEventListener("click", (e) => {
422
+ e.stopPropagation();
423
+ confirmDeleteProject(p);
424
+ });
425
+ card.appendChild(delBtn);
426
+
427
+ card.addEventListener("click", () => {
428
+ if (typeof isStreaming !== "undefined" && isStreaming) {
429
+ showError("Cannot switch projects while AI is generating.");
430
+ return;
431
+ }
432
+ if (p.sessionId) resumeSession(p.sessionId);
433
+ else openTheme(p.name);
434
+ });
435
+ list.appendChild(card);
436
+ }
437
+
438
+ if (viewAll) viewAll.classList.toggle("hidden", projects.length <= top.length);
439
+ section.classList.remove("hidden");
440
+ }
441
+
442
+ // ---------------------------------------------------------------------------
443
+ // Project switcher (rendered into the editor-mode rail popover)
186
444
  // ---------------------------------------------------------------------------
187
445
 
188
446
  const railTooltip = document.getElementById("project-rail-tooltip");
189
447
 
448
+ /**
449
+ * Populate the project switcher menu with all projects. Item DOM uses the
450
+ * stable `.project-rail__item*` class names so the rename / delete logic in
451
+ * chat.js + setup.js can keep targeting them.
452
+ */
190
453
  function populateProjectRail(info) {
191
- const rail = document.getElementById("project-rail-items");
192
- const countEl = document.getElementById("rail-project-count");
193
- if (!rail) return;
194
- rail.innerHTML = "";
454
+ const list = document.getElementById("project-switcher-list");
455
+ if (!list) return;
456
+ list.innerHTML = "";
195
457
 
196
458
  const projects = deduplicateProjects(info);
197
- if (countEl) countEl.textContent = projects.length;
198
459
 
199
460
  if (projects.length === 0) {
200
- rail.innerHTML = '<div class="project-rail__empty">No projects yet.<br>Create one to get started.</div>';
461
+ list.innerHTML = '<div class="project-switcher__empty">No projects yet.<br>Create one to get started.</div>';
201
462
  return;
202
463
  }
203
464
 
204
465
  for (const p of projects) {
205
- const item = document.createElement("div");
466
+ const item = document.createElement("button");
467
+ item.type = "button";
206
468
  item.className = "project-rail__item";
207
469
  item.dataset.name = p.name;
470
+ if (p.sessionId) item.dataset.sessionId = p.sessionId;
208
471
 
209
472
  const initial = p.name.charAt(0).toUpperCase();
210
473
  const meta = p.updatedAt ? timeAgo(p.updatedAt) : "on disk";
211
474
 
212
- // Bubble (always visible — in collapsed mode this is the only thing shown)
213
475
  const bubble = document.createElement("div");
214
476
  bubble.className = "project-rail__item-bubble";
215
477
  bubble.textContent = initial;
216
478
  item.appendChild(bubble);
217
479
 
218
- // Info (visible when expanded via CSS)
219
480
  const infoEl = document.createElement("div");
220
481
  infoEl.className = "project-rail__item-info";
221
482
  infoEl.innerHTML = `
@@ -223,7 +484,6 @@ function populateProjectRail(info) {
223
484
  <span class="project-rail__item-meta">${meta}</span>`;
224
485
  item.appendChild(infoEl);
225
486
 
226
- // Double-click on name to rename
227
487
  const nameSpan = infoEl.querySelector(".project-rail__item-name");
228
488
  if (nameSpan) {
229
489
  nameSpan.addEventListener("dblclick", (e) => {
@@ -232,8 +492,8 @@ function populateProjectRail(info) {
232
492
  });
233
493
  }
234
494
 
235
- // Delete button (visible when expanded + hover)
236
495
  const delBtn = document.createElement("button");
496
+ delBtn.type = "button";
237
497
  delBtn.className = "project-rail__item-delete";
238
498
  delBtn.innerHTML = "&times;";
239
499
  delBtn.title = "Delete project";
@@ -243,44 +503,17 @@ function populateProjectRail(info) {
243
503
  });
244
504
  item.appendChild(delBtn);
245
505
 
246
- // Tooltip (only when collapsed — skip when expanded since name is visible)
247
- item.addEventListener("mouseenter", () => {
248
- const railEl = document.getElementById("project-rail");
249
- if (railEl && railEl.classList.contains("project-rail--expanded")) return;
250
-
251
- let stats = "";
252
- if (p.moduleCount != null) {
253
- stats = p.moduleCount + " module" + (p.moduleCount !== 1 ? "s" : "");
254
- if (p.templateCount > 1) stats += " \u00b7 " + p.templateCount + " templates";
255
- stats += p.updatedAt ? " \u00b7 " + timeAgo(p.updatedAt) : " \u00b7 on disk";
256
- } else {
257
- stats = p.updatedAt ? timeAgo(p.updatedAt) : "on disk";
258
- }
259
-
260
- railTooltip.innerHTML =
261
- '<div class="project-rail__tooltip-name">' + esc(p.name) + "</div>" +
262
- '<div class="project-rail__tooltip-stats">' + stats + "</div>";
263
-
264
- const rect = item.getBoundingClientRect();
265
- railTooltip.style.top = rect.top + "px";
266
- railTooltip.classList.add("project-rail__tooltip--visible");
267
- });
268
-
269
- item.addEventListener("mouseleave", () => {
270
- railTooltip.classList.remove("project-rail__tooltip--visible");
271
- });
272
-
273
- // Click to open (blocked while AI is generating)
274
506
  item.addEventListener("click", () => {
275
507
  if (typeof isStreaming !== "undefined" && isStreaming) {
276
508
  showError("Cannot switch projects while AI is generating.");
277
509
  return;
278
510
  }
511
+ closeProjectSwitcher();
279
512
  if (p.sessionId) resumeSession(p.sessionId);
280
513
  else openTheme(p.name);
281
514
  });
282
515
 
283
- rail.appendChild(item);
516
+ list.appendChild(item);
284
517
  }
285
518
 
286
519
  updateRailActive();
@@ -291,14 +524,99 @@ function updateRailActive() {
291
524
  document.querySelectorAll(".project-rail__item").forEach((btn) => {
292
525
  btn.classList.toggle("project-rail__item--active", btn.dataset.name === current);
293
526
  });
527
+ // Refresh the rail's current-project bubble + name (editor mode only).
528
+ const bubble = document.getElementById("project-rail-current-bubble");
529
+ const nameEl = document.getElementById("project-rail-current-name");
530
+ if (bubble) bubble.textContent = current ? current.charAt(0).toUpperCase() : "P";
531
+ if (nameEl) nameEl.textContent = current || "";
532
+ const trigger = document.getElementById("project-rail-current");
533
+ if (trigger) trigger.title = current ? current + " — switch project" : "Switch project";
534
+ }
535
+
536
+ // ---------------------------------------------------------------------------
537
+ // Switcher popover open/close
538
+ // ---------------------------------------------------------------------------
539
+
540
+ function openProjectSwitcher() {
541
+ const popover = document.getElementById("project-switcher");
542
+ const trigger = document.getElementById("project-rail-current");
543
+ if (!popover || !trigger) return;
544
+ const rect = trigger.getBoundingClientRect();
545
+ popover.style.top = Math.max(8, rect.top) + "px";
546
+ popover.hidden = false;
547
+ trigger.setAttribute("aria-expanded", "true");
548
+ // Refresh data so the list reflects the latest sessions.
549
+ fetch("/api/setup")
550
+ .then((r) => r.json())
551
+ .then((info) => populateProjectRail(info))
552
+ .catch(() => {});
294
553
  }
295
554
 
296
- // "+" button → open New Theme panel (show setup first if needed)
297
- document.getElementById("project-rail-add")?.addEventListener("click", () => {
298
- if (setupScreen.classList.contains("hidden")) showSetup();
555
+ function closeProjectSwitcher() {
556
+ const popover = document.getElementById("project-switcher");
557
+ const trigger = document.getElementById("project-rail-current");
558
+ if (popover) popover.hidden = true;
559
+ if (trigger) trigger.setAttribute("aria-expanded", "false");
560
+ }
561
+
562
+ function toggleProjectSwitcher() {
563
+ const popover = document.getElementById("project-switcher");
564
+ if (!popover) return;
565
+ if (popover.hidden) openProjectSwitcher();
566
+ else closeProjectSwitcher();
567
+ }
568
+
569
+ document.getElementById("project-rail-current")?.addEventListener("click", (e) => {
570
+ e.stopPropagation();
571
+ toggleProjectSwitcher();
572
+ });
573
+
574
+ document.getElementById("project-rail-back")?.addEventListener("click", () => {
575
+ if (typeof isStreaming !== "undefined" && isStreaming) {
576
+ showError("Cannot leave the editor while AI is generating.");
577
+ return;
578
+ }
579
+ closeProjectSwitcher();
580
+ showSetup();
581
+ });
582
+
583
+ document.getElementById("project-switcher-add")?.addEventListener("click", () => {
584
+ closeProjectSwitcher();
585
+ showSetup();
299
586
  togglePanel("new");
300
587
  });
301
588
 
589
+ // Close on outside click / Escape
590
+ document.addEventListener("click", (e) => {
591
+ const popover = document.getElementById("project-switcher");
592
+ if (!popover || popover.hidden) return;
593
+ const trigger = document.getElementById("project-rail-current");
594
+ if (popover.contains(e.target) || (trigger && trigger.contains(e.target))) return;
595
+ closeProjectSwitcher();
596
+ });
597
+
598
+ document.addEventListener("keydown", (e) => {
599
+ if (e.key === "Escape") closeProjectSwitcher();
600
+ });
601
+
602
+ // Tooltip on the current-project bubble (editor rail)
603
+ document.getElementById("project-rail-current")?.addEventListener("mouseenter", () => {
604
+ const popover = document.getElementById("project-switcher");
605
+ if (popover && !popover.hidden) return;
606
+ const name = currentAppTheme || currentDashboardTheme || "";
607
+ if (!name) return;
608
+ railTooltip.innerHTML =
609
+ '<div class="project-rail__tooltip-name">' + esc(name) + "</div>" +
610
+ '<div class="project-rail__tooltip-stats">Click to switch project</div>';
611
+ const rect = document.getElementById("project-rail-current").getBoundingClientRect();
612
+ railTooltip.style.top = rect.top + "px";
613
+ railTooltip.classList.add("project-rail__tooltip--visible");
614
+ });
615
+
616
+ document.getElementById("project-rail-current")?.addEventListener("mouseleave", () => {
617
+ railTooltip.classList.remove("project-rail__tooltip--visible");
618
+ });
619
+
302
620
  // ---------------------------------------------------------------------------
303
621
  // Inline rename
304
622
  // ---------------------------------------------------------------------------
@@ -431,6 +749,146 @@ function confirmDeleteProject(project) {
431
749
  });
432
750
  }
433
751
 
752
+ // ---------------------------------------------------------------------------
753
+ // First-visit product intro walkthrough (3 steps)
754
+ // ---------------------------------------------------------------------------
755
+
756
+ const INTRO_SEEN_KEY = "vibespot:introSeen";
757
+ const INTRO_SAMPLE_PROMPT =
758
+ "A landing page for a B2B SaaS product called Northwind Analytics. " +
759
+ "Include a hero with a headline and CTA, three feature cards, a customer logo bar, " +
760
+ "a testimonial, and a final call-to-action section.";
761
+
762
+ function renderIntroProgress(stepIndex, totalSteps) {
763
+ let html = "";
764
+ for (let i = 0; i < totalSteps; i++) {
765
+ const cls = i === stepIndex ? "active" : i < stepIndex ? "done" : "";
766
+ html += `<div class="walkthrough__step-dot ${cls}">${i < stepIndex ? vsIcon("check", {size: "sm"}) : i + 1}</div>`;
767
+ if (i < totalSteps - 1) html += `<div class="walkthrough__step-line"></div>`;
768
+ }
769
+ return html;
770
+ }
771
+
772
+ function dismissIntroWalkthrough(info) {
773
+ try { localStorage.setItem(INTRO_SEEN_KEY, "1"); } catch {}
774
+ const url = new URL(location.href);
775
+ if (url.searchParams.has("intro")) {
776
+ url.searchParams.delete("intro");
777
+ history.replaceState(null, "", url.pathname + url.search + url.hash);
778
+ }
779
+ document.getElementById("walkthrough").classList.add("hidden");
780
+ // If the user still has no AI engine on a fresh install, fall through to
781
+ // the engine-setup walkthrough; otherwise reveal the normal setup options.
782
+ if (info && !info.aiAvailable && (info.sessions || []).length === 0 && (info.localThemes || []).length === 0) {
783
+ showWalkthrough();
784
+ } else {
785
+ document.getElementById("setup-options").classList.remove("hidden");
786
+ }
787
+ }
788
+
789
+ function showIntroWalkthrough(info) {
790
+ const walkthrough = document.getElementById("walkthrough");
791
+ const options = document.getElementById("setup-options");
792
+ const progress = document.getElementById("walkthrough-progress");
793
+ const content = document.getElementById("walkthrough-content");
794
+ if (!walkthrough || !options || !progress || !content) return;
795
+
796
+ walkthrough.classList.remove("hidden");
797
+ options.classList.add("hidden");
798
+
799
+ const STEPS = [
800
+ {
801
+ title: "Welcome to vibeSpot",
802
+ body: `
803
+ <p>vibeSpot turns plain-language descriptions into native HubSpot CMS landing pages.
804
+ Describe what you want, watch a live preview build, then upload the result straight to HubSpot.</p>
805
+ <ul class="walkthrough__bullets">
806
+ <li>Chat-driven editing with a side-by-side preview</li>
807
+ <li>Generates real HubL modules, not screenshots or mockups</li>
808
+ <li>Works with Claude, OpenAI, or Gemini &mdash; API key or CLI</li>
809
+ </ul>
810
+ `,
811
+ },
812
+ {
813
+ title: "How it maps to HubSpot",
814
+ body: `
815
+ <p>Every vibeSpot page becomes a fully editable HubSpot theme. The pieces line up like this:</p>
816
+ <ul class="walkthrough__bullets">
817
+ <li><strong>Sections</strong> &rarr; HubSpot <strong>modules</strong> with editable fields</li>
818
+ <li><strong>Shared CSS &amp; tokens</strong> &rarr; theme-level <code>:root</code> variables</li>
819
+ <li><strong>Project</strong> &rarr; uploadable <strong>HubSpot CMS theme</strong> with templates</li>
820
+ </ul>
821
+ <p>Marketers can keep editing fields in HubSpot after upload &mdash; no code changes required.</p>
822
+ `,
823
+ },
824
+ {
825
+ title: "Try it with a pre-filled prompt",
826
+ body: `
827
+ <p>We&rsquo;ll drop a sample prompt into the builder so you can see vibeSpot in action.
828
+ You can edit it before pressing <strong>Build</strong>.</p>
829
+ <div class="walkthrough__card walkthrough__sample-prompt">
830
+ <div class="walkthrough__card-title">Sample prompt</div>
831
+ <div class="walkthrough__sample-prompt-body">${esc(INTRO_SAMPLE_PROMPT)}</div>
832
+ </div>
833
+ `,
834
+ },
835
+ ];
836
+
837
+ let stepIndex = 0;
838
+
839
+ function render() {
840
+ const step = STEPS[stepIndex];
841
+ progress.innerHTML = renderIntroProgress(stepIndex, STEPS.length);
842
+
843
+ const isLast = stepIndex === STEPS.length - 1;
844
+ const primaryLabel = isLast ? "Try it now" : "Next";
845
+ const backBtn = stepIndex > 0
846
+ ? `<button class="btn btn--secondary" id="intro-back">Back</button>`
847
+ : "";
848
+
849
+ content.innerHTML = `
850
+ <div class="walkthrough__step-title">${esc(step.title)}</div>
851
+ <div class="walkthrough__step-desc">${step.body}</div>
852
+ <div class="walkthrough__actions">
853
+ <button class="btn btn--ghost" id="intro-skip">Skip intro</button>
854
+ <span class="walkthrough__actions-spacer"></span>
855
+ ${backBtn}
856
+ <button class="btn btn--primary" id="intro-next">${primaryLabel}</button>
857
+ </div>
858
+ `;
859
+
860
+ document.getElementById("intro-skip").addEventListener("click", () => dismissIntroWalkthrough(info));
861
+ document.getElementById("intro-next").addEventListener("click", () => {
862
+ if (isLast) {
863
+ // Pre-fill the prompt and hand off to the normal builder flow.
864
+ const promptInput = document.getElementById("setup-prompt-input");
865
+ if (promptInput) {
866
+ promptInput.value = INTRO_SAMPLE_PROMPT;
867
+ promptInput.dispatchEvent(new Event("input", { bubbles: true }));
868
+ }
869
+ dismissIntroWalkthrough(info);
870
+ // Focus the prompt so the user lands ready to edit / submit.
871
+ setTimeout(() => {
872
+ const el = document.getElementById("setup-prompt-input");
873
+ if (el) {
874
+ el.focus();
875
+ if (typeof el.scrollIntoView === "function") {
876
+ el.scrollIntoView({ behavior: "smooth", block: "center" });
877
+ }
878
+ }
879
+ }, 0);
880
+ return;
881
+ }
882
+ stepIndex++;
883
+ render();
884
+ });
885
+ const backEl = document.getElementById("intro-back");
886
+ if (backEl) backEl.addEventListener("click", () => { stepIndex--; render(); });
887
+ }
888
+
889
+ render();
890
+ }
891
+
434
892
  // ---------------------------------------------------------------------------
435
893
  // Guided walkthrough (first-run experience)
436
894
  // ---------------------------------------------------------------------------
@@ -664,8 +1122,191 @@ async function createTheme() {
664
1122
  }
665
1123
  }
666
1124
 
1125
+ // ---------------------------------------------------------------------------
1126
+ // Primary path: "Describe the landing page you want to build..."
1127
+ // Creates a fresh theme and forwards the prompt to the chat once it connects.
1128
+ // ---------------------------------------------------------------------------
1129
+
1130
+ function generateThemeNameFromPrompt(prompt) {
1131
+ const slug = prompt
1132
+ .toLowerCase()
1133
+ .replace(/[^a-z0-9\s-]/g, "")
1134
+ .trim()
1135
+ .split(/\s+/)
1136
+ .slice(0, 5)
1137
+ .join("-")
1138
+ .replace(/-+/g, "-")
1139
+ .replace(/^-|-$/g, "")
1140
+ .slice(0, 40);
1141
+
1142
+ if (slug) return slug;
1143
+ return "page-" + Date.now().toString(36);
1144
+ }
1145
+
1146
+ async function startFromPrompt() {
1147
+ const input = document.getElementById("setup-prompt-input");
1148
+ const submitBtn = document.getElementById("setup-prompt-submit");
1149
+ const prompt = (input?.value || "").trim();
1150
+ if (!prompt) {
1151
+ input?.focus();
1152
+ return;
1153
+ }
1154
+
1155
+ if (submitBtn) submitBtn.disabled = true;
1156
+ const themeName = generateThemeNameFromPrompt(prompt);
1157
+ showLoading("Creating theme...");
1158
+
1159
+ try {
1160
+ const createBody = { name: themeName };
1161
+ if (window.__pendingAssetType) createBody.assetType = window.__pendingAssetType;
1162
+ const res = await fetch("/api/setup/create", {
1163
+ method: "POST",
1164
+ headers: { "Content-Type": "application/json" },
1165
+ body: JSON.stringify(createBody),
1166
+ });
1167
+ const data = await res.json();
1168
+
1169
+ if (data.error) {
1170
+ showError(data.error);
1171
+ if (submitBtn) submitBtn.disabled = false;
1172
+ return;
1173
+ }
1174
+
1175
+ // chat.js will pick this up on the next ws "init" message
1176
+ window.__pendingInitialPrompt = prompt;
1177
+ if (input) input.value = "";
1178
+ showAppDirect(data.themeName);
1179
+ } catch (err) {
1180
+ showError("Failed to create theme: " + err.message);
1181
+ if (submitBtn) submitBtn.disabled = false;
1182
+ }
1183
+ }
1184
+
1185
+ // ---------------------------------------------------------------------------
1186
+ // Starter templates
1187
+ // ---------------------------------------------------------------------------
1188
+
1189
+ let _startersCache = null;
1190
+ let _selectedStarterId = null;
1191
+
1192
+ function escHtml(s) {
1193
+ const d = document.createElement("div");
1194
+ d.textContent = s;
1195
+ return d.innerHTML;
1196
+ }
1197
+
1198
+ async function loadStarterGrid() {
1199
+ const grid = document.getElementById("starter-grid");
1200
+ if (!grid) return;
1201
+
1202
+ if (_startersCache !== null) {
1203
+ renderStarterGrid(_startersCache);
1204
+ return;
1205
+ }
1206
+
1207
+ try {
1208
+ const res = await fetch("/api/starters");
1209
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1210
+ const data = await res.json();
1211
+ _startersCache = data.starters || [];
1212
+ renderStarterGrid(_startersCache);
1213
+ } catch {
1214
+ // API unavailable — attach click listeners to any hardcoded static cards
1215
+ grid.querySelectorAll(".starter-card").forEach((card) => {
1216
+ card.addEventListener("click", () => selectStarter(card.dataset.starterId));
1217
+ });
1218
+ }
1219
+ }
1220
+
1221
+ function renderStarterGrid(starters) {
1222
+ const grid = document.getElementById("starter-grid");
1223
+ if (!grid) return;
1224
+
1225
+ if (starters.length === 0) {
1226
+ grid.innerHTML = '<p class="setup__hint">No starter templates available.</p>';
1227
+ return;
1228
+ }
1229
+
1230
+ const pageStarters = starters.filter((s) => s.contentType !== "email");
1231
+ const emailStarters = starters.filter((s) => s.contentType === "email");
1232
+
1233
+ const renderCards = (list) => list.map((s) => `
1234
+ <div class="starter-card${_selectedStarterId === s.id ? " selected" : ""}" data-starter-id="${escHtml(s.id)}">
1235
+ <span class="starter-card__name">${escHtml(s.name)}</span>
1236
+ <span class="starter-card__desc">${escHtml(s.description)}</span>
1237
+ <span class="starter-card__meta">${s.moduleCount} modules</span>
1238
+ </div>
1239
+ `).join("");
1240
+
1241
+ const renderGroup = (title, list) =>
1242
+ `<div class="starter-grid__group">
1243
+ <h4 class="starter-grid__heading">${escHtml(title)}</h4>
1244
+ <div class="starter-grid__section">${renderCards(list)}</div>
1245
+ </div>`;
1246
+
1247
+ let html = "";
1248
+ if (_serverContentMode === "email") {
1249
+ if (emailStarters.length > 0) html += renderGroup("Email Templates", emailStarters);
1250
+ if (pageStarters.length > 0) html += renderGroup("Page Templates", pageStarters);
1251
+ } else {
1252
+ if (pageStarters.length > 0) html += renderGroup("Page Templates", pageStarters);
1253
+ if (emailStarters.length > 0) html += renderGroup("Email Templates", emailStarters);
1254
+ }
1255
+ grid.innerHTML = html;
1256
+
1257
+ grid.querySelectorAll(".starter-card").forEach((card) => {
1258
+ card.addEventListener("click", () => selectStarter(card.dataset.starterId));
1259
+ });
1260
+ }
1261
+
1262
+ function selectStarter(id) {
1263
+ _selectedStarterId = id;
1264
+ const starter = (_startersCache || []).find((s) => s.id === id);
1265
+ if (!starter) return;
1266
+
1267
+ document.querySelectorAll(".starter-card").forEach((c) => c.classList.toggle("selected", c.dataset.starterId === id));
1268
+
1269
+ const createSection = document.getElementById("starter-create");
1270
+ const label = document.getElementById("starter-create-label");
1271
+ if (createSection && label) {
1272
+ label.textContent = `Create theme from "${starter.name}":`;
1273
+ createSection.classList.remove("hidden");
1274
+ setTimeout(() => document.getElementById("starter-theme-name")?.focus(), 50);
1275
+ }
1276
+ }
1277
+
1278
+ async function createFromStarter() {
1279
+ if (!_selectedStarterId) return;
1280
+ const name = document.getElementById("starter-theme-name").value.trim();
1281
+ if (!name) {
1282
+ showError("Please enter a name for your theme.");
1283
+ return;
1284
+ }
1285
+
1286
+ showLoading("Creating theme from template...");
1287
+
1288
+ try {
1289
+ const res = await fetch("/api/setup/create", {
1290
+ method: "POST",
1291
+ headers: { "Content-Type": "application/json" },
1292
+ body: JSON.stringify({ name, starterId: _selectedStarterId }),
1293
+ });
1294
+ const data = await res.json();
1295
+
1296
+ if (data.error) {
1297
+ showError(data.error);
1298
+ return;
1299
+ }
1300
+
1301
+ showApp(data.themeName);
1302
+ } catch (err) {
1303
+ showError("Failed to create theme: " + err.message);
1304
+ }
1305
+ }
1306
+
667
1307
  async function fetchTheme() {
668
- const name = document.getElementById("fetch-theme-name").value.trim();
1308
+ const nameEl = document.getElementById("fetch-theme-name") || document.getElementById("dl-theme-name");
1309
+ const name = nameEl ? nameEl.value.trim() : "";
669
1310
  if (!name) {
670
1311
  showError("Please enter the theme name from your HubSpot account.");
671
1312
  return;
@@ -770,16 +1411,12 @@ function showApp(themeName) {
770
1411
  * Used as fallback or when navigating from dashboard to a specific template.
771
1412
  */
772
1413
  function showAppDirect(themeName) {
773
- setupScreen.classList.add("hidden");
774
- document.getElementById("setup-topbar").classList.add("hidden");
775
- document.getElementById("project-rail")?.classList.remove("project-rail--expanded");
776
1414
  if (typeof hideDashboard === "function") hideDashboard();
1415
+ appBody.dataset.mode = "editor";
777
1416
  appScreen.classList.remove("hidden");
1417
+ document.getElementById("project-rail")?.setAttribute("data-mode", "editor");
778
1418
  document.getElementById("theme-name").textContent = themeName;
779
1419
 
780
- const urlEl = document.getElementById("browser-url");
781
- if (urlEl) urlEl.textContent = themeName + ".vibespot.app";
782
-
783
1420
  currentAppTheme = themeName;
784
1421
  const target = "#/app/" + encodeURIComponent(themeName);
785
1422
  if (location.hash !== target) {
@@ -798,9 +1435,9 @@ function showAppDirect(themeName) {
798
1435
  function showSetup() {
799
1436
  appScreen.classList.add("hidden");
800
1437
  if (typeof hideDashboard === "function") hideDashboard();
801
- setupScreen.classList.remove("hidden");
802
- document.getElementById("setup-topbar").classList.remove("hidden");
803
- document.getElementById("project-rail")?.classList.add("project-rail--expanded");
1438
+ appBody.dataset.mode = "project-home";
1439
+ document.getElementById("project-rail")?.setAttribute("data-mode", "project-home");
1440
+ closeProjectSwitcher();
804
1441
  currentAppTheme = "";
805
1442
 
806
1443
  hideLoading();
@@ -813,25 +1450,16 @@ function showSetup() {
813
1450
  initSetup();
814
1451
  }
815
1452
 
816
- // App back button → go back to dashboard from chat
817
- document.getElementById("app-back")?.addEventListener("click", () => {
818
- if (currentAppTheme && typeof showDashboard === "function") {
819
- appScreen.classList.add("hidden");
820
- showDashboard(currentAppTheme);
821
- }
1453
+ // Editor back button → go back to setup
1454
+ document.getElementById("editor-back")?.addEventListener("click", () => {
1455
+ showSetup();
822
1456
  });
823
1457
 
824
- // Logo click → go back to setup (from dashboard)
1458
+ // Logo click → go back to setup
825
1459
  document.querySelectorAll(".topbar__brand").forEach((el) => {
826
1460
  el.style.cursor = "pointer";
827
1461
  el.addEventListener("click", () => {
828
- const dashEl = document.getElementById("dashboard-screen");
829
- if (dashEl && !dashEl.classList.contains("hidden")) {
830
- showSetup();
831
- return;
832
- }
833
- // Fallback
834
- if (!appScreen.classList.contains("hidden")) {
1462
+ if (appBody.dataset.mode === "editor") {
835
1463
  showSetup();
836
1464
  }
837
1465
  });
@@ -870,7 +1498,7 @@ let remoteThemesLoaded = false;
870
1498
 
871
1499
  function togglePanel(action) {
872
1500
  const panels = document.querySelectorAll(".setup__panel");
873
- const buttons = document.querySelectorAll(".setup__action-btn");
1501
+ const buttons = document.querySelectorAll(".setup__entry-card");
874
1502
 
875
1503
  // Close if same panel clicked
876
1504
  if (activePanel === action) {
@@ -884,18 +1512,19 @@ function togglePanel(action) {
884
1512
  panels.forEach((p) => p.classList.add("hidden"));
885
1513
  buttons.forEach((b) => b.classList.remove("active"));
886
1514
 
887
- const panelMap = { new: "panel-new", continue: "panel-continue", download: "panel-download", figma: "panel-figma", convert: "panel-convert" };
1515
+ const panelMap = { starter: "panel-starter", new: "panel-new", continue: "panel-continue", download: "panel-download", figma: "panel-figma", convert: "panel-convert" };
888
1516
  const panel = document.getElementById(panelMap[action]);
889
1517
  if (panel) {
890
1518
  panel.classList.remove("hidden");
891
1519
  activePanel = action;
892
1520
  }
893
1521
 
894
- // Mark button active
895
- const btn = document.querySelector(`.setup__action-btn[data-action="${action}"]`);
1522
+ // Mark card active
1523
+ const btn = document.querySelector(`.setup__entry-card[data-action="${action}"]`);
896
1524
  if (btn) btn.classList.add("active");
897
1525
 
898
1526
  // Focus input if applicable
1527
+ if (action === "starter") loadStarterGrid();
899
1528
  if (action === "new") setTimeout(() => document.getElementById("new-theme-name")?.focus(), 50);
900
1529
  if (action === "convert") setTimeout(() => document.getElementById("import-url")?.focus(), 50);
901
1530
  if (action === "figma") initFigmaPanel();
@@ -907,39 +1536,225 @@ function togglePanel(action) {
907
1536
  if (action === "continue") populateContinuePanel();
908
1537
  }
909
1538
 
1539
+ let _bulkSelected = new Set();
1540
+
910
1541
  function populateContinuePanel() {
911
1542
  const container = document.getElementById("continue-projects");
912
1543
  const empty = document.getElementById("continue-empty");
913
1544
  if (!container) return;
914
1545
 
915
- // Gather projects from the rail
916
- const railItems = document.querySelectorAll(".project-rail__item");
917
- if (railItems.length === 0) {
1546
+ const projects = _allProjects;
1547
+ if (!projects || projects.length === 0) {
918
1548
  container.innerHTML = "";
919
- empty.classList.remove("hidden");
1549
+ _bulkSelected.clear();
1550
+ if (empty) empty.classList.remove("hidden");
920
1551
  return;
921
1552
  }
922
1553
 
923
- empty.classList.add("hidden");
924
- container.innerHTML = "";
1554
+ if (empty) empty.classList.add("hidden");
1555
+ _bulkSelected.clear();
1556
+
1557
+ const toolbar = document.createElement("div");
1558
+ toolbar.className = "projects-bulk-toolbar hidden";
1559
+ toolbar.id = "projects-bulk-toolbar";
1560
+ toolbar.innerHTML =
1561
+ `<span class="projects-bulk-toolbar__count" id="bulk-count">0 selected</span>` +
1562
+ `<button type="button" class="btn btn--sm btn--secondary" id="bulk-duplicate">Duplicate</button>` +
1563
+ `<button type="button" class="btn btn--sm btn--danger" id="bulk-delete">Delete</button>`;
1564
+
1565
+ const table = document.createElement("table");
1566
+ table.className = "projects-table";
1567
+ table.innerHTML = `<thead><tr>
1568
+ <th class="projects-table__th-check"><input type="checkbox" id="bulk-select-all" class="projects-table__checkbox" title="Select all" /></th>
1569
+ <th>Name</th>
1570
+ <th>Pages</th>
1571
+ <th>Emails</th>
1572
+ <th>Modules</th>
1573
+ <th>Brand Assets</th>
1574
+ <th></th>
1575
+ </tr></thead>`;
1576
+
1577
+ const tbody = document.createElement("tbody");
1578
+ for (const p of projects) {
1579
+ const tr = document.createElement("tr");
1580
+ tr.dataset.projectName = p.name;
1581
+
1582
+ const checkTd = document.createElement("td");
1583
+ checkTd.className = "projects-table__td-check";
1584
+ const cb = document.createElement("input");
1585
+ cb.type = "checkbox";
1586
+ cb.className = "projects-table__checkbox";
1587
+ cb.dataset.projectName = p.name;
1588
+ cb.addEventListener("change", () => {
1589
+ if (cb.checked) _bulkSelected.add(p.name);
1590
+ else _bulkSelected.delete(p.name);
1591
+ tr.classList.toggle("projects-table__row--selected", cb.checked);
1592
+ syncBulkToolbar();
1593
+ });
1594
+ checkTd.appendChild(cb);
1595
+ tr.appendChild(checkTd);
1596
+
1597
+ const nameTd = document.createElement("td");
1598
+ nameTd.className = "projects-table__name";
1599
+ nameTd.textContent = p.name;
1600
+ tr.appendChild(nameTd);
1601
+
1602
+ for (const val of [p.pageCount ?? 0, p.emailCount ?? 0, p.moduleCount ?? 0]) {
1603
+ const td = document.createElement("td");
1604
+ td.textContent = String(val);
1605
+ tr.appendChild(td);
1606
+ }
925
1607
 
926
- railItems.forEach((item) => {
927
- const name = item.dataset.name || item.querySelector(".project-rail__name")?.textContent || "";
928
- const sessionId = item.dataset.sessionId || "";
929
- const meta = item.querySelector(".project-rail__meta")?.textContent || "";
930
-
931
- const pill = document.createElement("button");
932
- pill.className = "setup__pill";
933
- pill.innerHTML = `<span>${esc(name)}</span>${meta ? `<span class="setup__pill__meta">${esc(meta)}</span>` : ""}`;
934
- pill.addEventListener("click", () => {
935
- if (sessionId) {
936
- resumeSession(sessionId);
937
- } else {
938
- openTheme(name);
1608
+ const brandTd = document.createElement("td");
1609
+ brandTd.textContent = p.hasBrandAssets ? "" : "";
1610
+ tr.appendChild(brandTd);
1611
+
1612
+ const actionsCell = document.createElement("td");
1613
+ actionsCell.className = "projects-table__actions";
1614
+ tr.appendChild(actionsCell);
1615
+
1616
+ const openBtn = document.createElement("button");
1617
+ openBtn.type = "button";
1618
+ openBtn.className = "btn btn--sm btn--primary";
1619
+ openBtn.textContent = "Open";
1620
+ openBtn.addEventListener("click", () => {
1621
+ if (typeof isStreaming !== "undefined" && isStreaming) {
1622
+ showError("Cannot switch projects while AI is generating.");
1623
+ return;
939
1624
  }
1625
+ if (p.sessionId) resumeSession(p.sessionId);
1626
+ else openTheme(p.name);
1627
+ });
1628
+ actionsCell.appendChild(openBtn);
1629
+
1630
+ const delBtn = document.createElement("button");
1631
+ delBtn.type = "button";
1632
+ delBtn.className = "btn btn--sm btn--danger";
1633
+ delBtn.textContent = "Delete";
1634
+ delBtn.addEventListener("click", () => {
1635
+ confirmDeleteProject(p);
1636
+ });
1637
+ actionsCell.appendChild(delBtn);
1638
+
1639
+ tbody.appendChild(tr);
1640
+ }
1641
+ table.appendChild(tbody);
1642
+
1643
+ container.innerHTML = "";
1644
+ container.appendChild(toolbar);
1645
+ container.appendChild(table);
1646
+
1647
+ const selectAll = document.getElementById("bulk-select-all");
1648
+ selectAll.addEventListener("change", () => {
1649
+ const cbs = container.querySelectorAll("tbody .projects-table__checkbox");
1650
+ cbs.forEach((c) => {
1651
+ c.checked = selectAll.checked;
1652
+ const name = c.dataset.projectName;
1653
+ const row = c.closest("tr");
1654
+ if (selectAll.checked) _bulkSelected.add(name);
1655
+ else _bulkSelected.delete(name);
1656
+ if (row) row.classList.toggle("projects-table__row--selected", selectAll.checked);
940
1657
  });
941
- container.appendChild(pill);
1658
+ syncBulkToolbar();
942
1659
  });
1660
+
1661
+ document.getElementById("bulk-delete").addEventListener("click", () => bulkDeleteProjects());
1662
+ document.getElementById("bulk-duplicate").addEventListener("click", () => bulkDuplicateProjects());
1663
+ }
1664
+
1665
+ function syncBulkToolbar() {
1666
+ const toolbar = document.getElementById("projects-bulk-toolbar");
1667
+ const countEl = document.getElementById("bulk-count");
1668
+ const selectAll = document.getElementById("bulk-select-all");
1669
+ if (!toolbar) return;
1670
+
1671
+ const n = _bulkSelected.size;
1672
+ toolbar.classList.toggle("hidden", n === 0);
1673
+ if (countEl) countEl.textContent = `${n} selected`;
1674
+
1675
+ if (selectAll) {
1676
+ const total = document.querySelectorAll("#continue-projects tbody .projects-table__checkbox").length;
1677
+ selectAll.checked = n > 0 && n === total;
1678
+ selectAll.indeterminate = n > 0 && n < total;
1679
+ }
1680
+ }
1681
+
1682
+ function bulkDeleteProjects() {
1683
+ if (_bulkSelected.size === 0) return;
1684
+ const names = [..._bulkSelected];
1685
+ const projects = _allProjects.filter((p) => names.includes(p.name));
1686
+
1687
+ const overlay = document.createElement("div");
1688
+ overlay.className = "confirm-overlay";
1689
+ overlay.innerHTML = `
1690
+ <div class="confirm-dialog">
1691
+ <div class="confirm-dialog__title">Delete ${projects.length} project${projects.length > 1 ? "s" : ""}?</div>
1692
+ <div class="confirm-dialog__detail">${projects.map((p) => `<strong>${esc(p.name)}</strong>`).join(", ")}</div>
1693
+ <label class="confirm-dialog__check">
1694
+ <input type="checkbox" id="confirm-bulk-delete-files" checked />
1695
+ <span>Also delete local files</span>
1696
+ </label>
1697
+ <p class="confirm-dialog__warn">Deleting local files cannot be undone.</p>
1698
+ <div class="confirm-dialog__actions">
1699
+ <button class="btn btn--secondary" id="confirm-bulk-cancel">Cancel</button>
1700
+ <button class="btn btn--danger" id="confirm-bulk-delete">Delete</button>
1701
+ </div>
1702
+ </div>
1703
+ `;
1704
+ document.body.appendChild(overlay);
1705
+
1706
+ document.getElementById("confirm-bulk-cancel").addEventListener("click", () => overlay.remove());
1707
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); });
1708
+
1709
+ document.getElementById("confirm-bulk-delete").addEventListener("click", async () => {
1710
+ const deleteFiles = document.getElementById("confirm-bulk-delete-files").checked;
1711
+ overlay.remove();
1712
+
1713
+ for (const p of projects) {
1714
+ try {
1715
+ if (p.sessionId) {
1716
+ await fetch("/api/themes", {
1717
+ method: "DELETE",
1718
+ headers: { "Content-Type": "application/json" },
1719
+ body: JSON.stringify({ sessionId: p.sessionId, deleteFiles }),
1720
+ });
1721
+ } else if (deleteFiles) {
1722
+ await fetch("/api/themes/delete-local", {
1723
+ method: "POST",
1724
+ headers: { "Content-Type": "application/json" },
1725
+ body: JSON.stringify({ themeName: p.name }),
1726
+ });
1727
+ }
1728
+ } catch { /* continue deleting others */ }
1729
+ }
1730
+ _bulkSelected.clear();
1731
+ await initSetup();
1732
+ populateContinuePanel();
1733
+ });
1734
+ }
1735
+
1736
+ async function bulkDuplicateProjects() {
1737
+ if (_bulkSelected.size === 0) return;
1738
+ const names = [..._bulkSelected];
1739
+ const projects = _allProjects.filter((p) => names.includes(p.name) && p.sessionId);
1740
+
1741
+ if (projects.length === 0) {
1742
+ showError("Only saved projects can be duplicated.");
1743
+ return;
1744
+ }
1745
+
1746
+ for (const p of projects) {
1747
+ try {
1748
+ await fetch("/api/themes/duplicate", {
1749
+ method: "POST",
1750
+ headers: { "Content-Type": "application/json" },
1751
+ body: JSON.stringify({ sessionId: p.sessionId }),
1752
+ });
1753
+ } catch { /* continue */ }
1754
+ }
1755
+ _bulkSelected.clear();
1756
+ await initSetup();
1757
+ populateContinuePanel();
943
1758
  }
944
1759
 
945
1760
  async function loadDownloadPanel() {
@@ -996,7 +1811,7 @@ function initDlAccountSwitch(accounts, activeId) {
996
1811
  let html = '<div style="display:flex;flex-direction:column;gap:6px">';
997
1812
  for (const acct of accounts) {
998
1813
  const isActive = acct.portalId === activeId;
999
- html += `<button class="btn btn--${isActive ? "primary" : "secondary"} dl-acct-btn" data-portal="${esc(acct.portalId)}" style="text-align:left;padding:6px 12px;font-size:13px">${esc(acct.portalName || acct.portalId)} (${esc(acct.portalId)})${isActive ? " " : ""}</button>`;
1814
+ html += `<button class="btn btn--${isActive ? "primary" : "secondary"} dl-acct-btn" data-portal="${esc(acct.portalId)}" style="text-align:left;padding:6px 12px;font-size:13px">${esc(acct.portalName || acct.portalId)} (${esc(acct.portalId)})${isActive ? ' <span class="vs-icon-inline">' + vsIcon("check", {size: "sm"}) + '</span>' : ""}</button>`;
1000
1815
  }
1001
1816
  html += `<button class="btn btn--secondary dl-acct-btn" data-portal="__new" style="text-align:left;padding:6px 12px;font-size:13px">+ Add another account</button>`;
1002
1817
  html += '</div>';
@@ -1051,9 +1866,95 @@ async function downloadThemeByName() {
1051
1866
  // Event listeners
1052
1867
  // ---------------------------------------------------------------------------
1053
1868
 
1054
- // Action buttons
1055
- document.querySelectorAll(".setup__action-btn").forEach((btn) => {
1056
- btn.addEventListener("click", () => togglePanel(btn.dataset.action));
1869
+ // Asset-type cards (guided entry — VIB-255). The "From Template" card opens
1870
+ // the existing starter grid; "Import" shows source picker; others reveal a
1871
+ // pre-scoped describe prompt.
1872
+ document.querySelectorAll(".setup__type-card").forEach((card) => {
1873
+ card.addEventListener("click", () => {
1874
+ const action = card.dataset.action;
1875
+ if (action === "starter") {
1876
+ activePanel = null;
1877
+ togglePanel("starter");
1878
+ setTimeout(() => {
1879
+ document.getElementById("panel-starter")?.scrollIntoView({ behavior: "smooth", block: "nearest" });
1880
+ }, 60);
1881
+ return;
1882
+ }
1883
+ const assetType = card.dataset.assetType;
1884
+ if (assetType === "import") {
1885
+ showImportSources();
1886
+ return;
1887
+ }
1888
+ showScopedPrompt(card);
1889
+ });
1890
+ });
1891
+
1892
+ // "Back" link inside the scoped describe prompt restores the asset-type cards.
1893
+ document.getElementById("setup-prompt-back")?.addEventListener("click", () => {
1894
+ showAssetTypeCards();
1895
+ });
1896
+
1897
+ // Import source picker
1898
+ function showImportSources() {
1899
+ const cards = document.getElementById("setup-type-cards");
1900
+ const importPanel = document.getElementById("setup-import-sources");
1901
+ const question = document.getElementById("setup-question");
1902
+ if (cards) cards.classList.add("hidden");
1903
+ if (question) question.classList.add("hidden");
1904
+ if (importPanel) importPanel.classList.remove("hidden");
1905
+ }
1906
+
1907
+ document.getElementById("setup-import-back")?.addEventListener("click", () => {
1908
+ const importPanel = document.getElementById("setup-import-sources");
1909
+ if (importPanel) importPanel.classList.add("hidden");
1910
+ showAssetTypeCards();
1911
+ });
1912
+
1913
+ document.querySelectorAll(".setup__import-btn").forEach((btn) => {
1914
+ btn.addEventListener("click", () => {
1915
+ const action = btn.dataset.action;
1916
+ const importPanel = document.getElementById("setup-import-sources");
1917
+ if (importPanel) importPanel.classList.add("hidden");
1918
+ showAssetTypeCards();
1919
+ togglePanel(action);
1920
+ });
1921
+ });
1922
+
1923
+ // "View all" link in recent projects → open the full Continue panel
1924
+ document.getElementById("setup-recent-all")?.addEventListener("click", () => {
1925
+ togglePanel("continue");
1926
+ setTimeout(() => {
1927
+ document.getElementById("panel-continue")?.scrollIntoView({ behavior: "smooth", block: "nearest" });
1928
+ }, 60);
1929
+ });
1930
+
1931
+ // Primary "describe-it" prompt
1932
+ const promptInputEl = document.getElementById("setup-prompt-input");
1933
+ const promptSubmitEl = document.getElementById("setup-prompt-submit");
1934
+ if (promptInputEl && promptSubmitEl) {
1935
+ const syncSubmitState = () => {
1936
+ promptSubmitEl.disabled = promptInputEl.value.trim().length === 0;
1937
+ };
1938
+ promptInputEl.addEventListener("input", syncSubmitState);
1939
+ promptInputEl.addEventListener("keydown", (e) => {
1940
+ // ⌘/Ctrl + Enter submits; plain Enter inserts newline like a normal textarea.
1941
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
1942
+ e.preventDefault();
1943
+ if (!promptSubmitEl.disabled) startFromPrompt();
1944
+ }
1945
+ });
1946
+ promptSubmitEl.addEventListener("click", () => {
1947
+ if (!promptSubmitEl.disabled) startFromPrompt();
1948
+ });
1949
+ syncSubmitState();
1950
+ const shortcutEl = document.getElementById("setup-prompt-shortcut");
1951
+ if (shortcutEl && !/Mac|iPhone|iPad/.test(navigator.platform)) shortcutEl.textContent = "Ctrl+↩";
1952
+ }
1953
+
1954
+ // Starter templates
1955
+ document.getElementById("btn-create-from-starter").addEventListener("click", createFromStarter);
1956
+ document.getElementById("starter-theme-name").addEventListener("keydown", (e) => {
1957
+ if (e.key === "Enter") { e.preventDefault(); createFromStarter(); }
1057
1958
  });
1058
1959
 
1059
1960
  // New theme
@@ -1451,8 +2352,7 @@ function handleRoute() {
1451
2352
  if (appTemplateMatch) {
1452
2353
  const themeName = decodeURIComponent(appTemplateMatch[1]);
1453
2354
  const templateId = decodeURIComponent(appTemplateMatch[2]);
1454
- // Already showing this nothing to do
1455
- if (currentAppTheme === themeName && !appScreen.classList.contains("hidden")) return;
2355
+ if (currentAppTheme === themeName && appBody.dataset.mode === "editor") return;
1456
2356
  // Open theme then activate template
1457
2357
  openTheme(themeName).then(() => {
1458
2358
  if (typeof showChat === "function") {
@@ -1466,24 +2366,22 @@ function handleRoute() {
1466
2366
  const appMatch = hash.match(/^#\/app\/([^/]+)$/);
1467
2367
  if (appMatch) {
1468
2368
  const themeName = decodeURIComponent(appMatch[1]);
1469
- if (currentAppTheme === themeName && !appScreen.classList.contains("hidden")) return;
2369
+ if (currentAppTheme === themeName && appBody.dataset.mode === "editor") return;
1470
2370
  openTheme(themeName);
1471
2371
  return;
1472
2372
  }
1473
2373
 
1474
- // #/dashboard/{themeName} → show dashboard for theme
2374
+ // #/dashboard/{themeName} → show editor for theme
1475
2375
  const dashMatch = hash.match(/^#\/dashboard\/(.+)$/);
1476
2376
  if (dashMatch) {
1477
2377
  const themeName = decodeURIComponent(dashMatch[1]);
1478
- const dashEl = document.getElementById("dashboard-screen");
1479
- if (currentDashboardTheme === themeName && dashEl && !dashEl.classList.contains("hidden")) return;
2378
+ if (currentDashboardTheme === themeName && appBody.dataset.mode === "editor") return;
1480
2379
  openTheme(themeName);
1481
2380
  return;
1482
2381
  }
1483
2382
 
1484
2383
  // Default: show setup
1485
- const dashEl = document.getElementById("dashboard-screen");
1486
- if (!appScreen.classList.contains("hidden") || (dashEl && !dashEl.classList.contains("hidden"))) {
2384
+ if (appBody.dataset.mode === "editor") {
1487
2385
  showSetup();
1488
2386
  }
1489
2387
  }