vibespot 0.9.2 → 0.9.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibespot",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "description": "AI-powered HubSpot CMS landing page builder — vibe coding & React converter",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,11 +21,18 @@
21
21
  "dependencies": {
22
22
  "@anthropic-ai/sdk": "^0.39.0",
23
23
  "@clack/prompts": "^0.9.1",
24
+ "@codemirror/lang-css": "^6.3.1",
25
+ "@codemirror/lang-html": "^6.4.11",
26
+ "@codemirror/lang-javascript": "^6.2.5",
27
+ "@codemirror/lang-json": "^6.0.2",
28
+ "@codemirror/theme-one-dark": "^6.1.3",
24
29
  "busboy": "^1.6.0",
25
30
  "chalk": "^5.4.1",
31
+ "codemirror": "^6.0.2",
26
32
  "commander": "^13.1.0",
27
33
  "execa": "^9.5.2",
28
34
  "mammoth": "^1.11.0",
35
+ "marked": "^17.0.4",
29
36
  "pdf-parse": "^2.4.5",
30
37
  "ws": "^8.19.0"
31
38
  },
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Code editor view — file browser + CodeMirror 6 editor.
3
+ * Toggles with the preview in the right panel.
4
+ */
5
+
6
+ let codeEditorView = null;
7
+ let codeFiles = [];
8
+ let currentCodeFile = null;
9
+ let codeDirty = false;
10
+ let codeSavedSinceSwitch = false;
11
+
12
+ function prettyJson(str) {
13
+ try { return JSON.stringify(JSON.parse(str), null, 2); } catch { return str || ""; }
14
+ }
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // File tree building
18
+ // ---------------------------------------------------------------------------
19
+
20
+ function buildCodeFileTree(data) {
21
+ const files = [];
22
+
23
+ // Shared files
24
+ if (data.sharedCss) {
25
+ files.push({ id: "shared/theme.css", label: "theme.css", group: "Shared", lang: "css", content: data.sharedCss, shared: "css" });
26
+ }
27
+ if (data.sharedJs) {
28
+ files.push({ id: "shared/animations.js", label: "animations.js", group: "Shared", lang: "javascript", content: data.sharedJs, shared: "js" });
29
+ }
30
+
31
+ // Per-module files
32
+ for (const mod of (data.modules || [])) {
33
+ const name = mod.moduleName;
34
+ files.push({ id: name + "/module.html", label: "module.html", group: name, lang: "html", content: mod.moduleHtml, moduleName: name, fileType: "html" });
35
+ files.push({ id: name + "/module.css", label: "module.css", group: name, lang: "css", content: mod.moduleCss, moduleName: name, fileType: "css" });
36
+ if (mod.moduleJs) {
37
+ files.push({ id: name + "/module.js", label: "module.js", group: name, lang: "javascript", content: mod.moduleJs, moduleName: name, fileType: "js" });
38
+ }
39
+ files.push({ id: name + "/fields.json", label: "fields.json", group: name, lang: "json", content: mod.fieldsJson, moduleName: name, fileType: "fields" });
40
+ }
41
+
42
+ return files;
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // File browser rendering
47
+ // ---------------------------------------------------------------------------
48
+
49
+ const FILE_ICONS = {
50
+ html: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>',
51
+ css: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h16M4 12h10M4 17h6"/></svg>',
52
+ javascript: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/></svg>',
53
+ json: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3"/><path d="M16 3h3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-3"/></svg>',
54
+ };
55
+
56
+ function renderCodeFileList(files) {
57
+ const listEl = document.getElementById("code-file-list");
58
+ listEl.innerHTML = "";
59
+
60
+ // Group files
61
+ const groups = new Map();
62
+ for (const f of files) {
63
+ if (!groups.has(f.group)) groups.set(f.group, []);
64
+ groups.get(f.group).push(f);
65
+ }
66
+
67
+ for (const [groupName, groupFiles] of groups) {
68
+ const groupEl = document.createElement("div");
69
+ groupEl.className = "code-view__file-group";
70
+
71
+ const headerEl = document.createElement("div");
72
+ headerEl.className = "code-view__group-header";
73
+ headerEl.textContent = groupName;
74
+ groupEl.appendChild(headerEl);
75
+
76
+ for (const f of groupFiles) {
77
+ const itemEl = document.createElement("div");
78
+ itemEl.className = "code-view__file-item";
79
+ itemEl.dataset.fileId = f.id;
80
+ itemEl.innerHTML = (FILE_ICONS[f.lang] || "") + '<span class="code-view__file-name">' + f.label + "</span>";
81
+ itemEl.addEventListener("click", () => openCodeFile(f));
82
+ groupEl.appendChild(itemEl);
83
+ }
84
+
85
+ listEl.appendChild(groupEl);
86
+ }
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Editor management
91
+ // ---------------------------------------------------------------------------
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Custom vibespot theme (uses CSS variables for dark/light support)
95
+ // ---------------------------------------------------------------------------
96
+
97
+ function vibespotTheme() {
98
+ const theme = CM.EditorView.theme({
99
+ "&": {
100
+ backgroundColor: "var(--bg-deep, #0a0a0a)",
101
+ color: "var(--text, #f5f0eb)",
102
+ },
103
+ ".cm-content": {
104
+ caretColor: "var(--accent, #e8613a)",
105
+ },
106
+ ".cm-cursor, .cm-dropCursor": {
107
+ borderLeftColor: "var(--accent, #e8613a)",
108
+ },
109
+ "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
110
+ backgroundColor: "var(--accent-dim, rgba(232,97,58,0.15))",
111
+ },
112
+ ".cm-panels": {
113
+ backgroundColor: "var(--bg-panel-solid, #131110)",
114
+ color: "var(--text, #f5f0eb)",
115
+ },
116
+ ".cm-panels.cm-panels-top": {
117
+ borderBottom: "1px solid var(--border, rgba(255,255,255,0.06))",
118
+ },
119
+ ".cm-panels.cm-panels-bottom": {
120
+ borderTop: "1px solid var(--border, rgba(255,255,255,0.06))",
121
+ },
122
+ ".cm-searchMatch": {
123
+ backgroundColor: "var(--accent-dim, rgba(232,97,58,0.15))",
124
+ outline: "1px solid var(--accent-glow, rgba(232,97,58,0.3))",
125
+ },
126
+ ".cm-searchMatch.cm-searchMatch-selected": {
127
+ backgroundColor: "var(--accent-dim, rgba(232,97,58,0.15))",
128
+ },
129
+ ".cm-activeLine": {
130
+ backgroundColor: "var(--bg-input, rgba(255,255,255,0.04))",
131
+ },
132
+ ".cm-selectionMatch": {
133
+ backgroundColor: "var(--accent-tint, rgba(232,97,58,0.06))",
134
+ },
135
+ ".cm-matchingBracket, .cm-nonmatchingBracket": {
136
+ backgroundColor: "var(--accent-dim, rgba(232,97,58,0.15))",
137
+ outline: "1px solid var(--accent-glow, rgba(232,97,58,0.3))",
138
+ },
139
+ ".cm-gutters": {
140
+ backgroundColor: "var(--bg-panel-solid, #131110)",
141
+ color: "var(--text-muted, rgba(255,255,255,0.2))",
142
+ border: "none",
143
+ borderRight: "1px solid var(--border, rgba(255,255,255,0.06))",
144
+ },
145
+ ".cm-activeLineGutter": {
146
+ backgroundColor: "var(--bg-input, rgba(255,255,255,0.04))",
147
+ color: "var(--text-dim, rgba(255,255,255,0.45))",
148
+ },
149
+ ".cm-foldPlaceholder": {
150
+ backgroundColor: "transparent",
151
+ border: "none",
152
+ color: "var(--text-muted)",
153
+ },
154
+ ".cm-tooltip": {
155
+ border: "1px solid var(--border)",
156
+ backgroundColor: "var(--bg-panel-solid)",
157
+ color: "var(--text)",
158
+ },
159
+ ".cm-tooltip .cm-tooltip-arrow:before": {
160
+ borderTopColor: "transparent",
161
+ borderBottomColor: "transparent",
162
+ },
163
+ ".cm-tooltip .cm-tooltip-arrow:after": {
164
+ borderTopColor: "var(--bg-panel-solid)",
165
+ borderBottomColor: "var(--bg-panel-solid)",
166
+ },
167
+ ".cm-tooltip-autocomplete": {
168
+ "& > ul > li[aria-selected]": {
169
+ backgroundColor: "var(--accent-dim)",
170
+ color: "var(--text)",
171
+ },
172
+ },
173
+ }, { dark: true });
174
+
175
+ return theme;
176
+ }
177
+
178
+ function vibespotHighlight() {
179
+ const t = CM.tags;
180
+ return CM.syntaxHighlighting(CM.HighlightStyle.define([
181
+ { tag: t.keyword, color: "#c678dd" },
182
+ { tag: [t.name, t.deleted, t.character, t.propertyName, t.macroName], color: "#e06c75" },
183
+ { tag: [t.function(t.variableName), t.labelName], color: "#61afef" },
184
+ { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: "#d19a66" },
185
+ { tag: [t.definition(t.name), t.separator], color: "#abb2bf" },
186
+ { tag: [t.typeName, t.className, t.number, t.changed, t.annotation, t.modifier, t.self, t.namespace], color: "#e5c07b" },
187
+ { tag: [t.operator, t.operatorKeyword, t.url, t.escape, t.regexp, t.link, t.special(t.string)], color: "#56b6c2" },
188
+ { tag: [t.meta, t.comment], color: "#5c6370", fontStyle: "italic" },
189
+ { tag: t.strong, fontWeight: "bold" },
190
+ { tag: t.emphasis, fontStyle: "italic" },
191
+ { tag: t.strikethrough, textDecoration: "line-through" },
192
+ { tag: t.link, color: "#56b6c2", textDecoration: "underline" },
193
+ { tag: t.heading, fontWeight: "bold", color: "#e06c75" },
194
+ { tag: [t.atom, t.bool, t.special(t.variableName)], color: "#d19a66" },
195
+ { tag: [t.processingInstruction, t.string, t.inserted], color: "#98c379" },
196
+ { tag: t.invalid, color: "#ffffff", backgroundColor: "#e06c75" },
197
+ ]));
198
+ }
199
+
200
+ function getLangExtension(lang) {
201
+ if (!window.CM) return [];
202
+ switch (lang) {
203
+ case "html": return [CM.html()];
204
+ case "css": return [CM.css()];
205
+ case "javascript": return [CM.javascript()];
206
+ case "json": return [CM.json()];
207
+ default: return [];
208
+ }
209
+ }
210
+
211
+ function openCodeFile(file) {
212
+ // Warn if dirty
213
+ if (codeDirty && currentCodeFile) {
214
+ if (!confirm("You have unsaved changes. Discard them?")) return;
215
+ }
216
+
217
+ currentCodeFile = file;
218
+ codeDirty = false;
219
+ updateDirtyState();
220
+
221
+ // Update sidebar selection
222
+ document.querySelectorAll(".code-view__file-item").forEach((el) => {
223
+ el.classList.toggle("code-view__file-item--active", el.dataset.fileId === file.id);
224
+ });
225
+
226
+ // Update header
227
+ document.getElementById("code-filename").textContent = file.id;
228
+
229
+ // Destroy previous editor
230
+ if (codeEditorView) {
231
+ codeEditorView.destroy();
232
+ codeEditorView = null;
233
+ }
234
+
235
+ const area = document.getElementById("code-editor-area");
236
+ area.innerHTML = "";
237
+
238
+ if (!window.CM) {
239
+ area.textContent = "CodeMirror not loaded";
240
+ return;
241
+ }
242
+
243
+ const saveKeymap = CM.keymap.of([{
244
+ key: "Mod-s",
245
+ run: () => { saveCurrentFile(); return true; },
246
+ }]);
247
+
248
+ codeEditorView = new CM.EditorView({
249
+ state: CM.EditorState.create({
250
+ doc: file.lang === "json" ? prettyJson(file.content) : (file.content || ""),
251
+ extensions: [
252
+ CM.basicSetup,
253
+ ...getLangExtension(file.lang),
254
+ vibespotTheme(),
255
+ vibespotHighlight(),
256
+ saveKeymap,
257
+ CM.EditorView.updateListener.of((update) => {
258
+ if (update.docChanged && !codeDirty) {
259
+ codeDirty = true;
260
+ updateDirtyState();
261
+ }
262
+ }),
263
+ ],
264
+ }),
265
+ parent: area,
266
+ });
267
+ }
268
+
269
+ function updateDirtyState() {
270
+ const dot = document.getElementById("code-dirty-dot");
271
+ const btn = document.getElementById("code-save-btn");
272
+ if (codeDirty) {
273
+ dot.classList.remove("hidden");
274
+ btn.disabled = false;
275
+ } else {
276
+ dot.classList.add("hidden");
277
+ btn.disabled = true;
278
+ }
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // Save
283
+ // ---------------------------------------------------------------------------
284
+
285
+ async function saveCurrentFile() {
286
+ if (!currentCodeFile || !codeEditorView || !codeDirty) return;
287
+
288
+ const content = codeEditorView.state.doc.toString();
289
+ const btn = document.getElementById("code-save-btn");
290
+ btn.disabled = true;
291
+ btn.textContent = "Saving...";
292
+
293
+ try {
294
+ const body = currentCodeFile.shared
295
+ ? { shared: currentCodeFile.shared, content }
296
+ : { moduleName: currentCodeFile.moduleName, fileType: currentCodeFile.fileType, content };
297
+
298
+ const res = await fetch("/api/modules/code", {
299
+ method: "POST",
300
+ headers: { "Content-Type": "application/json" },
301
+ body: JSON.stringify(body),
302
+ });
303
+ const data = await res.json();
304
+ if (data.ok) {
305
+ codeDirty = false;
306
+ codeSavedSinceSwitch = true;
307
+ updateDirtyState();
308
+ // Update the cached content so switching files doesn't lose changes
309
+ currentCodeFile.content = content;
310
+ // Refresh preview so changes are visible when switching back
311
+ if (typeof refreshPreview === "function") refreshPreview();
312
+ } else {
313
+ await vibeAlert(data.error || "Save failed", "Error");
314
+ }
315
+ } catch (err) {
316
+ await vibeAlert("Save failed: " + err.message, "Error");
317
+ } finally {
318
+ btn.textContent = "Save";
319
+ if (!codeDirty) btn.disabled = true;
320
+ }
321
+ }
322
+
323
+ document.getElementById("code-save-btn")?.addEventListener("click", saveCurrentFile);
324
+
325
+ // ---------------------------------------------------------------------------
326
+ // View toggle
327
+ // ---------------------------------------------------------------------------
328
+
329
+ async function loadCodeFiles() {
330
+ try {
331
+ const res = await fetch("/api/modules");
332
+ const data = await res.json();
333
+ codeFiles = buildCodeFileTree(data);
334
+ renderCodeFileList(codeFiles);
335
+
336
+ // Auto-select first file if none selected
337
+ if (codeFiles.length > 0 && !currentCodeFile) {
338
+ openCodeFile(codeFiles[0]);
339
+ } else if (currentCodeFile) {
340
+ // Re-sync content from server
341
+ const updated = codeFiles.find((f) => f.id === currentCodeFile.id);
342
+ if (updated && !codeDirty) {
343
+ openCodeFile(updated);
344
+ }
345
+ }
346
+ } catch (err) {
347
+ console.warn("Failed to load code files:", err);
348
+ }
349
+ }
350
+
351
+ document.querySelectorAll(".view-toggle__btn").forEach((btn) => {
352
+ btn.addEventListener("click", () => {
353
+ const view = btn.dataset.view;
354
+ document.querySelectorAll(".view-toggle__btn").forEach((b) => b.classList.remove("active"));
355
+ btn.classList.add("active");
356
+
357
+ const previewEl = document.getElementById("preview-container");
358
+ const codeEl = document.getElementById("code-view");
359
+ const chromeBar = document.getElementById("browser-chrome");
360
+
361
+ if (view === "code") {
362
+ previewEl.classList.add("hidden");
363
+ codeEl.classList.remove("hidden");
364
+ loadCodeFiles();
365
+ } else {
366
+ codeEl.classList.add("hidden");
367
+ previewEl.classList.remove("hidden");
368
+ // Refresh preview if code was saved while in code view
369
+ if (codeSavedSinceSwitch && typeof refreshPreview === "function") {
370
+ refreshPreview();
371
+ }
372
+ codeSavedSinceSwitch = false;
373
+ }
374
+ });
375
+ });
376
+
377
+ // Reset code editor state when switching templates/themes
378
+ function resetCodeEditor() {
379
+ if (codeEditorView) {
380
+ codeEditorView.destroy();
381
+ codeEditorView = null;
382
+ }
383
+ currentCodeFile = null;
384
+ codeDirty = false;
385
+ codeFiles = [];
386
+ const listEl = document.getElementById("code-file-list");
387
+ if (listEl) listEl.innerHTML = "";
388
+ const area = document.getElementById("code-editor-area");
389
+ if (area) area.innerHTML = "";
390
+ const filename = document.getElementById("code-filename");
391
+ if (filename) filename.textContent = "Select a file";
392
+ updateDirtyState();
393
+ }
package/ui/dashboard.js CHANGED
@@ -110,6 +110,7 @@ function renderTemplateList(templates) {
110
110
  <span class="dashboard__template-label">${esc(tpl.label)}</span>
111
111
  <span class="dashboard__template-meta">${tpl.moduleCount} module${tpl.moduleCount !== 1 ? "s" : ""}</span>
112
112
  <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>
113
114
  <button class="dashboard__template-delete" data-id="${esc(tpl.id)}" title="Delete template">&times;</button>
114
115
  `;
115
116
  list.appendChild(item);
@@ -119,6 +120,8 @@ function renderTemplateList(templates) {
119
120
  list.onclick = (e) => {
120
121
  const openBtn = e.target.closest(".dashboard__template-open");
121
122
  if (openBtn) return openTemplate(openBtn.dataset.id);
123
+ const cloneBtn = e.target.closest(".dashboard__template-clone");
124
+ if (cloneBtn) return cloneTemplateAction(cloneBtn.dataset.id);
122
125
  const delBtn = e.target.closest(".dashboard__template-delete");
123
126
  if (delBtn) return confirmDeleteTemplate(delBtn.dataset.id);
124
127
  };
@@ -290,6 +293,31 @@ function renderBrandAssets(assets) {
290
293
  bvIcon.classList.remove("brand-asset-upload__icon--done");
291
294
  }
292
295
 
296
+ // View buttons — show only when asset exists
297
+ let sgView = document.getElementById("btn-view-styleguide");
298
+ if (!sgView) {
299
+ sgView = document.createElement("button");
300
+ sgView.id = "btn-view-styleguide";
301
+ sgView.className = "btn btn--sm btn--ghost brand-asset-view";
302
+ sgView.textContent = "View";
303
+ sgView.title = "View styleguide";
304
+ sgView.addEventListener("click", viewStyleguide);
305
+ document.getElementById("brand-upload-styleguide")?.after(sgView);
306
+ }
307
+ sgView.style.display = assets.hasStyleguide ? "" : "none";
308
+
309
+ let bvView = document.getElementById("btn-view-brandvoice");
310
+ if (!bvView) {
311
+ bvView = document.createElement("button");
312
+ bvView.id = "btn-view-brandvoice";
313
+ bvView.className = "btn btn--sm btn--ghost brand-asset-view";
314
+ bvView.textContent = "View";
315
+ bvView.title = "View brand voice";
316
+ bvView.addEventListener("click", viewBrandvoice);
317
+ document.getElementById("brand-upload-brandvoice")?.after(bvView);
318
+ }
319
+ bvView.style.display = assets.hasBrandvoice ? "" : "none";
320
+
293
321
  // Humanify toggle
294
322
  const humanifyCheckbox = document.getElementById("humanify-checkbox");
295
323
  if (humanifyCheckbox) {
@@ -297,6 +325,34 @@ function renderBrandAssets(assets) {
297
325
  }
298
326
  }
299
327
 
328
+ async function viewStyleguide() {
329
+ try {
330
+ const res = await fetch("/api/brand-assets");
331
+ const data = await res.json();
332
+ if (data.styleguide) {
333
+ await vibeViewContent(data.styleguide, "Styleguide", "styleguide.md");
334
+ } else {
335
+ await vibeAlert("No styleguide found.", "Info");
336
+ }
337
+ } catch (err) {
338
+ await vibeAlert("Failed to load styleguide: " + err.message, "Error");
339
+ }
340
+ }
341
+
342
+ async function viewBrandvoice() {
343
+ try {
344
+ const res = await fetch("/api/brand-assets");
345
+ const data = await res.json();
346
+ if (data.brandvoice) {
347
+ await vibeViewContent(data.brandvoice, "Brand Voice", "brandvoice.md");
348
+ } else {
349
+ await vibeAlert("No brand voice found.", "Info");
350
+ }
351
+ } catch (err) {
352
+ await vibeAlert("Failed to load brand voice: " + err.message, "Error");
353
+ }
354
+ }
355
+
300
356
  // ---------------------------------------------------------------------------
301
357
  // Actions
302
358
  // ---------------------------------------------------------------------------
@@ -368,6 +424,27 @@ async function confirmDeleteTemplate(templateId) {
368
424
  }
369
425
  }
370
426
 
427
+ async function cloneTemplateAction(templateId) {
428
+ const label = prompt("Name for the cloned template:");
429
+ if (!label) return;
430
+
431
+ try {
432
+ const res = await fetch("/api/templates/clone", {
433
+ method: "POST",
434
+ headers: { "Content-Type": "application/json" },
435
+ body: JSON.stringify({ templateId, label }),
436
+ });
437
+ const data = await res.json();
438
+ if (!data.ok) {
439
+ await vibeAlert(data.error || "Clone failed", "Error");
440
+ return;
441
+ }
442
+ await refreshDashboard();
443
+ } catch (err) {
444
+ await vibeAlert("Failed to clone: " + err.message, "Error");
445
+ }
446
+ }
447
+
371
448
  async function uploadBrandAsset(type) {
372
449
  const uploadEl = document.getElementById(`brand-upload-${type}`);
373
450
  const fileInput = uploadEl.querySelector("input[type=file]");
@@ -468,6 +545,81 @@ document.getElementById("brand-upload-brandvoice").querySelector("input").addEve
468
545
  if (e.target.files[0]) handleBrandFileSelected("brandvoice", e.target.files[0]);
469
546
  });
470
547
 
548
+ // Extract design from theme
549
+ document.getElementById("btn-extract-design")?.addEventListener("click", async () => {
550
+ const btn = document.getElementById("btn-extract-design");
551
+ const origText = btn.textContent;
552
+ btn.textContent = "Extracting...";
553
+ btn.disabled = true;
554
+
555
+ try {
556
+ const res = await fetch("/api/brand-assets/extract", {
557
+ method: "POST",
558
+ headers: { "Content-Type": "application/json" },
559
+ body: JSON.stringify({}),
560
+ });
561
+ const data = await res.json();
562
+ if (data.ok) {
563
+ await refreshDashboard();
564
+ const view = await vibeConfirm("Design system extracted and saved as styleguide.", "Would you like to view it?", { confirmLabel: "View Styleguide", confirmClass: "btn--primary" });
565
+ if (view && data.styleguide) {
566
+ await vibeViewContent(data.styleguide, "Styleguide", "styleguide.md");
567
+ }
568
+ } else {
569
+ await vibeAlert(data.error || "Extraction failed", "Error");
570
+ }
571
+ } catch (err) {
572
+ await vibeAlert("Extraction failed: " + err.message, "Error");
573
+ } finally {
574
+ btn.textContent = origText;
575
+ btn.disabled = false;
576
+ }
577
+ });
578
+
579
+ // Import design reference from another theme
580
+ document.getElementById("btn-import-reference")?.addEventListener("click", async () => {
581
+ const input = await vibePrompt(
582
+ "Import design from another theme",
583
+ "",
584
+ "HubSpot theme name or local path (e.g. ~/vibespot-themes/my-theme)"
585
+ );
586
+ if (!input) return;
587
+
588
+ // Detect source: if it looks like a path (contains / or ~), treat as local
589
+ const isLocal = input.includes("/") || input.startsWith("~");
590
+ const body = isLocal
591
+ ? { source: "local", localPath: input }
592
+ : { source: "hubspot", themeName: input };
593
+
594
+ const btn = document.getElementById("btn-import-reference");
595
+ const origText = btn.textContent;
596
+ btn.textContent = "Importing...";
597
+ btn.disabled = true;
598
+
599
+ try {
600
+ const res = await fetch("/api/brand-assets/import-reference", {
601
+ method: "POST",
602
+ headers: { "Content-Type": "application/json" },
603
+ body: JSON.stringify(body),
604
+ });
605
+ const data = await res.json();
606
+ if (data.ok) {
607
+ await refreshDashboard();
608
+ const view = await vibeConfirm("Design imported and saved as styleguide.", "Would you like to view it?", { confirmLabel: "View Styleguide", confirmClass: "btn--primary" });
609
+ if (view && data.styleguide) {
610
+ await vibeViewContent(data.styleguide, "Styleguide", "styleguide.md");
611
+ }
612
+ } else {
613
+ await vibeAlert(data.error || "Import failed", "Error");
614
+ }
615
+ } catch (err) {
616
+ await vibeAlert("Import failed: " + err.message, "Error");
617
+ } finally {
618
+ btn.textContent = origText;
619
+ btn.disabled = false;
620
+ }
621
+ });
622
+
471
623
  // Dashboard theme heading — double-click to rename
472
624
  document.getElementById("dashboard-theme-heading")?.addEventListener("dblclick", () => {
473
625
  const el = document.getElementById("dashboard-theme-heading");