vibespot 0.7.0 → 0.9.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibespot",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "AI-powered HubSpot CMS landing page builder — vibe coding & React converter",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,12 +21,16 @@
21
21
  "dependencies": {
22
22
  "@anthropic-ai/sdk": "^0.39.0",
23
23
  "@clack/prompts": "^0.9.1",
24
+ "busboy": "^1.6.0",
24
25
  "chalk": "^5.4.1",
25
26
  "commander": "^13.1.0",
26
27
  "execa": "^9.5.2",
28
+ "mammoth": "^1.11.0",
29
+ "pdf-parse": "^2.4.5",
27
30
  "ws": "^8.19.0"
28
31
  },
29
32
  "devDependencies": {
33
+ "@types/busboy": "^1.5.4",
30
34
  "@types/node": "^22.0.0",
31
35
  "@types/ws": "^8.18.1",
32
36
  "tsup": "^8.4.0",
package/ui/chat.js CHANGED
@@ -15,6 +15,8 @@ let streamTimerInterval = null;
15
15
  let lastStreamStatus = "";
16
16
  let currentSessionId = "";
17
17
  let currentTemplateId = "";
18
+ let renderScheduled = false;
19
+ let scrollScheduled = false;
18
20
 
19
21
  const messagesEl = document.getElementById("chat-messages");
20
22
  const inputEl = document.getElementById("chat-input");
@@ -47,11 +49,15 @@ function connectWebSocket() {
47
49
  };
48
50
 
49
51
  ws.onclose = () => {
52
+ stopStreamTimer();
53
+ if (isStreaming) finishStreaming();
50
54
  setStatus("Disconnected — reconnecting...");
51
55
  setTimeout(connectWebSocket, 2000);
52
56
  };
53
57
 
54
58
  ws.onerror = () => {
59
+ stopStreamTimer();
60
+ if (isStreaming) finishStreaming();
55
61
  setStatus("Connection error");
56
62
  };
57
63
  }
@@ -147,25 +153,191 @@ function handleWsMessage(msg) {
147
153
  }
148
154
  }
149
155
 
156
+ // ---------------------------------------------------------------------------
157
+ // File attachments
158
+ // ---------------------------------------------------------------------------
159
+
160
+ const pendingFiles = [];
161
+ const fileChipsEl = document.getElementById("file-chips");
162
+ const fileInputEl = document.getElementById("file-input");
163
+ const attachBtn = document.getElementById("btn-attach-file");
164
+ const dropOverlay = document.getElementById("drop-overlay");
165
+ const MAX_FILE_SIZE = 10 * 1024 * 1024;
166
+
167
+ const IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/svg+xml", "image/webp", "image/gif"]);
168
+ const DOC_TYPES = new Set([
169
+ "application/pdf",
170
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
171
+ "text/markdown", "text/plain",
172
+ ]);
173
+ const SUPPORTED_TYPES = new Set([...IMAGE_TYPES, ...DOC_TYPES]);
174
+
175
+ function addPendingFile(file) {
176
+ if (!SUPPORTED_TYPES.has(file.type)) {
177
+ showToast(`Unsupported file type: ${file.name}`);
178
+ return;
179
+ }
180
+ if (file.size > MAX_FILE_SIZE) {
181
+ showToast(`File too large (>10MB): ${file.name}`);
182
+ return;
183
+ }
184
+ pendingFiles.push(file);
185
+ renderFileChips();
186
+ }
187
+
188
+ function removePendingFile(index) {
189
+ pendingFiles.splice(index, 1);
190
+ renderFileChips();
191
+ }
192
+
193
+ function renderFileChips() {
194
+ fileChipsEl.innerHTML = "";
195
+ if (pendingFiles.length === 0) {
196
+ fileChipsEl.classList.remove("visible");
197
+ return;
198
+ }
199
+ fileChipsEl.classList.add("visible");
200
+ pendingFiles.forEach((file, i) => {
201
+ const isImage = IMAGE_TYPES.has(file.type);
202
+ const chip = document.createElement("div");
203
+ chip.className = `file-chip file-chip--${isImage ? "image" : "doc"}`;
204
+ const sizeKB = Math.round(file.size / 1024);
205
+ const sizeStr = sizeKB > 1024 ? `${(sizeKB / 1024).toFixed(1)} MB` : `${sizeKB} KB`;
206
+ chip.innerHTML = `
207
+ <span class="file-chip__icon">${isImage ? "\u{1F5BC}" : "\u{1F4C4}"}</span>
208
+ <span class="file-chip__name">${escapeHtml(file.name)}</span>
209
+ <span class="file-chip__size">${sizeStr}</span>
210
+ <button class="file-chip__remove" title="Remove">&times;</button>
211
+ `;
212
+ chip.querySelector(".file-chip__remove").addEventListener("click", () => removePendingFile(i));
213
+ fileChipsEl.appendChild(chip);
214
+ });
215
+ }
216
+
217
+ function showToast(message) {
218
+ const toast = document.createElement("div");
219
+ toast.className = "toast";
220
+ toast.textContent = message;
221
+ document.body.appendChild(toast);
222
+ setTimeout(() => toast.classList.add("visible"), 10);
223
+ setTimeout(() => {
224
+ toast.classList.remove("visible");
225
+ setTimeout(() => toast.remove(), 300);
226
+ }, 3000);
227
+ }
228
+
229
+ async function uploadFiles(files) {
230
+ const formData = new FormData();
231
+ for (const file of files) {
232
+ formData.append("files", file);
233
+ }
234
+ const resp = await fetch("/api/upload-files", { method: "POST", body: formData });
235
+ if (!resp.ok) {
236
+ const err = await resp.json().catch(() => ({ error: "Upload failed" }));
237
+ throw new Error(err.error || "Upload failed");
238
+ }
239
+ return resp.json();
240
+ }
241
+
242
+ function renderFileChipsInMessage(files) {
243
+ if (!files || files.length === 0) return "";
244
+ return `<div class="chat-msg__files">${files
245
+ .map((f) => {
246
+ const isImage = f.type === "image";
247
+ return `<span class="file-chip file-chip--${isImage ? "image" : "doc"} file-chip--sent">
248
+ <span class="file-chip__icon">${isImage ? "\u{1F5BC}" : "\u{1F4C4}"}</span>
249
+ <span class="file-chip__name">${escapeHtml(f.originalName || f.name)}</span>
250
+ </span>`;
251
+ })
252
+ .join("")}</div>`;
253
+ }
254
+
255
+ // Drag-and-drop
256
+ let dragCounter = 0;
257
+
258
+ inputEl.closest(".chat__input-area").addEventListener("dragenter", (e) => {
259
+ e.preventDefault();
260
+ dragCounter++;
261
+ dropOverlay.classList.remove("hidden");
262
+ });
263
+
264
+ inputEl.closest(".chat__input-area").addEventListener("dragleave", (e) => {
265
+ e.preventDefault();
266
+ dragCounter--;
267
+ if (dragCounter <= 0) {
268
+ dragCounter = 0;
269
+ dropOverlay.classList.add("hidden");
270
+ }
271
+ });
272
+
273
+ inputEl.closest(".chat__input-area").addEventListener("dragover", (e) => {
274
+ e.preventDefault();
275
+ });
276
+
277
+ inputEl.closest(".chat__input-area").addEventListener("drop", (e) => {
278
+ e.preventDefault();
279
+ dragCounter = 0;
280
+ dropOverlay.classList.add("hidden");
281
+ if (e.dataTransfer?.files) {
282
+ for (const file of e.dataTransfer.files) {
283
+ addPendingFile(file);
284
+ }
285
+ }
286
+ });
287
+
288
+ // Paperclip button
289
+ attachBtn.addEventListener("click", () => fileInputEl.click());
290
+ fileInputEl.addEventListener("change", () => {
291
+ for (const file of fileInputEl.files) {
292
+ addPendingFile(file);
293
+ }
294
+ fileInputEl.value = "";
295
+ });
296
+
150
297
  // ---------------------------------------------------------------------------
151
298
  // Sending messages
152
299
  // ---------------------------------------------------------------------------
153
300
 
154
- function sendMessage(text) {
155
- if (!text.trim() || isStreaming || !ws || ws.readyState !== WebSocket.OPEN) return;
301
+ async function sendMessage(text) {
302
+ const hasFiles = pendingFiles.length > 0;
303
+ if ((!text.trim() && !hasFiles) || isStreaming || !ws || ws.readyState !== WebSocket.OPEN) return;
156
304
 
157
305
  // Remove welcome screen
158
306
  const welcome = messagesEl.querySelector(".chat__welcome");
159
307
  if (welcome) welcome.remove();
160
308
 
161
- // Show user message
162
- appendUserMessage(text);
309
+ // Upload files first if any
310
+ let uploadedFiles = [];
311
+ const filesToUpload = [...pendingFiles];
312
+ if (hasFiles) {
313
+ pendingFiles.length = 0;
314
+ renderFileChips();
315
+ setStatus("Uploading files...");
316
+ try {
317
+ const result = await uploadFiles(filesToUpload);
318
+ uploadedFiles = result.files || [];
319
+ if (result.errors?.length) {
320
+ result.errors.forEach((e) => showToast(e));
321
+ }
322
+ } catch (err) {
323
+ showToast(err.message);
324
+ setStatus("Upload failed");
325
+ return;
326
+ }
327
+ }
328
+
329
+ // Show user message with file chips
330
+ appendUserMessage(text, null, uploadedFiles);
163
331
 
164
332
  // Start streaming indicator
165
333
  startStreaming();
166
334
 
167
- // Send via WebSocket
168
- ws.send(JSON.stringify({ type: "chat", message: text }));
335
+ // Send via WebSocket with file IDs
336
+ const payload = { type: "chat", message: text || "(files attached)" };
337
+ if (uploadedFiles.length > 0) {
338
+ payload.fileIds = uploadedFiles.map((f) => f.id);
339
+ }
340
+ ws.send(JSON.stringify(payload));
169
341
 
170
342
  // Clear input
171
343
  inputEl.value = "";
@@ -183,10 +355,11 @@ function formatMessageTime(ts) {
183
355
  return `${d.getHours()}:${d.getMinutes().toString().padStart(2, "0")}`;
184
356
  }
185
357
 
186
- function appendUserMessage(text, timestamp) {
358
+ function appendUserMessage(text, timestamp, files) {
187
359
  const time = formatMessageTime(timestamp || Date.now());
188
360
  const div = document.createElement("div");
189
361
  div.className = "chat-msg chat-msg--user";
362
+ const fileChipsHtml = renderFileChipsInMessage(files);
190
363
  div.innerHTML = `
191
364
  <div class="chat-msg__avatar chat-msg__avatar--user">Y</div>
192
365
  <div class="chat-msg__content">
@@ -194,6 +367,7 @@ function appendUserMessage(text, timestamp) {
194
367
  <span class="chat-msg__sender">You</span>
195
368
  <span class="chat-msg__time">${time}</span>
196
369
  </div>
370
+ ${fileChipsHtml}
197
371
  <div class="chat-msg__bubble">${escapeHtml(text)}</div>
198
372
  </div>`;
199
373
  messagesEl.appendChild(div);
@@ -236,6 +410,16 @@ function handleStreamChunk(text) {
236
410
  if (!streamingMsgEl) return;
237
411
  streamBuffer += text;
238
412
 
413
+ if (!renderScheduled) {
414
+ renderScheduled = true;
415
+ requestAnimationFrame(flushStreamRender);
416
+ }
417
+ }
418
+
419
+ function flushStreamRender() {
420
+ renderScheduled = false;
421
+ if (!streamingMsgEl) return;
422
+
239
423
  // Hide incomplete code fences (AI is writing module code)
240
424
  let display = streamBuffer;
241
425
  const fenceCount = (display.match(/```/g) || []).length;
@@ -410,7 +594,16 @@ function escapeHtml(str) {
410
594
  }
411
595
 
412
596
  function scrollToBottom() {
413
- messagesEl.scrollTop = messagesEl.scrollHeight;
597
+ if (scrollScheduled) return;
598
+ scrollScheduled = true;
599
+ requestAnimationFrame(() => {
600
+ scrollScheduled = false;
601
+ // Only auto-scroll if user is near the bottom (within 150px)
602
+ const gap = messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight;
603
+ if (gap < 150) {
604
+ messagesEl.scrollTop = messagesEl.scrollHeight;
605
+ }
606
+ });
414
607
  }
415
608
 
416
609
  function setStatus(text) {
@@ -488,7 +681,11 @@ async function refreshHistoryPanel() {
488
681
  }
489
682
 
490
683
  list.innerHTML = toggleHtml;
491
- for (const commit of data.commits) {
684
+ const HISTORY_LIMIT = 50;
685
+ const commits = data.commits.slice(0, HISTORY_LIMIT);
686
+ const frag = document.createDocumentFragment();
687
+
688
+ for (const commit of commits) {
492
689
  const isInitial = commit.message.startsWith("Initial ");
493
690
  const isRollback = commit.message.includes("Rollback to:");
494
691
 
@@ -507,7 +704,15 @@ async function refreshHistoryPanel() {
507
704
  <div class="history-item__msg">${escapeHtml(displayMsg)}</div>
508
705
  ${!isInitial ? `<button class="history-item__rollback" data-hash="${escapeHtml(commit.fullHash)}">Restore</button>` : ""}
509
706
  `;
510
- list.appendChild(item);
707
+ frag.appendChild(item);
708
+ }
709
+ list.appendChild(frag);
710
+
711
+ if (data.commits.length > HISTORY_LIMIT) {
712
+ const more = document.createElement("div");
713
+ more.className = "history__show-more";
714
+ more.textContent = `Showing ${HISTORY_LIMIT} of ${data.commits.length} versions`;
715
+ list.appendChild(more);
511
716
  }
512
717
 
513
718
  list.querySelectorAll(".history-item__rollback").forEach((btn) => {
package/ui/dashboard.js CHANGED
@@ -115,25 +115,20 @@ function renderTemplateList(templates) {
115
115
  list.appendChild(item);
116
116
  }
117
117
 
118
- // Attach click handlers
119
- list.querySelectorAll(".dashboard__template-open").forEach((btn) => {
120
- btn.addEventListener("click", () => openTemplate(btn.dataset.id));
121
- });
122
- list.querySelectorAll(".dashboard__template-delete").forEach((btn) => {
123
- btn.addEventListener("click", () => confirmDeleteTemplate(btn.dataset.id));
124
- });
125
-
126
- // Double-click on label to rename
127
- list.querySelectorAll(".dashboard__template-label").forEach((labelEl) => {
118
+ // Event delegation — single listener handles all template actions
119
+ list.onclick = (e) => {
120
+ const openBtn = e.target.closest(".dashboard__template-open");
121
+ if (openBtn) return openTemplate(openBtn.dataset.id);
122
+ const delBtn = e.target.closest(".dashboard__template-delete");
123
+ if (delBtn) return confirmDeleteTemplate(delBtn.dataset.id);
124
+ };
125
+ list.ondblclick = (e) => {
126
+ const labelEl = e.target.closest(".dashboard__template-label");
127
+ if (!labelEl) return;
128
128
  const item = labelEl.closest(".dashboard__template-item");
129
129
  const templateId = item?.querySelector(".dashboard__template-open")?.dataset.id;
130
- if (!templateId) return;
131
-
132
- labelEl.addEventListener("dblclick", (e) => {
133
- e.stopPropagation();
134
- startTemplateRename(labelEl, templateId);
135
- });
136
- });
130
+ if (templateId) startTemplateRename(labelEl, templateId);
131
+ };
137
132
  }
138
133
 
139
134
  function startTemplateRename(labelEl, templateId) {
package/ui/dialog.js CHANGED
@@ -6,10 +6,9 @@
6
6
  // HTML-escape helper (standalone so dialog.js has no load-order dependency)
7
7
  if (typeof esc === "undefined") {
8
8
  // eslint-disable-next-line no-var
9
+ var _escMap = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
9
10
  var esc = function (str) {
10
- const el = document.createElement("span");
11
- el.textContent = String(str);
12
- return el.innerHTML;
11
+ return String(str).replace(/[&<>"']/g, function (c) { return _escMap[c]; });
13
12
  };
14
13
  }
15
14
 
package/ui/favicon.ico CHANGED
Binary file
@@ -38,6 +38,7 @@ async function openFieldEditor(moduleName) {
38
38
  }
39
39
 
40
40
  function closeFieldEditor() {
41
+ if (updateTimer) { clearTimeout(updateTimer); updateTimer = null; }
41
42
  currentEditModule = null;
42
43
  if (typeof showModuleListView === "function") {
43
44
  showModuleListView();
package/ui/index.html CHANGED
@@ -70,53 +70,63 @@
70
70
  <!-- Settings link (hidden — now in sidebar footer) -->
71
71
  <div class="setup__settings-link hidden" id="setup-settings-link"></div>
72
72
 
73
- <!-- Main options -->
73
+ <!-- Main action buttons -->
74
74
  <div class="setup__options" id="setup-options">
75
- <!-- Previous sessions (hidden — now shown in sidebar) -->
76
- <div class="setup__section hidden" id="section-recent">
77
- <div class="setup__cards" id="recent-sessions"></div>
78
- </div>
79
-
80
- <!-- Local themes (hidden — now shown in sidebar) -->
81
- <div class="setup__section hidden" id="section-local">
82
- <div class="setup__cards" id="local-themes"></div>
75
+ <div class="setup__actions">
76
+ <button class="setup__action-btn" data-action="new">
77
+ <span class="setup__action-icon">&#43;</span>
78
+ <span>New Theme</span>
79
+ </button>
80
+ <button class="setup__action-btn" data-action="continue">
81
+ <span class="setup__action-icon">&#9654;</span>
82
+ <span>Continue</span>
83
+ </button>
84
+ <button class="setup__action-btn" data-action="download">
85
+ <span class="setup__action-icon">&#8595;</span>
86
+ <span>From HubSpot</span>
87
+ </button>
88
+ <button class="setup__action-btn" data-action="convert">
89
+ <span class="setup__action-icon">&#8634;</span>
90
+ <span>Convert React</span>
91
+ <span class="setup__action-badge">Experimental</span>
92
+ </button>
83
93
  </div>
84
94
 
85
- <!-- Create new -->
86
- <div class="setup__section">
87
- <p class="setup__label">Start a new project</p>
95
+ <!-- Expandable panels (one visible at a time) -->
96
+ <div class="setup__panel hidden" id="panel-new">
88
97
  <div class="setup__row">
89
98
  <input type="text" class="setup__input setup__input--wide" id="new-theme-name" placeholder="my-landing-page" />
90
99
  <button class="btn btn--primary" id="btn-create-theme">Create</button>
91
100
  </div>
92
101
  </div>
93
102
 
94
- <!-- Fetch from HubSpot (only shown if hs is installed) -->
95
- <div class="setup__section hidden" id="section-fetch">
96
- <p class="setup__label">Download from HubSpot</p>
97
- <div class="setup__row">
98
- <input type="text" class="setup__input setup__input--wide" id="fetch-theme-name" placeholder="theme-name-in-hubspot" />
99
- <button class="btn btn--secondary" id="btn-fetch-theme">Fetch</button>
100
- </div>
103
+ <div class="setup__panel hidden" id="panel-continue">
104
+ <div class="setup__pills" id="continue-projects"></div>
105
+ <p class="setup__hint setup__hint--empty hidden" id="continue-empty">No themes yet — create one above!</p>
101
106
  </div>
102
107
 
103
- <!-- Import from GitHub -->
104
- <div class="setup__section">
105
- <p class="setup__label">Import from GitHub</p>
106
- <div class="setup__row">
107
- <input type="text" class="setup__input setup__input--wide" id="import-url" placeholder="https://github.com/user/my-landing-page" />
108
- <button class="btn btn--secondary" id="import-btn">Import</button>
108
+ <div class="setup__panel hidden" id="panel-download">
109
+ <div class="setup__panel-account hidden" id="dl-account">
110
+ <span id="dl-account-name"></span>
111
+ <button class="btn-link" id="dl-account-change">Change</button>
112
+ </div>
113
+ <div id="dl-account-switch" class="hidden" style="margin:0 0 12px"></div>
114
+ <div class="setup__row hidden" id="dl-input-row">
115
+ <input type="text" class="setup__input setup__input--wide" id="dl-theme-name" placeholder="Enter theme folder name on HubSpot" />
116
+ <button class="btn btn--primary" id="btn-fetch-theme">Download</button>
117
+ </div>
118
+ <p class="setup__hint hidden" id="dl-hint">Enter the exact folder name of your theme in the HubSpot Design Manager</p>
119
+ <div class="hidden" id="dl-no-account">
120
+ <p class="setup__hint">Connect a HubSpot account in <a href="#" id="dl-open-settings">Settings</a> to download themes.</p>
109
121
  </div>
110
- <p class="setup__hint">Convert a React/Lovable project to HubSpot</p>
111
122
  </div>
112
123
 
113
- <!-- Open folder -->
114
- <div class="setup__section">
115
- <p class="setup__label">Open from disk</p>
124
+ <div class="setup__panel hidden" id="panel-convert">
116
125
  <div class="setup__row">
117
- <input type="text" class="setup__input setup__input--wide" id="open-theme-path" placeholder="/path/to/my-theme" />
118
- <button class="btn btn--secondary" id="btn-open-theme">Open</button>
126
+ <input type="text" class="setup__input setup__input--wide" id="import-url" placeholder="https://github.com/user/repo or Lovable URL" />
127
+ <button class="btn btn--primary" id="import-btn">Import</button>
119
128
  </div>
129
+ <p class="setup__hint">Convert a React or Lovable project to a HubSpot theme</p>
120
130
  </div>
121
131
  </div>
122
132
 
@@ -361,9 +371,14 @@
361
371
  </div>
362
372
  </div>
363
373
  <div class="chat__input-area">
374
+ <div class="chat__file-chips" id="file-chips"></div>
364
375
  <div class="chat__input-wrapper">
365
376
  <textarea class="chat__input" id="chat-input" placeholder="Describe your landing page..." rows="1"></textarea>
366
377
  <div class="chat__input-actions">
378
+ <button class="chat__input-icon" id="btn-attach-file" title="Attach files (images, PDFs, docs)">
379
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>
380
+ </button>
381
+ <input type="file" id="file-input" multiple accept="image/png,image/jpeg,image/svg+xml,image/webp,image/gif,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/markdown,text/plain" style="display:none">
367
382
  <button class="chat__input-icon" id="btn-starter-templates" title="Templates">
368
383
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>
369
384
  </button>
@@ -372,6 +387,12 @@
372
387
  </button>
373
388
  </div>
374
389
  </div>
390
+ <div class="chat__drop-overlay hidden" id="drop-overlay">
391
+ <div class="chat__drop-overlay-content">
392
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
393
+ <span>Drop files here</span>
394
+ </div>
395
+ </div>
375
396
  </div>
376
397
  </div>
377
398
  </aside>
@@ -430,6 +451,12 @@
430
451
  <h2 class="settings__title">Settings</h2>
431
452
  <button class="settings__close" id="settings-close">&times;</button>
432
453
  </div>
454
+ <div class="settings__tabs" id="settings-tabs">
455
+ <button class="settings__tab active" data-tab="ai">AI</button>
456
+ <button class="settings__tab" data-tab="hubspot">HubSpot</button>
457
+ <button class="settings__tab" data-tab="github">GitHub</button>
458
+ <button class="settings__tab" data-tab="vibespot">vibeSpot</button>
459
+ </div>
433
460
  <div class="settings__body" id="settings-body">
434
461
  <!-- Populated dynamically by settings.js -->
435
462
  <div class="settings__loading">