vibespot 0.7.1 → 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/ui/settings.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Settings panel — environment detection, AI engine selection, API keys, tool install, auth flows.
2
+ * Settings panel — tabbed layout with AI, HubSpot, GitHub, vibeSpot tabs.
3
3
  */
4
4
 
5
5
  // ---------------------------------------------------------------------------
@@ -7,6 +7,7 @@
7
7
  // ---------------------------------------------------------------------------
8
8
 
9
9
  let settingsData = null;
10
+ let activeTab = "ai";
10
11
  const activePolls = {};
11
12
 
12
13
  const ENGINE_LABELS = {
@@ -23,7 +24,6 @@ const ENGINE_LABELS = {
23
24
  // ---------------------------------------------------------------------------
24
25
 
25
26
  function openSettings() {
26
- // Close menu if open
27
27
  if (typeof closeMenu === "function") closeMenu();
28
28
  document.getElementById("settings-overlay").classList.remove("hidden");
29
29
  refreshSettings();
@@ -37,6 +37,22 @@ function closeSettings() {
37
37
  });
38
38
  }
39
39
 
40
+ // ---------------------------------------------------------------------------
41
+ // Tab switching
42
+ // ---------------------------------------------------------------------------
43
+
44
+ function initTabs() {
45
+ const tabs = document.querySelectorAll("#settings-tabs .settings__tab");
46
+ tabs.forEach((tab) => {
47
+ tab.addEventListener("click", () => {
48
+ tabs.forEach((t) => t.classList.remove("active"));
49
+ tab.classList.add("active");
50
+ activeTab = tab.dataset.tab;
51
+ if (settingsData) renderSettings(settingsData);
52
+ });
53
+ });
54
+ }
55
+
40
56
  // ---------------------------------------------------------------------------
41
57
  // Fetch and render
42
58
  // ---------------------------------------------------------------------------
@@ -56,16 +72,29 @@ async function refreshSettings() {
56
72
 
57
73
  function renderSettings(data) {
58
74
  const body = document.getElementById("settings-body");
75
+ body.innerHTML = "";
76
+
77
+ switch (activeTab) {
78
+ case "ai": renderAITab(body, data); break;
79
+ case "hubspot": renderHubSpotTab(body, data); break;
80
+ case "github": renderGitHubTab(body, data); break;
81
+ case "vibespot": renderVibeSpotTab(body, data); break;
82
+ }
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // AI Tab
87
+ // ---------------------------------------------------------------------------
88
+
89
+ function renderAITab(body, data) {
59
90
  const env = data.environment;
60
91
  const config = data.config;
61
92
 
62
- body.innerHTML = "";
63
-
64
- // --- AI Engines section ---
65
- const aiSection = el("section", "settings__section");
66
- aiSection.appendChild(sectionTitle("AI Engine"));
93
+ // Engine selector
94
+ const section = el("section", "settings__section");
95
+ section.appendChild(sectionTitle("Engine"));
96
+ section.appendChild(desc("Choose which AI engine generates your HubSpot modules. API engines need an API key. CLI engines need the tool installed on your system."));
67
97
 
68
- // Engine selector pills
69
98
  const selectEl = el("div", "settings__engine-select");
70
99
  const allEngines = [
71
100
  { id: "claude-code", label: "Claude Code" },
@@ -82,15 +111,12 @@ function renderSettings(data) {
82
111
  btn.textContent = eng.label;
83
112
  btn.disabled = !available;
84
113
  if (config.aiEngine === eng.id) btn.classList.add("active");
85
- if (!config.aiEngine && available && env.availableEngines[0] === eng.id) {
86
- // Highlight first available if none selected
87
- }
88
114
  btn.addEventListener("click", () => setEngine(eng.id));
89
115
  selectEl.appendChild(btn);
90
116
  }
91
- aiSection.appendChild(selectEl);
117
+ section.appendChild(selectEl);
92
118
 
93
- // Model selector (for the active engine)
119
+ // Model selector
94
120
  const activeEngine = config.aiEngine || (env.availableEngines.length > 0 ? env.availableEngines[0] : null);
95
121
  if (activeEngine) {
96
122
  const modelRow = el("div", "settings__model-row");
@@ -110,7 +136,6 @@ function renderSettings(data) {
110
136
  modelSelect.appendChild(opt);
111
137
  }
112
138
 
113
- // Custom model option
114
139
  const customOpt = document.createElement("option");
115
140
  customOpt.value = "__custom__";
116
141
  customOpt.textContent = "Custom...";
@@ -130,24 +155,16 @@ function renderSettings(data) {
130
155
  });
131
156
 
132
157
  modelRow.appendChild(modelSelect);
133
- aiSection.appendChild(modelRow);
158
+ section.appendChild(modelRow);
134
159
  }
135
160
 
136
- // CLI tools subsection
137
- aiSection.appendChild(subsectionTitle("CLI Tools"));
138
- const cliTools = [
139
- { key: "claudeCode", name: "Claude Code", installId: "claude", url: "https://claude.ai/code" },
140
- { key: "geminiCli", name: "Gemini CLI", installId: "gemini", url: "https://github.com/google-gemini/gemini-cli" },
141
- { key: "codexCli", name: "Codex CLI", installId: "codex", url: "https://github.com/openai/codex" },
142
- ];
161
+ body.appendChild(section);
143
162
 
144
- for (const tool of cliTools) {
145
- const info = env.tools[tool.key];
146
- aiSection.appendChild(createToolCard(tool.name, info, tool.installId, tool.url));
147
- }
163
+ // API Keys section
164
+ const keysSection = el("section", "settings__section");
165
+ keysSection.appendChild(sectionTitle("API Keys"));
166
+ keysSection.appendChild(desc("API keys are stored locally in ~/.vibespot/config.json and never sent anywhere except the provider\u2019s API."));
148
167
 
149
- // API keys subsection
150
- aiSection.appendChild(subsectionTitle("API Keys"));
151
168
  const providers = [
152
169
  { key: "anthropic", name: "Anthropic", placeholder: "sk-ant-api03-..." },
153
170
  { key: "openai", name: "OpenAI", placeholder: "sk-..." },
@@ -156,253 +173,321 @@ function renderSettings(data) {
156
173
 
157
174
  for (const prov of providers) {
158
175
  const keyInfo = env.apiKeys[prov.key];
159
- aiSection.appendChild(createApiKeyCard(prov.key, prov.name, prov.placeholder, keyInfo));
176
+ keysSection.appendChild(createApiKeyCard(prov.key, prov.name, prov.placeholder, keyInfo));
160
177
  }
178
+ body.appendChild(keysSection);
161
179
 
162
- body.appendChild(aiSection);
163
-
164
- // --- HubSpot section ---
165
- const hsSection = el("section", "settings__section");
166
- hsSection.appendChild(sectionTitle("HubSpot"));
167
- hsSection.appendChild(createHubSpotCard(env.tools.hubspot));
168
- body.appendChild(hsSection);
169
-
170
- // --- GitHub section ---
171
- const ghSection = el("section", "settings__section");
172
- ghSection.appendChild(sectionTitle("GitHub"));
173
- ghSection.appendChild(createGitHubCard(env.tools.github));
174
- body.appendChild(ghSection);
175
- }
176
-
177
- // ---------------------------------------------------------------------------
178
- // Tool card
179
- // ---------------------------------------------------------------------------
180
-
181
- function createToolCard(name, info, installId, url) {
182
- const card = el("div", "settings__card");
180
+ // CLI Tools section with toggles
181
+ const cliSection = el("section", "settings__section");
182
+ cliSection.appendChild(sectionTitle("CLI Tools"));
183
+ cliSection.appendChild(desc("Enable CLI tools you have installed. Install status is only checked when you toggle a tool on, so disabled tools add zero overhead."));
183
184
 
184
- const row = el("div", "settings__card-row");
185
-
186
- if (info.found) {
187
- row.appendChild(dot(info.authenticated ? "success" : "warn"));
188
- } else {
189
- row.appendChild(dot("muted"));
190
- }
185
+ const cliTools = [
186
+ { key: "claudeCode", id: "claude-code", name: "Claude Code", installId: "claude", url: "https://claude.ai/code" },
187
+ { key: "geminiCli", id: "gemini-cli", name: "Gemini CLI", installId: "gemini", url: "https://github.com/google-gemini/gemini-cli" },
188
+ { key: "codexCli", id: "codex-cli", name: "Codex CLI", installId: "codex", url: "https://github.com/openai/codex" },
189
+ ];
191
190
 
192
- const label = el("span", "settings__card-label");
193
- label.textContent = name;
194
- row.appendChild(label);
191
+ const enabledTools = config.enabledCLITools || [];
195
192
 
196
- if (info.found) {
197
- const meta = el("span", "settings__card-meta");
198
- meta.textContent = `v${info.version}`;
199
- row.appendChild(meta);
200
- } else {
201
- const installBtn = el("button", "settings__btn");
202
- installBtn.textContent = "Install";
203
- installBtn.addEventListener("click", () => installTool(installId, installBtn));
204
- row.appendChild(installBtn);
205
-
206
- if (url) {
207
- const link = el("a", "settings__btn");
208
- link.textContent = "Docs";
209
- link.href = url;
210
- link.target = "_blank";
211
- link.style.textDecoration = "none";
212
- row.appendChild(link);
193
+ for (const tool of cliTools) {
194
+ const enabled = enabledTools.includes(tool.id);
195
+ const info = env.tools[tool.key];
196
+ const row = el("div", "settings__card");
197
+
198
+ const toggleRow = el("div", "settings__toggle-row");
199
+ const labelWrap = el("div", "");
200
+ const label = el("div", "settings__toggle-label");
201
+ label.textContent = tool.name;
202
+ labelWrap.appendChild(label);
203
+
204
+ if (enabled && info.found) {
205
+ const sub = el("div", "settings__toggle-label-sub");
206
+ sub.textContent = `v${info.version}` + (info.authenticated ? " \u2014 authenticated" : " \u2014 not authenticated");
207
+ sub.style.color = info.authenticated ? "var(--success)" : "var(--warning)";
208
+ labelWrap.appendChild(sub);
209
+ } else if (enabled && !info.found) {
210
+ const sub = el("div", "settings__toggle-label-sub");
211
+ sub.textContent = "Not installed";
212
+ sub.style.color = "var(--text-muted)";
213
+ labelWrap.appendChild(sub);
213
214
  }
214
- }
215
215
 
216
- card.appendChild(row);
216
+ toggleRow.appendChild(labelWrap);
217
217
 
218
- // Auth action row for installed but not authenticated CLI tools
219
- if (info.found && !info.authenticated) {
220
- const authRow = el("div", "settings__card-row settings__card-row--sub");
221
-
222
- {
223
- // Claude Code / Gemini CLI — browser-based sign in
224
- const authLabel = el("span", "settings__card-meta");
225
- authLabel.textContent = info.authDetail || "Not authenticated";
226
- authLabel.style.color = "var(--warning, #f59e0b)";
227
- authRow.appendChild(authLabel);
218
+ const toggle = el("button", "settings__toggle" + (enabled ? " active" : ""));
219
+ toggle.addEventListener("click", async () => {
220
+ const newVal = !enabled;
221
+ await fetch("/api/settings/cli-toggle", {
222
+ method: "POST",
223
+ headers: { "Content-Type": "application/json" },
224
+ body: JSON.stringify({ toolId: tool.id, enabled: newVal }),
225
+ });
226
+ refreshSettings();
227
+ });
228
+ toggleRow.appendChild(toggle);
229
+
230
+ row.appendChild(toggleRow);
231
+
232
+ // If enabled but not installed, show install button
233
+ if (enabled && !info.found) {
234
+ const installRow = el("div", "settings__card-row");
235
+ const installBtn = el("button", "settings__btn settings__btn--primary");
236
+ installBtn.textContent = "Install";
237
+ installBtn.addEventListener("click", () => installTool(tool.installId, installBtn));
238
+ installRow.appendChild(installBtn);
239
+ if (tool.url) {
240
+ const link = el("a", "settings__btn");
241
+ link.textContent = "Docs";
242
+ link.href = tool.url;
243
+ link.target = "_blank";
244
+ link.style.textDecoration = "none";
245
+ installRow.appendChild(link);
246
+ }
247
+ row.appendChild(installRow);
248
+ }
228
249
 
250
+ // If enabled, installed, but not authenticated — show sign in
251
+ if (enabled && info.found && !info.authenticated) {
252
+ const authRow = el("div", "settings__card-row");
229
253
  const authBtn = el("button", "settings__btn settings__btn--primary");
230
254
  authBtn.textContent = "Sign in";
231
- authBtn.addEventListener("click", () => authCLI(installId, authBtn));
255
+ authBtn.addEventListener("click", () => authCLI(tool.installId, authBtn));
232
256
  authRow.appendChild(authBtn);
233
-
234
- card.appendChild(authRow);
257
+ row.appendChild(authRow);
235
258
  }
236
- } else if (info.found && info.authenticated) {
237
- const authRow = el("div", "settings__card-row settings__card-row--sub");
238
- const authLabel = el("span", "settings__card-meta");
239
- authLabel.textContent = "Authenticated";
240
- authLabel.style.color = "var(--success, #22c55e)";
241
- authRow.appendChild(authLabel);
242
- card.appendChild(authRow);
243
- }
244
259
 
245
- return card;
260
+ cliSection.appendChild(row);
261
+ }
262
+ body.appendChild(cliSection);
246
263
  }
247
264
 
248
265
  // ---------------------------------------------------------------------------
249
- // API key card
266
+ // HubSpot Tab
250
267
  // ---------------------------------------------------------------------------
251
268
 
252
- function createApiKeyCard(provider, name, placeholder, keyInfo) {
253
- const card = el("div", "settings__apikey-row");
269
+ function renderHubSpotTab(body, data) {
270
+ const env = data.environment;
271
+ const config = data.config;
272
+ const hs = env.tools.hubspot;
254
273
 
255
- const label = el("span", "settings__apikey-label");
256
- label.textContent = name;
257
- card.appendChild(label);
274
+ // Upload mode toggle
275
+ const modeSection = el("section", "settings__section");
276
+ modeSection.appendChild(sectionTitle("Upload Mode"));
258
277
 
259
- if (keyInfo.configured) {
260
- // Show masked key + edit/clear buttons
261
- const value = el("span", "settings__apikey-value");
262
- value.textContent = keyInfo.masked;
263
- card.appendChild(value);
278
+ const currentMode = config.hubspotUploadMode || "api";
279
+ const pills = el("div", "settings__mode-pills");
264
280
 
265
- const actions = el("div", "settings__apikey-actions");
281
+ const apiPill = el("button", "settings__mode-pill" + (currentMode === "api" ? " active" : ""));
282
+ apiPill.textContent = "API (recommended)";
283
+ apiPill.addEventListener("click", () => setHsMode("api"));
284
+ pills.appendChild(apiPill);
266
285
 
267
- const editBtn = el("button", "settings__btn");
268
- editBtn.textContent = "Edit";
269
- editBtn.addEventListener("click", () => {
270
- // Replace card content with input
271
- showApiKeyInput(card, provider, name, placeholder);
272
- });
273
- actions.appendChild(editBtn);
286
+ const cliPill = el("button", "settings__mode-pill" + (currentMode === "cli" ? " active" : ""));
287
+ cliPill.textContent = "CLI (legacy)";
288
+ cliPill.addEventListener("click", () => setHsMode("cli"));
289
+ pills.appendChild(cliPill);
274
290
 
275
- const clearBtn = el("button", "settings__btn");
276
- clearBtn.textContent = "Clear";
277
- clearBtn.addEventListener("click", async () => {
278
- await fetch("/api/settings/apikey", {
279
- method: "POST",
280
- headers: { "Content-Type": "application/json" },
281
- body: JSON.stringify({ provider, apiKey: null }),
282
- });
283
- refreshSettings();
284
- });
285
- actions.appendChild(clearBtn);
291
+ modeSection.appendChild(pills);
286
292
 
287
- card.appendChild(actions);
293
+ if (currentMode === "api") {
294
+ modeSection.appendChild(desc("Uploads directly to HubSpot via API. No CLI installation needed. Supports parallel file uploads for faster deployments."));
288
295
  } else {
289
- showApiKeyInput(card, provider, name, placeholder);
296
+ modeSection.appendChild(desc("Uses the HubSpot CLI (hs command). Requires @hubspot/cli installed globally. Slower sequential uploads."));
290
297
  }
291
298
 
292
- return card;
293
- }
299
+ body.appendChild(modeSection);
294
300
 
295
- function showApiKeyInput(container, provider, name, placeholder) {
296
- // Clear existing content except label
297
- const label = container.querySelector(".settings__apikey-label");
298
- container.innerHTML = "";
299
- if (label) container.appendChild(label);
300
- else {
301
- const lbl = el("span", "settings__apikey-label");
302
- lbl.textContent = name;
303
- container.appendChild(lbl);
304
- }
301
+ // Accounts section
302
+ const acctSection = el("section", "settings__section");
303
+ acctSection.appendChild(sectionTitle("Accounts"));
305
304
 
306
- const input = el("input", "settings__apikey-input");
307
- input.type = "password";
308
- input.placeholder = placeholder;
309
- container.appendChild(input);
305
+ if (currentMode === "api") {
306
+ acctSection.appendChild(desc("Connect HubSpot accounts with a Personal Access Key. Keys are stored locally and used to authenticate API requests."));
310
307
 
311
- const saveBtn = el("button", "settings__btn settings__btn--primary");
312
- saveBtn.textContent = "Save";
313
- saveBtn.addEventListener("click", () => saveApiKey(provider, input.value, saveBtn));
314
- container.appendChild(saveBtn);
315
-
316
- input.addEventListener("keydown", (e) => {
317
- if (e.key === "Enter") { e.preventDefault(); saveBtn.click(); }
318
- });
319
-
320
- input.focus();
321
- }
322
-
323
- // ---------------------------------------------------------------------------
324
- // HubSpot card
325
- // ---------------------------------------------------------------------------
326
-
327
- function createHubSpotCard(hs) {
328
- const card = el("div", "settings__card");
308
+ const accounts = config.hubspotAccounts || [];
309
+ if (accounts.length > 0) {
310
+ for (const acct of accounts) {
311
+ const isActive = acct.portalId === config.activeHubSpotAccount;
312
+ const card = el("div", "settings__card");
313
+ const row = el("div", "settings__card-row");
314
+ row.appendChild(dot(isActive ? "success" : "muted"));
315
+ const label = el("span", "settings__card-label");
316
+ label.textContent = `${acct.portalName} (${acct.portalId})`;
317
+ if (isActive) label.textContent += " \u2014 active";
318
+ row.appendChild(label);
329
319
 
330
- // CLI status
331
- const cliRow = el("div", "settings__card-row");
332
- cliRow.appendChild(dot(hs.found ? "success" : "warn"));
333
- const cliLabel = el("span", "settings__card-label");
334
- cliLabel.textContent = "HubSpot CLI";
335
- cliRow.appendChild(cliLabel);
320
+ const actions = el("span", "settings__card-actions");
321
+ if (!isActive) {
322
+ const useBtn = el("button", "settings__btn settings__btn--small");
323
+ useBtn.textContent = "Use";
324
+ useBtn.addEventListener("click", () => switchHsAccount(acct.portalId, useBtn));
325
+ actions.appendChild(useBtn);
326
+ }
327
+ const removeBtn = el("button", "settings__btn settings__btn--small settings__btn--danger");
328
+ removeBtn.textContent = "Remove";
329
+ removeBtn.addEventListener("click", () => removeHsAccount(acct.portalId, removeBtn));
330
+ actions.appendChild(removeBtn);
331
+ row.appendChild(actions);
332
+ card.appendChild(row);
333
+ acctSection.appendChild(card);
334
+ }
335
+ } else {
336
+ const empty = el("div", "settings__card");
337
+ const emptyRow = el("div", "settings__card-row");
338
+ emptyRow.appendChild(dot("muted"));
339
+ const emptyLabel = el("span", "settings__card-label");
340
+ emptyLabel.textContent = "No accounts connected";
341
+ emptyRow.appendChild(emptyLabel);
342
+ empty.appendChild(emptyRow);
343
+ acctSection.appendChild(empty);
344
+ }
336
345
 
337
- if (hs.found) {
338
- const meta = el("span", "settings__card-meta");
339
- meta.textContent = `v${hs.version}`;
340
- cliRow.appendChild(meta);
346
+ // Add account button + PAK input
347
+ const addCard = el("div", "settings__card");
348
+ const addRow = el("div", "settings__card-row");
349
+ const addBtn = el("button", "settings__btn settings__btn--primary");
350
+ addBtn.textContent = "Add Account";
351
+ addBtn.addEventListener("click", () => showPakInput(addCard, addBtn));
352
+ addRow.appendChild(addBtn);
353
+ addCard.appendChild(addRow);
354
+ acctSection.appendChild(addCard);
341
355
  } else {
342
- const installBtn = el("button", "settings__btn");
343
- installBtn.textContent = "Install";
344
- installBtn.addEventListener("click", () => installTool("hubspot", installBtn));
345
- cliRow.appendChild(installBtn);
346
- }
347
- card.appendChild(cliRow);
348
-
349
- // Accounts list (if CLI is installed)
350
- if (hs.found) {
351
- const accounts = hs.accounts || [];
356
+ // CLI mode show CLI status and accounts from hs accounts list
357
+ acctSection.appendChild(desc("HubSpot CLI accounts are managed by the hs command. Use \u201chs auth\u201d to add accounts."));
358
+
359
+ const cliCard = el("div", "settings__card");
360
+ const cliRow = el("div", "settings__card-row");
361
+ cliRow.appendChild(dot(hs.found ? "success" : "warn"));
362
+ const cliLabel = el("span", "settings__card-label");
363
+ cliLabel.textContent = "HubSpot CLI";
364
+ cliRow.appendChild(cliLabel);
365
+
366
+ if (hs.found) {
367
+ const meta = el("span", "settings__card-meta");
368
+ meta.textContent = `v${hs.version}`;
369
+ cliRow.appendChild(meta);
370
+ } else {
371
+ const installBtn = el("button", "settings__btn");
372
+ installBtn.textContent = "Install";
373
+ installBtn.addEventListener("click", () => installTool("hubspot", installBtn));
374
+ cliRow.appendChild(installBtn);
375
+ }
376
+ cliCard.appendChild(cliRow);
377
+ acctSection.appendChild(cliCard);
352
378
 
353
- if (accounts.length > 0) {
354
- // Show each account with switch/remove actions
355
- accounts.forEach((acct) => {
379
+ if (hs.found) {
380
+ const accounts = hs.accounts || [];
381
+ for (const acct of accounts) {
382
+ const card = el("div", "settings__card");
356
383
  const row = el("div", "settings__card-row");
357
384
  row.appendChild(dot(acct.isDefault ? "success" : "muted"));
358
385
  const label = el("span", "settings__card-label");
359
386
  label.textContent = `${acct.name} (${acct.portalId})`;
360
- if (acct.isDefault) label.textContent += " active";
387
+ if (acct.isDefault) label.textContent += " \u2014 active";
361
388
  row.appendChild(label);
362
389
 
363
390
  const actions = el("span", "settings__card-actions");
364
-
365
391
  if (!acct.isDefault) {
366
392
  const useBtn = el("button", "settings__btn settings__btn--small");
367
393
  useBtn.textContent = "Use";
368
394
  useBtn.addEventListener("click", () => switchHsAccount(acct.portalId, useBtn));
369
395
  actions.appendChild(useBtn);
370
396
  }
371
-
372
397
  const removeBtn = el("button", "settings__btn settings__btn--small settings__btn--danger");
373
398
  removeBtn.textContent = "Remove";
374
399
  removeBtn.addEventListener("click", () => removeHsAccount(acct.portalId, removeBtn));
375
400
  actions.appendChild(removeBtn);
376
-
377
401
  row.appendChild(actions);
378
402
  card.appendChild(row);
379
- });
380
- } else if (!hs.authenticated) {
381
- const authRow = el("div", "settings__card-row");
382
- authRow.appendChild(dot("warn"));
383
- const authLabel = el("span", "settings__card-label");
384
- authLabel.textContent = "Not authenticated";
385
- authRow.appendChild(authLabel);
386
- card.appendChild(authRow);
387
- }
403
+ acctSection.appendChild(card);
404
+ }
388
405
 
389
- // "Add account" button (always available when CLI is installed)
390
- const addRow = el("div", "settings__card-row");
391
- const addBtn = el("button", "settings__btn settings__btn--primary");
392
- addBtn.textContent = "Add Account";
393
- addBtn.addEventListener("click", () => startHsAuth(addBtn, card));
394
- addRow.appendChild(addBtn);
395
- card.appendChild(addRow);
406
+ const addRow2 = el("div", "settings__card");
407
+ const addBtnRow = el("div", "settings__card-row");
408
+ const addBtn2 = el("button", "settings__btn settings__btn--primary");
409
+ addBtn2.textContent = "Add Account";
410
+ addBtn2.addEventListener("click", () => startHsAuth(addBtn2, addRow2));
411
+ addBtnRow.appendChild(addBtn2);
412
+ addRow2.appendChild(addBtnRow);
413
+ acctSection.appendChild(addRow2);
414
+ }
396
415
  }
397
416
 
398
- return card;
417
+ body.appendChild(acctSection);
418
+ }
419
+
420
+ function showPakInput(container, addBtn) {
421
+ // Hide the add button, show PAK input
422
+ addBtn.style.display = "none";
423
+
424
+ const instructions = el("div", "settings__instructions");
425
+ instructions.innerHTML = `
426
+ <strong>Connect your HubSpot account:</strong>
427
+ <ol>
428
+ <li>Open <a href="https://app.hubspot.com/l/personal-access-key" target="_blank" rel="noopener">HubSpot Personal Access Key</a></li>
429
+ <li>Create a key with the <strong>Content</strong> scope enabled</li>
430
+ <li>Copy the key and paste it below</li>
431
+ </ol>
432
+ <div class="settings__pak-row">
433
+ <input type="password" class="settings__apikey-input" placeholder="Personal access key (pat-na1-...)" />
434
+ <button class="settings__btn settings__btn--primary">Connect</button>
435
+ </div>
436
+ `;
437
+ container.appendChild(instructions);
438
+
439
+ const input = instructions.querySelector("input");
440
+ const saveBtn = instructions.querySelector("button");
441
+ input.focus();
442
+
443
+ const doSave = async () => {
444
+ const key = input.value.trim();
445
+ if (!key) return;
446
+ saveBtn.disabled = true;
447
+ saveBtn.innerHTML = '<span class="settings__spinner"></span> Validating...';
448
+
449
+ try {
450
+ const res = await fetch("/api/settings/hs-auth", {
451
+ method: "POST",
452
+ headers: { "Content-Type": "application/json" },
453
+ body: JSON.stringify({ personalAccessKey: key }),
454
+ });
455
+ const data = await res.json();
456
+ if (data.ok) {
457
+ refreshSettings();
458
+ } else if (data.jobId) {
459
+ pollJob(data.jobId, () => refreshSettings(), () => {
460
+ saveBtn.textContent = "Failed";
461
+ saveBtn.disabled = false;
462
+ });
463
+ } else {
464
+ saveBtn.textContent = data.error || "Failed";
465
+ saveBtn.disabled = false;
466
+ setTimeout(() => { saveBtn.textContent = "Connect"; }, 2000);
467
+ }
468
+ } catch {
469
+ saveBtn.textContent = "Failed";
470
+ setTimeout(() => { saveBtn.textContent = "Connect"; saveBtn.disabled = false; }, 2000);
471
+ }
472
+ };
473
+
474
+ saveBtn.addEventListener("click", doSave);
475
+ input.addEventListener("keydown", (e) => {
476
+ if (e.key === "Enter") { e.preventDefault(); doSave(); }
477
+ });
399
478
  }
400
479
 
401
480
  // ---------------------------------------------------------------------------
402
- // GitHub card
481
+ // GitHub Tab
403
482
  // ---------------------------------------------------------------------------
404
483
 
405
- function createGitHubCard(gh) {
484
+ function renderGitHubTab(body, data) {
485
+ const gh = data.environment.tools.github;
486
+
487
+ const section = el("section", "settings__section");
488
+ section.appendChild(sectionTitle("GitHub CLI"));
489
+ section.appendChild(desc("GitHub CLI enables pushing your theme to a repository. Optional \u2014 not needed for HubSpot deployment."));
490
+
406
491
  const card = el("div", "settings__card");
407
492
 
408
493
  // CLI status
@@ -435,9 +520,7 @@ function createGitHubCard(gh) {
435
520
  authRow.appendChild(authLabel);
436
521
 
437
522
  const actions = el("span", "settings__card-actions");
438
-
439
523
  if (gh.authenticated) {
440
- // Switch account = logout + login
441
524
  const switchBtn = el("button", "settings__btn settings__btn--small");
442
525
  switchBtn.textContent = "Switch";
443
526
  switchBtn.addEventListener("click", () => switchGhAccount(switchBtn));
@@ -458,7 +541,69 @@ function createGitHubCard(gh) {
458
541
  card.appendChild(authRow);
459
542
  }
460
543
 
461
- return card;
544
+ section.appendChild(card);
545
+ body.appendChild(section);
546
+ }
547
+
548
+ // ---------------------------------------------------------------------------
549
+ // vibeSpot Tab
550
+ // ---------------------------------------------------------------------------
551
+
552
+ function renderVibeSpotTab(body, data) {
553
+ const section = el("section", "settings__section");
554
+ section.appendChild(sectionTitle("About"));
555
+ section.appendChild(desc("General vibeSpot configuration and information."));
556
+
557
+ const card = el("div", "settings__card");
558
+
559
+ // Version
560
+ const versionRow = el("div", "settings__card-row");
561
+ const versionLabel = el("span", "settings__card-label");
562
+ versionLabel.textContent = "Version";
563
+ versionRow.appendChild(versionLabel);
564
+ const versionVal = el("span", "settings__card-meta");
565
+ versionVal.textContent = data.version || "dev";
566
+ versionRow.appendChild(versionVal);
567
+ card.appendChild(versionRow);
568
+
569
+ // Workspace
570
+ const wsRow = el("div", "settings__card-row");
571
+ const wsLabel = el("span", "settings__card-label");
572
+ wsLabel.textContent = "Workspace";
573
+ wsRow.appendChild(wsLabel);
574
+ const wsVal = el("span", "settings__card-meta");
575
+ wsVal.textContent = "~/vibespot-themes";
576
+ wsVal.style.fontFamily = "var(--font-mono, monospace)";
577
+ wsVal.style.fontSize = "11px";
578
+ wsRow.appendChild(wsVal);
579
+ card.appendChild(wsRow);
580
+
581
+ // Session count
582
+ if (data.sessionCount !== undefined) {
583
+ const sessRow = el("div", "settings__card-row");
584
+ const sessLabel = el("span", "settings__card-label");
585
+ sessLabel.textContent = "Saved sessions";
586
+ sessRow.appendChild(sessLabel);
587
+ const sessVal = el("span", "settings__card-meta");
588
+ sessVal.textContent = String(data.sessionCount);
589
+ sessRow.appendChild(sessVal);
590
+ card.appendChild(sessRow);
591
+ }
592
+
593
+ // Local themes count
594
+ if (data.localThemeCount !== undefined) {
595
+ const themeRow = el("div", "settings__card-row");
596
+ const themeLabel = el("span", "settings__card-label");
597
+ themeLabel.textContent = "Local themes";
598
+ themeRow.appendChild(themeLabel);
599
+ const themeVal = el("span", "settings__card-meta");
600
+ themeVal.textContent = String(data.localThemeCount);
601
+ themeRow.appendChild(themeVal);
602
+ card.appendChild(themeRow);
603
+ }
604
+
605
+ section.appendChild(card);
606
+ body.appendChild(section);
462
607
  }
463
608
 
464
609
  // ---------------------------------------------------------------------------
@@ -472,7 +617,6 @@ async function setEngine(engineId) {
472
617
  body: JSON.stringify({ engine: engineId }),
473
618
  });
474
619
 
475
- // Update statusbar engine label
476
620
  const statusEngine = document.getElementById("status-engine");
477
621
  if (statusEngine) {
478
622
  statusEngine.textContent = ENGINE_LABELS[engineId] || engineId;
@@ -481,6 +625,15 @@ async function setEngine(engineId) {
481
625
  refreshSettings();
482
626
  }
483
627
 
628
+ async function setHsMode(mode) {
629
+ await fetch("/api/settings/hs-mode", {
630
+ method: "POST",
631
+ headers: { "Content-Type": "application/json" },
632
+ body: JSON.stringify({ mode }),
633
+ });
634
+ refreshSettings();
635
+ }
636
+
484
637
  async function saveApiKey(provider, key, btn) {
485
638
  if (!key || !key.trim()) return;
486
639
  btn.disabled = true;
@@ -518,9 +671,7 @@ async function installTool(toolId, btn) {
518
671
  const data = await res.json();
519
672
 
520
673
  if (data.jobId) {
521
- pollJob(data.jobId, () => {
522
- refreshSettings();
523
- }, (err) => {
674
+ pollJob(data.jobId, () => refreshSettings(), () => {
524
675
  btn.textContent = "Failed";
525
676
  btn.disabled = false;
526
677
  });
@@ -611,7 +762,6 @@ async function switchGhAccount(btn) {
611
762
  btn.innerHTML = '<span class="settings__spinner"></span>';
612
763
 
613
764
  try {
614
- // Logout first, then trigger login
615
765
  const res = await fetch("/api/settings/gh-logout", {
616
766
  method: "POST",
617
767
  headers: { "Content-Type": "application/json" },
@@ -620,7 +770,6 @@ async function switchGhAccount(btn) {
620
770
  const data = await res.json();
621
771
  if (data.jobId) {
622
772
  pollJob(data.jobId, () => {
623
- // After logout completes, start login flow
624
773
  startGhAuth(btn);
625
774
  }, () => {
626
775
  btn.textContent = "Failed";
@@ -646,7 +795,6 @@ async function startHsAuth(btn, card) {
646
795
  const data = await res.json();
647
796
 
648
797
  if (data.needsKey) {
649
- // Show instructions to get a personal access key
650
798
  btn.textContent = "Connect";
651
799
  btn.disabled = false;
652
800
 
@@ -764,18 +912,15 @@ function pollJob(jobId, onComplete, onError) {
764
912
  // CLI auth
765
913
  // ---------------------------------------------------------------------------
766
914
 
767
- async function authCLI(cli, btn, apiKey) {
915
+ async function authCLI(cli, btn) {
768
916
  btn.disabled = true;
769
917
  btn.innerHTML = '<span class="settings__spinner"></span>';
770
918
 
771
919
  try {
772
- const payload = { cli };
773
- if (apiKey) payload.apiKey = apiKey;
774
-
775
920
  const res = await fetch("/api/settings/cli-auth", {
776
921
  method: "POST",
777
922
  headers: { "Content-Type": "application/json" },
778
- body: JSON.stringify(payload),
923
+ body: JSON.stringify({ cli }),
779
924
  });
780
925
  const data = await res.json();
781
926
 
@@ -786,17 +931,15 @@ async function authCLI(cli, btn, apiKey) {
786
931
  }
787
932
 
788
933
  if (data.hint) {
789
- // Show hint to user (e.g., check browser)
790
934
  btn.innerHTML = '<span class="settings__spinner"></span> Check browser...';
791
935
  }
792
936
 
793
937
  if (data.jobId) {
794
938
  pollJob(data.jobId, () => refreshSettings(), () => {
795
- btn.textContent = "Failed try again";
939
+ btn.textContent = "Failed \u2014 try again";
796
940
  btn.disabled = false;
797
941
  });
798
942
  } else {
799
- // Immediate success (e.g., Codex API key saved)
800
943
  refreshSettings();
801
944
  }
802
945
  } catch {
@@ -805,16 +948,86 @@ async function authCLI(cli, btn, apiKey) {
805
948
  }
806
949
  }
807
950
 
951
+ // ---------------------------------------------------------------------------
952
+ // API key card
953
+ // ---------------------------------------------------------------------------
954
+
955
+ function createApiKeyCard(provider, name, placeholder, keyInfo) {
956
+ const card = el("div", "settings__apikey-row");
957
+
958
+ const label = el("span", "settings__apikey-label");
959
+ label.textContent = name;
960
+ card.appendChild(label);
961
+
962
+ if (keyInfo.configured) {
963
+ const value = el("span", "settings__apikey-value");
964
+ value.textContent = keyInfo.masked;
965
+ card.appendChild(value);
966
+
967
+ const actions = el("div", "settings__apikey-actions");
968
+
969
+ const editBtn = el("button", "settings__btn");
970
+ editBtn.textContent = "Edit";
971
+ editBtn.addEventListener("click", () => {
972
+ showApiKeyInput(card, provider, name, placeholder);
973
+ });
974
+ actions.appendChild(editBtn);
975
+
976
+ const clearBtn = el("button", "settings__btn");
977
+ clearBtn.textContent = "Clear";
978
+ clearBtn.addEventListener("click", async () => {
979
+ await fetch("/api/settings/apikey", {
980
+ method: "POST",
981
+ headers: { "Content-Type": "application/json" },
982
+ body: JSON.stringify({ provider, apiKey: null }),
983
+ });
984
+ refreshSettings();
985
+ });
986
+ actions.appendChild(clearBtn);
987
+
988
+ card.appendChild(actions);
989
+ } else {
990
+ showApiKeyInput(card, provider, name, placeholder);
991
+ }
992
+
993
+ return card;
994
+ }
995
+
996
+ function showApiKeyInput(container, provider, name, placeholder) {
997
+ const label = container.querySelector(".settings__apikey-label");
998
+ container.innerHTML = "";
999
+ if (label) container.appendChild(label);
1000
+ else {
1001
+ const lbl = el("span", "settings__apikey-label");
1002
+ lbl.textContent = name;
1003
+ container.appendChild(lbl);
1004
+ }
1005
+
1006
+ const input = el("input", "settings__apikey-input");
1007
+ input.type = "password";
1008
+ input.placeholder = placeholder;
1009
+ container.appendChild(input);
1010
+
1011
+ const saveBtn = el("button", "settings__btn settings__btn--primary");
1012
+ saveBtn.textContent = "Save";
1013
+ saveBtn.addEventListener("click", () => saveApiKey(provider, input.value, saveBtn));
1014
+ container.appendChild(saveBtn);
1015
+
1016
+ input.addEventListener("keydown", (e) => {
1017
+ if (e.key === "Enter") { e.preventDefault(); saveBtn.click(); }
1018
+ });
1019
+
1020
+ input.focus();
1021
+ }
1022
+
808
1023
  // ---------------------------------------------------------------------------
809
1024
  // Model selection helpers
810
1025
  // ---------------------------------------------------------------------------
811
1026
 
812
1027
  function getModelsForEngine(engine) {
813
- // Use server-provided model catalog if available
814
1028
  if (settingsData && settingsData.models && settingsData.models[engine]) {
815
1029
  return settingsData.models[engine];
816
1030
  }
817
- // Fallback to hardcoded defaults
818
1031
  switch (engine) {
819
1032
  case "claude-code":
820
1033
  return [
@@ -869,7 +1082,6 @@ async function setEngineModel(engine, model) {
869
1082
  body: JSON.stringify({ engine, model }),
870
1083
  });
871
1084
 
872
- // Update statusbar
873
1085
  const statusEngine = document.getElementById("status-engine");
874
1086
  if (statusEngine) {
875
1087
  const label = ENGINE_LABELS[engine] || engine;
@@ -890,8 +1102,7 @@ function el(tag, className) {
890
1102
  }
891
1103
 
892
1104
  function dot(variant) {
893
- const d = el("span", `settings__dot settings__dot--${variant}`);
894
- return d;
1105
+ return el("span", `settings__dot settings__dot--${variant}`);
895
1106
  }
896
1107
 
897
1108
  function sectionTitle(text) {
@@ -906,6 +1117,12 @@ function subsectionTitle(text) {
906
1117
  return h;
907
1118
  }
908
1119
 
1120
+ function desc(text) {
1121
+ const p = el("p", "settings__description");
1122
+ p.textContent = text;
1123
+ return p;
1124
+ }
1125
+
909
1126
  function escSettings(str) {
910
1127
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
911
1128
  }
@@ -919,12 +1136,13 @@ document.getElementById("settings-overlay").addEventListener("click", (e) => {
919
1136
  if (e.target.id === "settings-overlay") closeSettings();
920
1137
  });
921
1138
 
922
- // Setup screen settings button
923
1139
  document.getElementById("btn-setup-settings").addEventListener("click", openSettings);
924
1140
 
925
- // Escape key
926
1141
  document.addEventListener("keydown", (e) => {
927
1142
  if (e.key === "Escape" && !document.getElementById("settings-overlay").classList.contains("hidden")) {
928
1143
  closeSettings();
929
1144
  }
930
1145
  });
1146
+
1147
+ // Initialize tabs
1148
+ initTabs();