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/dashboard.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * Sits between setup (project list) and chat (template editing).
4
4
  */
5
5
 
6
- const dashboardScreen = document.getElementById("dashboard-screen");
6
+ const dashboardScreen = document.getElementById("editor");
7
7
 
8
8
  // Page type labels for display
9
9
  const PAGE_TYPE_LABELS = {
@@ -26,27 +26,29 @@ const PAGE_TYPE_FULL_LABELS = {
26
26
 
27
27
  let currentDashboardTheme = "";
28
28
  let currentDashboardSessionId = "";
29
+ let currentDashboardIsImported = false;
29
30
 
30
31
  async function showDashboard(themeName) {
31
32
  currentDashboardTheme = themeName;
32
33
 
33
- // Hide other screens
34
- setupScreen.classList.add("hidden");
35
- document.getElementById("setup-topbar").classList.add("hidden");
36
- document.getElementById("project-rail")?.classList.remove("project-rail--expanded");
37
- appScreen.classList.add("hidden");
34
+ const ab = document.getElementById("app-body");
35
+ if (ab) ab.dataset.mode = "editor";
36
+ document.getElementById("project-rail")?.setAttribute("data-mode", "editor");
38
37
  dashboardScreen.classList.remove("hidden");
39
38
 
40
- document.getElementById("dashboard-theme-name").textContent = themeName;
41
- document.getElementById("dashboard-theme-heading").textContent = themeName;
42
- document.getElementById("dashboard-theme-path-text").textContent = "";
39
+ document.getElementById("theme-name").textContent = themeName;
40
+ if (typeof updateRailActive === "function") updateRailActive();
43
41
 
44
42
  // Get sessionId for the active theme
45
43
  try {
46
44
  const themesRes = await fetch("/api/themes");
47
45
  const themesData = await themesRes.json();
48
46
  currentDashboardSessionId = themesData.activeTheme?.id || "";
49
- } catch { currentDashboardSessionId = ""; }
47
+ currentDashboardIsImported = !!themesData.activeTheme?.isImported;
48
+ } catch {
49
+ currentDashboardSessionId = "";
50
+ currentDashboardIsImported = false;
51
+ }
50
52
 
51
53
  // Update URL
52
54
  const target = "#/dashboard/" + encodeURIComponent(themeName);
@@ -56,11 +58,18 @@ async function showDashboard(themeName) {
56
58
 
57
59
  // Load dashboard data
58
60
  await refreshDashboard();
61
+
62
+ // Establish WebSocket so the page tree (populated via WS init message)
63
+ // and chat input work immediately — without this, a browser refresh
64
+ // leaves the page tree empty and the chat send button inert.
65
+ if (typeof connectWebSocket === "function") {
66
+ connectWebSocket();
67
+ }
59
68
  }
60
69
 
61
70
  function hideDashboard() {
62
- dashboardScreen.classList.add("hidden");
63
71
  currentDashboardTheme = "";
72
+ currentDashboardIsImported = false;
64
73
  closeModulePreview();
65
74
  }
66
75
 
@@ -77,16 +86,196 @@ async function refreshDashboard() {
77
86
  return;
78
87
  }
79
88
  renderTemplateList(data.templates || []);
89
+ renderProjectAssets(data.templates || []);
80
90
  renderModuleLibrary(data.moduleLibrary || []);
81
91
  renderBrandAssets(data.brandAssets || {});
82
- if (data.themePath) {
83
- document.getElementById("dashboard-theme-path-text").textContent = data.themePath;
92
+ await loadFontList();
93
+ renderBrandKit(data.brandAssets?.brandKit || null);
94
+ const pathEl = document.getElementById("dashboard-theme-path-text");
95
+ if (data.themePath && pathEl) {
96
+ pathEl.textContent = data.themePath;
97
+ }
98
+ if (currentDashboardIsImported) {
99
+ await refreshInverseAnalysis();
100
+ } else {
101
+ hideInverseAnalysis();
84
102
  }
85
103
  } catch (err) {
86
104
  console.error("Failed to load dashboard:", err);
87
105
  }
88
106
  }
89
107
 
108
+ // ---------------------------------------------------------------------------
109
+ // Import analysis
110
+ // ---------------------------------------------------------------------------
111
+
112
+ function hideInverseAnalysis() {
113
+ const section = document.getElementById("dashboard-inverse-section");
114
+ const summaryEl = document.getElementById("inverse-summary");
115
+ const status = document.getElementById("inverse-status");
116
+ const applyBtn = document.getElementById("btn-inverse-apply-tokens");
117
+ section?.classList.add("hidden");
118
+ if (summaryEl) summaryEl.innerHTML = "";
119
+ if (status) status.textContent = "Analyzing theme...";
120
+ if (applyBtn) applyBtn.classList.add("hidden");
121
+ }
122
+
123
+ async function refreshInverseAnalysis() {
124
+ const section = document.getElementById("dashboard-inverse-section");
125
+ const status = document.getElementById("inverse-status");
126
+ const summaryEl = document.getElementById("inverse-summary");
127
+ const applyBtn = document.getElementById("btn-inverse-apply-tokens");
128
+ if (!section || !summaryEl) return;
129
+
130
+ const capturedSessionId = currentDashboardSessionId;
131
+
132
+ try {
133
+ const res = await fetch("/api/inverse/analyze");
134
+ if (currentDashboardSessionId !== capturedSessionId) return;
135
+ const data = await res.json();
136
+ if (!res.ok || data.error) {
137
+ section.classList.add("hidden");
138
+ return;
139
+ }
140
+ section.classList.remove("hidden");
141
+ renderInverseAnalysis(data.report);
142
+ } catch (err) {
143
+ console.warn("Import analysis failed:", err);
144
+ section.classList.add("hidden");
145
+ }
146
+ }
147
+
148
+ function renderInverseAnalysis(report) {
149
+ const status = document.getElementById("inverse-status");
150
+ const summaryEl = document.getElementById("inverse-summary");
151
+ const applyBtn = document.getElementById("btn-inverse-apply-tokens");
152
+ if (!report || !summaryEl) return;
153
+
154
+ const counts = report.summary || {};
155
+ const tokens = report.designTokens || {};
156
+ const findings = report.findings || [];
157
+ const warnings = findings.filter((f) => f.severity === "warning").length;
158
+ const errors = findings.filter((f) => f.severity === "error").length;
159
+ const hasInferredTokens = (tokens.palette || []).length > 0;
160
+ const hasCssVars = (counts.cssVarCount || 0) > 0;
161
+
162
+ if (status) {
163
+ if (errors > 0) status.textContent = `${errors} issue${errors === 1 ? "" : "s"} need attention`;
164
+ else if (warnings > 0) status.textContent = `${warnings} warning${warnings === 1 ? "" : "s"}`;
165
+ else status.textContent = "No blocking risks found";
166
+ }
167
+
168
+ if (applyBtn) {
169
+ applyBtn.classList.toggle("hidden", hasCssVars || !hasInferredTokens);
170
+ applyBtn.disabled = false;
171
+ applyBtn.textContent = "Apply Tokens";
172
+ }
173
+
174
+ const stats = [
175
+ ["Modules (disk)", counts.moduleCount || 0],
176
+ ["Templates (disk)", counts.templateCount || 0],
177
+ ["Orphans", counts.orphanCount || 0],
178
+ ["Palette", counts.paletteSize || 0],
179
+ ["CSS Vars", counts.cssVarCount || 0],
180
+ ["Macros", counts.customMacroCount || 0],
181
+ ];
182
+
183
+ let html = `<div class="inverse-summary__stats">`;
184
+ for (const [label, value] of stats) {
185
+ html += `
186
+ <div class="inverse-stat">
187
+ <span class="inverse-stat__value">${esc(String(value))}</span>
188
+ <span class="inverse-stat__label">${esc(label)}</span>
189
+ </div>
190
+ `;
191
+ }
192
+ html += `</div>`;
193
+
194
+ if ((tokens.palette || []).length > 0) {
195
+ html += `<div class="inverse-block"><div class="inverse-block__label">Palette</div><div class="inverse-swatches">`;
196
+ for (const color of tokens.palette.slice(0, 8)) {
197
+ const label = color.varName ? `${color.value} (${color.varName})` : color.value;
198
+ html += `<span class="inverse-swatch" style="background:${inverseCssColor(color.value)}" title="${inverseEscAttr(label)}"></span>`;
199
+ }
200
+ html += `</div></div>`;
201
+ }
202
+
203
+ if ((tokens.fontFamilies || []).length > 0) {
204
+ html += `<div class="inverse-block"><div class="inverse-block__label">Typography</div><div class="inverse-tags">`;
205
+ for (const font of tokens.fontFamilies.slice(0, 4)) {
206
+ html += `<span class="inverse-tag">${esc(font)}</span>`;
207
+ }
208
+ html += `</div></div>`;
209
+ }
210
+
211
+ html += renderInverseFindings(findings);
212
+ summaryEl.innerHTML = html;
213
+ }
214
+
215
+ function renderInverseFindings(findings) {
216
+ if (!findings || findings.length === 0) {
217
+ return `<div class="inverse-findings inverse-findings--empty">No findings. This imported theme looks straightforward to edit.</div>`;
218
+ }
219
+
220
+ const severityOrder = { error: 0, warning: 1, info: 2 };
221
+ const sorted = [...findings].sort((a, b) => (severityOrder[a.severity] ?? 2) - (severityOrder[b.severity] ?? 2));
222
+ const visible = sorted.slice(0, 5);
223
+ let html = `<div class="inverse-findings">`;
224
+ for (const finding of visible) {
225
+ const severity = ["error", "warning", "info"].includes(finding.severity) ? finding.severity : "info";
226
+ const fixAttr = finding.fix ? ` title="${inverseEscAttr(finding.fix)}"` : "";
227
+ html += `
228
+ <div class="inverse-finding inverse-finding--${severity}">
229
+ <span class="inverse-finding__severity">${esc(severity)}</span>
230
+ <span class="inverse-finding__message"${fixAttr}>${esc(finding.message)}</span>
231
+ </div>
232
+ `;
233
+ }
234
+ if (findings.length > visible.length) {
235
+ html += `<div class="inverse-findings__more">${findings.length - visible.length} more finding${findings.length - visible.length === 1 ? "" : "s"} available in the CLI report.</div>`;
236
+ }
237
+ html += `</div>`;
238
+ return html;
239
+ }
240
+
241
+ function inverseEscAttr(value) {
242
+ return esc(String(value)).replace(/"/g, "&quot;").replace(/'/g, "&#39;");
243
+ }
244
+
245
+ function inverseCssColor(value) {
246
+ const color = String(value || "").trim();
247
+ if (/^#[0-9a-fA-F]{3,8}$/.test(color)) return color;
248
+ if (/^rgba?\([0-9.,%\s]+\)$/.test(color)) return color;
249
+ if (/^hsla?\([0-9.,%\sdegturnrad+-]+\)$/.test(color)) return color;
250
+ return "transparent";
251
+ }
252
+
253
+ document.getElementById("btn-inverse-apply-tokens")?.addEventListener("click", async () => {
254
+ const btn = document.getElementById("btn-inverse-apply-tokens");
255
+ if (!btn) return;
256
+
257
+ btn.disabled = true;
258
+ btn.textContent = "Applying...";
259
+ try {
260
+ const res = await fetch("/api/inverse/apply-tokens", { method: "POST" });
261
+ const data = await res.json();
262
+ if (!res.ok || data.error) {
263
+ await vibeAlert(data.error || "Failed to apply tokens.", "Error");
264
+ btn.disabled = false;
265
+ btn.textContent = "Apply Tokens";
266
+ return;
267
+ }
268
+ if (!data.applied) {
269
+ await vibeAlert(data.reason || "No tokens were applied.", "Info");
270
+ }
271
+ await refreshInverseAnalysis();
272
+ } catch (err) {
273
+ await vibeAlert("Failed to apply tokens: " + err.message, "Error");
274
+ btn.disabled = false;
275
+ btn.textContent = "Apply Tokens";
276
+ }
277
+ });
278
+
90
279
  // ---------------------------------------------------------------------------
91
280
  // Template list
92
281
  // ---------------------------------------------------------------------------
@@ -94,7 +283,8 @@ async function refreshDashboard() {
94
283
  function renderTemplateList(templates) {
95
284
  const list = document.getElementById("dashboard-template-list");
96
285
  const countEl = document.getElementById("dashboard-template-count");
97
- countEl.textContent = templates.length;
286
+ if (!list) return;
287
+ if (countEl) countEl.textContent = templates.length;
98
288
 
99
289
  if (templates.length === 0) {
100
290
  list.innerHTML = `<p class="dashboard__empty-state">No templates yet. Choose a page type above to get started.</p>`;
@@ -110,7 +300,7 @@ function renderTemplateList(templates) {
110
300
  <span class="dashboard__template-label">${esc(tpl.label)}</span>
111
301
  <span class="dashboard__template-meta">${tpl.moduleCount} module${tpl.moduleCount !== 1 ? "s" : ""}</span>
112
302
  <button class="btn btn--sm btn--primary dashboard__template-open" data-id="${esc(tpl.id)}">Open</button>
113
- <button class="dashboard__template-clone" data-id="${esc(tpl.id)}" title="Clone template">&#x29C9;</button>
303
+ <button class="dashboard__template-clone" data-id="${esc(tpl.id)}" title="Clone template">${vsIcon("copy", {size: "sm"})}</button>
114
304
  <button class="dashboard__template-delete" data-id="${esc(tpl.id)}" title="Delete template">&times;</button>
115
305
  `;
116
306
  list.appendChild(item);
@@ -193,6 +383,70 @@ function startTemplateRename(labelEl, templateId) {
193
383
  });
194
384
  }
195
385
 
386
+ // ---------------------------------------------------------------------------
387
+ // Project assets (Library tab)
388
+ // ---------------------------------------------------------------------------
389
+
390
+ const ASSET_TYPE_LABELS = {
391
+ landing_page: "Landing Page",
392
+ blog_post: "Blog Post",
393
+ website_page: "Website Page",
394
+ module_only: "Module Only",
395
+ email: "Email",
396
+ };
397
+
398
+ function renderProjectAssets(templates) {
399
+ const container = document.getElementById("library-assets-list");
400
+ if (!container) return;
401
+
402
+ if (!templates || templates.length === 0) {
403
+ container.innerHTML = `<p class="dashboard__empty-state">Assets will appear here as you create pages and emails.</p>`;
404
+ return;
405
+ }
406
+
407
+ const pages = templates.filter((t) => t.pageType !== "email");
408
+ const emails = templates.filter((t) => t.pageType === "email");
409
+
410
+ let html = "";
411
+
412
+ if (pages.length > 0) {
413
+ html += `<div class="library-assets__group">`;
414
+ html += `<h3 class="library-assets__group-title">Pages <span class="library-assets__count">${pages.length}</span></h3>`;
415
+ for (const tpl of pages) {
416
+ html += renderAssetCard(tpl);
417
+ }
418
+ html += `</div>`;
419
+ }
420
+
421
+ if (emails.length > 0) {
422
+ html += `<div class="library-assets__group">`;
423
+ html += `<h3 class="library-assets__group-title">Emails <span class="library-assets__count">${emails.length}</span></h3>`;
424
+ for (const tpl of emails) {
425
+ html += renderAssetCard(tpl);
426
+ }
427
+ html += `</div>`;
428
+ }
429
+
430
+ container.innerHTML = html;
431
+
432
+ container.onclick = (e) => {
433
+ const card = e.target.closest(".library-asset-card");
434
+ if (!card) return;
435
+ const templateId = card.dataset.id;
436
+ if (templateId) openTemplate(templateId);
437
+ };
438
+ }
439
+
440
+ function renderAssetCard(tpl) {
441
+ const typeLabel = ASSET_TYPE_LABELS[tpl.pageType] || tpl.pageType;
442
+ return `
443
+ <div class="library-asset-card" data-id="${esc(tpl.id)}" role="button" tabindex="0">
444
+ <span class="dashboard__template-badge dashboard__template-badge--${esc(tpl.pageType)}">${esc(PAGE_TYPE_LABELS[tpl.pageType] || typeLabel)}</span>
445
+ <span class="library-asset-card__label">${esc(tpl.label)}</span>
446
+ <span class="library-asset-card__meta">${tpl.moduleCount} module${tpl.moduleCount !== 1 ? "s" : ""}</span>
447
+ </div>`;
448
+ }
449
+
196
450
  // ---------------------------------------------------------------------------
197
451
  // Module library
198
452
  // ---------------------------------------------------------------------------
@@ -201,6 +455,7 @@ let activePreviewModule = "";
201
455
 
202
456
  function renderModuleLibrary(modules) {
203
457
  const container = document.getElementById("dashboard-module-library");
458
+ if (!container) return;
204
459
 
205
460
  if (modules.length === 0) {
206
461
  container.innerHTML = `<p class="dashboard__empty-state">Modules will appear here as you build pages.</p>`;
@@ -260,17 +515,17 @@ async function showModulePreview(moduleName, usedIn) {
260
515
  function closeModulePreview() {
261
516
  activePreviewModule = "";
262
517
  const previewEl = document.getElementById("dashboard-module-preview");
263
- previewEl.classList.add("hidden");
518
+ if (previewEl) previewEl.classList.add("hidden");
264
519
  document.querySelectorAll(".dashboard__module-chip").forEach((c) => {
265
520
  c.classList.remove("dashboard__module-chip--active");
266
521
  });
267
522
  }
268
523
 
269
524
  // Close button for module preview
270
- document.getElementById("dashboard-preview-close").addEventListener("click", closeModulePreview);
525
+ document.getElementById("dashboard-preview-close")?.addEventListener("click", closeModulePreview);
271
526
 
272
527
  // Delete button for module preview
273
- document.getElementById("dashboard-preview-delete").addEventListener("click", async () => {
528
+ document.getElementById("dashboard-preview-delete")?.addEventListener("click", async () => {
274
529
  const moduleName = activePreviewModule;
275
530
  if (!moduleName) return;
276
531
 
@@ -377,9 +632,14 @@ async function extractBrandAsset(type, card) {
377
632
  });
378
633
  const data = await res.json();
379
634
  if (data.ok && data.content) {
635
+ if (data.brandKit) {
636
+ await loadFontList();
637
+ renderBrandKit(data.brandKit);
638
+ }
380
639
  await refreshDashboard();
640
+ const msg = data.brandKit ? `${ASSET_LABELS[type]} extracted. Brand kit updated from styleguide.` : `${ASSET_LABELS[type]} extracted.`;
381
641
  const view = await vibeConfirm(
382
- `${ASSET_LABELS[type]} extracted.`,
642
+ msg,
383
643
  "Would you like to view it?",
384
644
  { confirmLabel: "View", confirmClass: "btn--primary" },
385
645
  );
@@ -416,6 +676,220 @@ document.getElementById("dashboard-brand-assets")?.addEventListener("change", (e
416
676
  handleBrandFileSelected(card.dataset.asset, e.target.files[0]);
417
677
  });
418
678
 
679
+ // ---------------------------------------------------------------------------
680
+ // Brand kit
681
+ // ---------------------------------------------------------------------------
682
+
683
+ let _fontList = [];
684
+
685
+ async function loadFontList() {
686
+ if (_fontList.length > 0) return;
687
+ try {
688
+ const res = await fetch("/api/fonts");
689
+ _fontList = await res.json();
690
+ } catch { _fontList = []; }
691
+ populateFontSelects();
692
+ }
693
+
694
+ function populateFontSelects() {
695
+ for (const id of ["bk-font-heading", "bk-font-body"]) {
696
+ const sel = document.getElementById(id);
697
+ if (!sel || sel.options.length > 1) continue;
698
+
699
+ const categories = ["system", "sans-serif", "serif", "display", "monospace"];
700
+ const catLabels = { system: "System", "sans-serif": "Sans-Serif", serif: "Serif", display: "Display", monospace: "Monospace" };
701
+ for (const cat of categories) {
702
+ const group = document.createElement("optgroup");
703
+ group.label = catLabels[cat] || cat;
704
+ for (const f of _fontList.filter((x) => x.category === cat)) {
705
+ const opt = document.createElement("option");
706
+ opt.value = f.stack;
707
+ opt.textContent = f.name;
708
+ opt.style.fontFamily = f.stack;
709
+ group.appendChild(opt);
710
+ }
711
+ sel.appendChild(group);
712
+ }
713
+ }
714
+ }
715
+
716
+ function renderBrandKit(brandKit) {
717
+ const fields = {
718
+ primary: { color: "bk-color-primary", hex: "bk-hex-primary" },
719
+ secondary: { color: "bk-color-secondary", hex: "bk-hex-secondary" },
720
+ accent: { color: "bk-color-accent", hex: "bk-hex-accent" },
721
+ };
722
+
723
+ for (const [key, ids] of Object.entries(fields)) {
724
+ const colorInput = document.getElementById(ids.color);
725
+ const hexInput = document.getElementById(ids.hex);
726
+ const val = brandKit?.colors?.[key] || "";
727
+ if (colorInput) colorInput.value = val || colorInput.value;
728
+ if (hexInput) hexInput.value = val;
729
+ }
730
+
731
+ const headingSelect = document.getElementById("bk-font-heading");
732
+ const bodySelect = document.getElementById("bk-font-body");
733
+ const logoInput = document.getElementById("bk-logo-url");
734
+
735
+ if (headingSelect) selectFontValue(headingSelect, brandKit?.fonts?.heading || "");
736
+ if (bodySelect) selectFontValue(bodySelect, brandKit?.fonts?.body || "");
737
+ if (logoInput) logoInput.value = brandKit?.logoUrl || "";
738
+
739
+ updateBrandPreview();
740
+ }
741
+
742
+ function selectFontValue(selectEl, value) {
743
+ if (!value) { selectEl.value = ""; return; }
744
+ const lower = value.toLowerCase().trim();
745
+ for (const opt of selectEl.options) {
746
+ if (opt.value.toLowerCase().trim() === lower) {
747
+ selectEl.value = opt.value;
748
+ return;
749
+ }
750
+ }
751
+ const custom = document.createElement("option");
752
+ custom.value = value;
753
+ custom.textContent = value.split(",")[0].replace(/['"]/g, "").trim() + " (custom)";
754
+ const firstOptgroup = selectEl.querySelector("optgroup");
755
+ selectEl.insertBefore(custom, firstOptgroup || selectEl.options[1]);
756
+ selectEl.value = value;
757
+ }
758
+
759
+ function updateBrandPreview() {
760
+ const hexRe = /^#[0-9a-fA-F]{6}$/;
761
+ const swatchKeys = ["primary", "secondary", "accent"];
762
+ for (const key of swatchKeys) {
763
+ const swatch = document.getElementById(`brand-preview-swatch-${key}`);
764
+ const hex = document.getElementById(`bk-hex-${key}`)?.value?.trim();
765
+ if (!swatch) continue;
766
+ if (hex && hexRe.test(hex)) {
767
+ swatch.style.background = hex;
768
+ swatch.dataset.empty = "false";
769
+ swatch.title = `${key.charAt(0).toUpperCase() + key.slice(1)} ${hex}`;
770
+ } else {
771
+ swatch.style.background = "";
772
+ swatch.dataset.empty = "true";
773
+ swatch.title = `${key.charAt(0).toUpperCase() + key.slice(1)} (not set)`;
774
+ }
775
+ }
776
+
777
+ const headingFont = document.getElementById("bk-font-heading")?.value || "";
778
+ const bodyFont = document.getElementById("bk-font-body")?.value || "";
779
+ const headingPreview = document.getElementById("brand-preview-heading");
780
+ const bodyPreview = document.getElementById("brand-preview-body");
781
+ if (headingPreview) headingPreview.style.fontFamily = headingFont || "Georgia, serif";
782
+ if (bodyPreview) bodyPreview.style.fontFamily = bodyFont || "Arial, Helvetica, sans-serif";
783
+
784
+ const logoUrl = document.getElementById("bk-logo-url")?.value?.trim();
785
+ const logoImg = document.getElementById("brand-preview-logo");
786
+ const logoPlaceholder = document.getElementById("brand-preview-logo-placeholder");
787
+ if (logoImg && logoPlaceholder) {
788
+ if (logoUrl) {
789
+ logoImg.src = logoUrl;
790
+ logoImg.hidden = false;
791
+ logoPlaceholder.hidden = true;
792
+ logoImg.onerror = () => {
793
+ logoImg.hidden = true;
794
+ logoPlaceholder.hidden = false;
795
+ logoPlaceholder.textContent = "Bad URL";
796
+ };
797
+ logoImg.onload = () => {
798
+ logoPlaceholder.textContent = "No logo";
799
+ };
800
+ } else {
801
+ logoImg.hidden = true;
802
+ logoImg.removeAttribute("src");
803
+ logoPlaceholder.hidden = false;
804
+ logoPlaceholder.textContent = "No logo";
805
+ }
806
+ }
807
+ }
808
+
809
+ for (const id of [
810
+ "bk-hex-primary", "bk-hex-secondary", "bk-hex-accent",
811
+ "bk-color-primary", "bk-color-secondary", "bk-color-accent",
812
+ "bk-logo-url",
813
+ ]) {
814
+ document.getElementById(id)?.addEventListener("input", updateBrandPreview);
815
+ }
816
+ for (const id of ["bk-font-heading", "bk-font-body"]) {
817
+ document.getElementById(id)?.addEventListener("change", updateBrandPreview);
818
+ }
819
+
820
+ document.addEventListener("DOMContentLoaded", () => { loadFontList(); updateBrandPreview(); });
821
+
822
+ function collectBrandKit() {
823
+ const kit = {};
824
+ const primary = document.getElementById("bk-hex-primary")?.value?.trim();
825
+ const secondary = document.getElementById("bk-hex-secondary")?.value?.trim();
826
+ const accent = document.getElementById("bk-hex-accent")?.value?.trim();
827
+ const hexRe = /^#[0-9a-fA-F]{6}$/;
828
+ const colors = {};
829
+ if (primary && hexRe.test(primary)) colors.primary = primary;
830
+ if (secondary && hexRe.test(secondary)) colors.secondary = secondary;
831
+ if (accent && hexRe.test(accent)) colors.accent = accent;
832
+ if (Object.keys(colors).length > 0) kit.colors = colors;
833
+
834
+ const heading = document.getElementById("bk-font-heading")?.value || "";
835
+ const body = document.getElementById("bk-font-body")?.value || "";
836
+ const fonts = {};
837
+ if (heading) fonts.heading = heading;
838
+ if (body) fonts.body = body;
839
+ if (Object.keys(fonts).length > 0) kit.fonts = fonts;
840
+
841
+ const logo = document.getElementById("bk-logo-url")?.value?.trim();
842
+ if (logo) kit.logoUrl = logo;
843
+
844
+ return kit;
845
+ }
846
+
847
+ // Sync color picker ↔ hex input
848
+ for (const key of ["primary", "secondary", "accent"]) {
849
+ const colorInput = document.getElementById(`bk-color-${key}`);
850
+ const hexInput = document.getElementById(`bk-hex-${key}`);
851
+ if (colorInput && hexInput) {
852
+ colorInput.addEventListener("input", () => { hexInput.value = colorInput.value; });
853
+ hexInput.addEventListener("input", () => {
854
+ if (/^#[0-9a-fA-F]{6}$/.test(hexInput.value)) colorInput.value = hexInput.value;
855
+ });
856
+ }
857
+ }
858
+
859
+ document.getElementById("bk-save")?.addEventListener("click", async () => {
860
+ const kit = collectBrandKit();
861
+ if (Object.keys(kit).length === 0) {
862
+ await vibeAlert("Please fill in at least one field.", "Info");
863
+ return;
864
+ }
865
+ try {
866
+ const res = await fetch("/api/brand-kit", {
867
+ method: "POST",
868
+ headers: { "Content-Type": "application/json" },
869
+ body: JSON.stringify(kit),
870
+ });
871
+ const data = await res.json();
872
+ if (data.ok) {
873
+ await vibeAlert("Brand kit saved.", "Success");
874
+ } else {
875
+ await vibeAlert(data.error || "Failed to save brand kit.", "Error");
876
+ }
877
+ } catch (err) {
878
+ await vibeAlert("Failed to save: " + err.message, "Error");
879
+ }
880
+ });
881
+
882
+ document.getElementById("bk-clear")?.addEventListener("click", async () => {
883
+ const ok = await vibeConfirm("Clear brand kit?", "This will remove all brand kit settings.", { confirmLabel: "Clear", confirmClass: "btn--danger" });
884
+ if (!ok) return;
885
+ try {
886
+ await fetch("/api/brand-kit", { method: "DELETE" });
887
+ renderBrandKit(null);
888
+ } catch (err) {
889
+ await vibeAlert("Failed to clear: " + err.message, "Error");
890
+ }
891
+ });
892
+
419
893
  // ---------------------------------------------------------------------------
420
894
  // Actions
421
895
  // ---------------------------------------------------------------------------
@@ -426,6 +900,7 @@ async function createTemplateFromPageType(pageType) {
426
900
  blog_post: "Blog Post",
427
901
  website_page: "Website Page",
428
902
  module_only: "Module",
903
+ email: "Email",
429
904
  };
430
905
 
431
906
  const label = await vibePrompt("Template name", defaultLabels[pageType] || "New Template");
@@ -443,8 +918,12 @@ async function createTemplateFromPageType(pageType) {
443
918
  return;
444
919
  }
445
920
 
446
- // Open the newly created template in chat
447
- openTemplate(data.template.id);
921
+ // Navigate to the editor with the new asset selected
922
+ await openTemplate(data.template.id);
923
+
924
+ // Focus the chat input so the user can start describing their page
925
+ const chatInput = document.getElementById("chat-input");
926
+ if (chatInput) setTimeout(() => chatInput.focus(), 100);
448
927
  } catch (err) {
449
928
  await vibeAlert("Failed to create template: " + err.message, "Error");
450
929
  }
@@ -561,6 +1040,10 @@ async function handleBrandFileSelected(type, file) {
561
1040
  await vibeAlert(data.error, "Error");
562
1041
  return;
563
1042
  }
1043
+ if (data.brandKit) {
1044
+ await loadFontList();
1045
+ renderBrandKit(data.brandKit);
1046
+ }
564
1047
  refreshDashboard();
565
1048
  } catch (err) {
566
1049
  await vibeAlert("Failed to upload: " + err.message, "Error");
@@ -572,22 +1055,19 @@ async function handleBrandFileSelected(type, file) {
572
1055
  // ---------------------------------------------------------------------------
573
1056
 
574
1057
  function showChat(themeName, templateId) {
575
- hideDashboard();
576
-
577
- // Show app screen
578
- appScreen.classList.remove("hidden");
1058
+ const ab = document.getElementById("app-body");
1059
+ if (ab) ab.dataset.mode = "editor";
1060
+ dashboardScreen.classList.remove("hidden");
579
1061
  document.getElementById("theme-name").textContent = themeName;
580
1062
 
581
- // Update browser chrome URL bar
582
- const urlEl = document.getElementById("browser-url");
583
- if (urlEl) urlEl.textContent = themeName + ".vibespot.app";
584
-
585
1063
  // Update URL
586
1064
  const target = `#/app/${encodeURIComponent(themeName)}/${encodeURIComponent(templateId)}`;
587
1065
  if (location.hash !== target) {
588
1066
  history.pushState(null, "", target);
589
1067
  }
590
1068
 
1069
+ switchWorkspaceTab("pages");
1070
+
591
1071
  // Connect WebSocket (defined in chat.js)
592
1072
  if (typeof connectWebSocket === "function") {
593
1073
  connectWebSocket();
@@ -597,6 +1077,8 @@ function showChat(themeName, templateId) {
597
1077
  if (typeof refreshPreview === "function") {
598
1078
  refreshPreview();
599
1079
  }
1080
+
1081
+ setTimeout(() => document.getElementById("chat-input")?.focus(), 100);
600
1082
  }
601
1083
 
602
1084
  // ---------------------------------------------------------------------------
@@ -610,26 +1092,11 @@ document.querySelectorAll(".page-type-card").forEach((card) => {
610
1092
  });
611
1093
  });
612
1094
 
613
- // Back button setup
614
- document.getElementById("dashboard-back").addEventListener("click", () => {
615
- hideDashboard();
616
- if (typeof showSetup === "function") showSetup();
617
- });
618
-
619
- // Settings button
620
- document.getElementById("dashboard-settings-btn").addEventListener("click", () => {
621
- if (typeof openSettings === "function") openSettings();
622
- });
1095
+ // Deploy button handled by chat.js (single listener to avoid duplicate overlays)
623
1096
 
624
- // Deploy button
625
- document.getElementById("dashboard-deploy-btn").addEventListener("click", () => {
626
- if (typeof startUpload === "function") {
627
- // Need to show app screen temporarily for upload
628
- appScreen.classList.remove("hidden");
629
- dashboardScreen.classList.add("hidden");
630
- startUpload();
631
- }
632
- });
1097
+ // Library tab — add page / add email
1098
+ document.getElementById("library-add-page")?.addEventListener("click", () => createTemplateFromPageType("landing_page"));
1099
+ document.getElementById("library-add-email")?.addEventListener("click", () => createTemplateFromPageType("email"));
633
1100
 
634
1101
  // Extract All button
635
1102
  document.getElementById("btn-extract-all")?.addEventListener("click", async () => {
@@ -658,13 +1125,18 @@ document.getElementById("btn-extract-all")?.addEventListener("click", async () =
658
1125
  });
659
1126
  const data = await res.json();
660
1127
  if (data.ok) {
1128
+ if (data.brandKit) {
1129
+ await loadFontList();
1130
+ renderBrandKit(data.brandKit);
1131
+ }
661
1132
  await refreshDashboard();
662
1133
  const extracted = data.extracted || {};
663
1134
  const names = Object.entries(extracted)
664
1135
  .filter(([, v]) => v)
665
1136
  .map(([k]) => ASSET_LABELS[k] || k);
666
1137
  if (names.length > 0) {
667
- await vibeAlert(`Extracted: ${names.join(", ")}`, "Done");
1138
+ const suffix = data.brandKit ? " Brand kit updated from styleguide." : "";
1139
+ await vibeAlert(`Extracted: ${names.join(", ")}.${suffix}`, "Done");
668
1140
  } else {
669
1141
  await vibeAlert("Nothing to extract \u2014 generate some modules first.", "Info");
670
1142
  }
@@ -728,9 +1200,9 @@ document.getElementById("btn-import-reference")?.addEventListener("click", async
728
1200
  }
729
1201
  });
730
1202
 
731
- // Dashboard theme heading — double-click to rename
732
- document.getElementById("dashboard-theme-heading")?.addEventListener("dblclick", () => {
733
- const el = document.getElementById("dashboard-theme-heading");
1203
+ // Theme name pill — double-click to rename
1204
+ document.getElementById("theme-name")?.addEventListener("dblclick", () => {
1205
+ const el = document.getElementById("theme-name");
734
1206
  if (!el || !currentDashboardSessionId) return;
735
1207
  if (el.contentEditable === "true") return;
736
1208
 
@@ -765,7 +1237,7 @@ document.getElementById("dashboard-theme-heading")?.addEventListener("dblclick",
765
1237
  if (data.ok) {
766
1238
  el.textContent = data.newName;
767
1239
  currentDashboardTheme = data.newName;
768
- document.getElementById("dashboard-theme-name").textContent = data.newName;
1240
+ document.getElementById("theme-name").textContent = data.newName;
769
1241
  window.location.hash = "#/dashboard/" + encodeURIComponent(data.newName);
770
1242
  // Update rail
771
1243
  const railItem = document.querySelector(`.project-rail__item[data-name="${oldName}"]`);
@@ -803,36 +1275,6 @@ document.getElementById("dashboard-theme-heading")?.addEventListener("dblclick",
803
1275
  });
804
1276
  });
805
1277
 
806
- // Download ZIP button
807
- document.getElementById("dashboard-download-zip").addEventListener("click", async () => {
808
- const btn = document.getElementById("dashboard-download-zip");
809
- const origHTML = btn.innerHTML;
810
- btn.disabled = true;
811
- btn.querySelector("span").textContent = "Downloading...";
812
-
813
- try {
814
- const res = await fetch("/api/download-zip");
815
- if (!res.ok) {
816
- const err = await res.json().catch(() => ({ error: "Download failed" }));
817
- throw new Error(err.error || "Download failed");
818
- }
819
- const blob = await res.blob();
820
- const url = URL.createObjectURL(blob);
821
- const a = document.createElement("a");
822
- a.href = url;
823
- a.download = (currentDashboardTheme || "theme") + ".zip";
824
- document.body.appendChild(a);
825
- a.click();
826
- a.remove();
827
- URL.revokeObjectURL(url);
828
- } catch (err) {
829
- if (typeof vibeAlert === "function") vibeAlert(err.message, "Error");
830
- } finally {
831
- btn.disabled = false;
832
- btn.innerHTML = origHTML;
833
- }
834
- });
835
-
836
1278
  // Humanify toggle
837
1279
  const humanifyCheckbox = document.getElementById("humanify-checkbox");
838
1280
  if (humanifyCheckbox) {
@@ -844,3 +1286,29 @@ if (humanifyCheckbox) {
844
1286
  });
845
1287
  });
846
1288
  }
1289
+
1290
+ // ---------------------------------------------------------------------------
1291
+ // Workspace tab navigation
1292
+ // ---------------------------------------------------------------------------
1293
+
1294
+ function switchWorkspaceTab(tabName) {
1295
+ document.querySelectorAll(".workspace-tab").forEach((btn) => {
1296
+ btn.classList.toggle("active", btn.dataset.wsTab === tabName);
1297
+ });
1298
+ document.querySelectorAll(".workspace-panel").forEach((panel) => {
1299
+ panel.classList.toggle("active", panel.dataset.wsPanel === tabName);
1300
+ });
1301
+ if (tabName === "settings" && typeof refreshSettings === "function") {
1302
+ refreshSettings();
1303
+ }
1304
+ if (tabName === "library") {
1305
+ refreshDashboard();
1306
+ }
1307
+ }
1308
+
1309
+ document.querySelectorAll(".workspace-tab").forEach((btn) => {
1310
+ btn.addEventListener("click", () => {
1311
+ switchWorkspaceTab(btn.dataset.wsTab);
1312
+ });
1313
+ });
1314
+