privateboard 0.1.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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +120 -0
  3. package/dist/cli.js +10502 -0
  4. package/dist/cli.js.map +1 -0
  5. package/package.json +63 -0
  6. package/public/adjourn-overlay.css +253 -0
  7. package/public/agent-overlay.css +444 -0
  8. package/public/agent-overlay.js +604 -0
  9. package/public/agent-profile.css +3230 -0
  10. package/public/agent-profile.js +3329 -0
  11. package/public/app.js +6629 -0
  12. package/public/auto-hide-scroll.js +90 -0
  13. package/public/avatar-skill.js +793 -0
  14. package/public/avatars/chair.svg +98 -0
  15. package/public/avatars/first-principles.svg +122 -0
  16. package/public/avatars/long-horizon.svg +147 -0
  17. package/public/avatars/open_ai.png +0 -0
  18. package/public/avatars/phenomenologist.svg +130 -0
  19. package/public/avatars/socrates.svg +187 -0
  20. package/public/avatars/user-empathy.svg +117 -0
  21. package/public/avatars/value-investor.svg +117 -0
  22. package/public/favicon.svg +10 -0
  23. package/public/fonts/agent-Italic.woff2 +0 -0
  24. package/public/fonts/human-sans.woff2 +0 -0
  25. package/public/icons.css +103 -0
  26. package/public/models-cache.js +57 -0
  27. package/public/new-agent.css +1359 -0
  28. package/public/new-agent.js +675 -0
  29. package/public/onboarding.css +628 -0
  30. package/public/onboarding.js +782 -0
  31. package/public/prototype-dashboard.html +7596 -0
  32. package/public/report/spines/a16z-thesis.css +1055 -0
  33. package/public/report/spines/anthropic-essay.css +556 -0
  34. package/public/report/spines/boardroom-dark.css +1082 -0
  35. package/public/report/spines/gartner-note.css +538 -0
  36. package/public/report/spines/mckinsey-deck.css +523 -0
  37. package/public/report/spines/openai-paper.css +516 -0
  38. package/public/report.html +1417 -0
  39. package/public/room-settings.css +895 -0
  40. package/public/room-settings.js +1039 -0
  41. package/public/themes.css +338 -0
  42. package/public/user-settings.css +1236 -0
  43. package/public/user-settings.js +1291 -0
@@ -0,0 +1,782 @@
1
+ /* ═══════════════════════════════════════════
2
+ ONBOARDING · first-run wizard
3
+ ═══════════════════════════════════════════
4
+ Boots before app.init: detects first-run state, blocks the dashboard
5
+ until the user has a name + a configured key. After completion, marks
6
+ localStorage["boardroom.onboarded"] so subsequent boots skip.
7
+
8
+ Steps:
9
+ 0 welcome / name
10
+ 1 theme
11
+ 2 api key (OpenRouter, the simplest path)
12
+ 3 done · choose to seed a demo room or convene fresh
13
+
14
+ Each step persists immediately (PUT /api/prefs · PUT /api/keys/openrouter).
15
+ */
16
+ (function () {
17
+ const ONBOARDED_KEY = "boardroom.onboarded";
18
+
19
+ const THEMES = [
20
+ { slug: "regent", name: "Regent", desc: "warm gold on dark · default",
21
+ swatches: ["#0A0A0A","#131312","#C9A46B","#9A7B40","#A57843","#B5706A","#6A9B97","#C8C5BE"] },
22
+ { slug: "eastwood", name: "Eastwood", desc: "calm forest green",
23
+ swatches: ["#0A0A0A","#131312","#6FB572","#427A48","#B59560","#B5706A","#6A9B97","#C8C5BE"] },
24
+ { slug: "atrium", name: "Atrium", desc: "warm paper · daylight",
25
+ swatches: ["#FBFBF7","#F4F2EC","#2E7D32","#1B5E20","#A86C2A","#A8403D","#2E7D7A","#1F1E1A"] },
26
+ { slug: "pinterest", name: "Pinterest", desc: "clean white · red accent",
27
+ swatches: ["#FFFFFF","#FAFAFA","#E60023","#AD081B","#F4A100","#E60023","#2E7D7A","#111111"] },
28
+ { slug: "apple", name: "Apple", desc: "pure white · system blue",
29
+ swatches: ["#FFFFFF","#F5F5F7","#0071E3","#0051A8","#FF9500","#FF3B30","#5AC8FA","#1D1D1F"] },
30
+ { slug: "alanpeabody", name: "Alan Peabody", desc: "cool blue · git-green accents",
31
+ swatches: ["#0E1419","#131A21","#6BAFE0","#3F7AAA","#C8A463","#D67373","#6FB5A8","#C8D0DA"] },
32
+ { slug: "amuse", name: "Amuse", desc: "magenta + cyan · playful",
33
+ swatches: ["#1A0E14","#21121A","#D67BC0","#9C4884","#DCBE5D","#E07F84","#6FBFC2","#DECBD2"] },
34
+ { slug: "jtriley", name: "JTriley", desc: "bright lime + yellow",
35
+ swatches: ["#0A0F0A","#131914","#B5DA40","#6E8E27","#F0CC4E","#D67762","#6FBE9A","#C8D6BE"] },
36
+ { slug: "nebirhos", name: "Nebirhos", desc: "teal · warm orange",
37
+ swatches: ["#0A1414","#11201F","#5EB1A6","#357770","#DD9258","#D87060","#6FBEC2","#B8D4D0"] },
38
+ { slug: "wedisagree", name: "We Disagree", desc: "argumentative orange",
39
+ swatches: ["#14110E","#1F1A14","#DD7B40","#A8521E","#E6B872","#E26060","#6FB28A","#D8CBBC"] }
40
+ ];
41
+
42
+ const DEMO_AGENTS = ["socrates", "first-principles", "value-investor"];
43
+ const NAMES = {
44
+ "socrates": "Socrates",
45
+ "first-principles": "First Principles",
46
+ "value-investor": "Value Investor",
47
+ "user-empathy": "User-Empathy",
48
+ "long-horizon": "Long Horizon",
49
+ "phenomenologist": "Phenomenologist",
50
+ };
51
+ // Recommended seed questions for first-run users. Concrete, current
52
+ // 2026-shaped scenarios — the kind of decision a builder/operator
53
+ // actually loses sleep over — so the boardroom's value (three lenses
54
+ // pressuring one decision) lands in the first turn. Each cast is
55
+ // hand-picked so the lenses fit the question.
56
+ const STARTER_QUESTIONS = [
57
+ {
58
+ tag: "// ai-startup",
59
+ text: "OpenAI and Anthropic keep launching everything — does my AI startup still have a real shot in 2026?",
60
+ hint: "skeptic + pattern hunter + causal reasoner stress-test the moat thesis",
61
+ tone: "debate",
62
+ intensity: "sharp",
63
+ briefStyle: "auto",
64
+ agents: ["socrates", "value-investor", "first-principles"],
65
+ },
66
+ {
67
+ tag: "// quit-tech",
68
+ text: "$300K saved, senior eng job at a Big Tech — quit now to build, or wait two more years?",
69
+ hint: "long-horizon vs. value-investor on a real fork in your career",
70
+ tone: "constructive",
71
+ intensity: "calm",
72
+ briefStyle: "auto",
73
+ agents: ["long-horizon", "value-investor", "socrates"],
74
+ },
75
+ {
76
+ tag: "// pricing",
77
+ text: "$49/mo B2B SaaS with sticky enterprise users — are we leaving 5–10x on the table by not raising to $499?",
78
+ hint: "is your unit economics actually consumer-priced for an enterprise problem?",
79
+ tone: "debate",
80
+ intensity: "sharp",
81
+ briefStyle: "auto",
82
+ agents: ["value-investor", "user-empathy", "socrates"],
83
+ },
84
+ {
85
+ tag: "// pivot",
86
+ text: "Six months of runway, real users but flat MRR — pivot the product, hold the line, or shut it down?",
87
+ hint: "no-mercy room: force the load-bearing claim into the open",
88
+ tone: "no-mercy",
89
+ intensity: "brutal",
90
+ briefStyle: "auto",
91
+ agents: ["socrates", "first-principles", "long-horizon"],
92
+ },
93
+ {
94
+ tag: "// agent-stack",
95
+ text: "Cursor + Claude Code + ChatGPT — am I overpaying for overlap, or is this the right mix for 2026?",
96
+ hint: "first-principles + user-empathy figure out what each tool actually buys you",
97
+ tone: "constructive",
98
+ intensity: "sharp",
99
+ briefStyle: "auto",
100
+ agents: ["first-principles", "user-empathy", "value-investor"],
101
+ },
102
+ ];
103
+ // Make available for app.js's empty-state.
104
+ try { window.BOARDROOM_STARTERS = STARTER_QUESTIONS; } catch (e) {}
105
+
106
+ function escape(s) {
107
+ return String(s).replace(/[&<>"']/g, (c) => ({
108
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
109
+ }[c]));
110
+ }
111
+
112
+ // ── Provider catalogue ─────────────────────────────────
113
+ // Model providers shown on step 2. OpenRouter leads — it's the
114
+ // universal router that unlocks every model from a single key, so
115
+ // it's the lowest-friction first stop for new users. Anthropic
116
+ // (Claude) is temporarily withheld; bring it back when the
117
+ // direct-Anthropic flow is ready.
118
+ // `slug` matches /api/keys/{slug} on the backend.
119
+ const KEY_PROVIDERS = [
120
+ {
121
+ slug: "openrouter",
122
+ label: "OpenRouter",
123
+ sub: "all-in-one router",
124
+ placeholder: "sk-or-v1-…",
125
+ help: "openrouter.ai/keys",
126
+ helpUrl: "https://openrouter.ai/keys",
127
+ },
128
+ {
129
+ slug: "openai",
130
+ label: "ChatGPT",
131
+ sub: "OpenAI",
132
+ placeholder: "sk-…",
133
+ help: "platform.openai.com",
134
+ helpUrl: "https://platform.openai.com/api-keys",
135
+ },
136
+ {
137
+ slug: "google",
138
+ label: "Gemini",
139
+ sub: "Google AI Studio",
140
+ placeholder: "AIza…",
141
+ help: "aistudio.google.com",
142
+ helpUrl: "https://aistudio.google.com/apikey",
143
+ },
144
+ ];
145
+
146
+ // ── State ──────────────────────────────────────────────
147
+ let currentStep = 0;
148
+ let prefsCache = { name: "", intro: "", theme: "regent" };
149
+ /** Per-provider configured flag · true when /api/keys reports the
150
+ * provider's row as configured. Drives the green dot on each tab
151
+ * and the Next-button enable state. */
152
+ let providerConfigured = {
153
+ openrouter: false,
154
+ openai: false,
155
+ google: false,
156
+ };
157
+ /** Currently-selected provider tab on step 2. Defaults to the first
158
+ * configured provider (sticky after partial completion) or to
159
+ * OpenRouter for fresh users — the lowest-friction starting point. */
160
+ let activeProvider = "openrouter";
161
+ let overlay = null;
162
+
163
+ /** True when ANY model provider has a key. Replaces the legacy
164
+ * single-provider flag; downstream callers that just need to know
165
+ * "can the user use the product?" use this. */
166
+ function anyKeyConfigured() {
167
+ return Object.values(providerConfigured).some(Boolean);
168
+ }
169
+
170
+ // ── Persistence ────────────────────────────────────────
171
+ async function loadInitial() {
172
+ try {
173
+ const [prefsRes, keysRes] = await Promise.all([
174
+ fetch("/api/prefs"),
175
+ fetch("/api/keys"),
176
+ ]);
177
+ if (prefsRes.ok) {
178
+ const p = await prefsRes.json();
179
+ prefsCache = { name: p.name === "You" ? "" : (p.name || ""), intro: p.intro || "", theme: p.theme || "regent" };
180
+ }
181
+ if (keysRes.ok) {
182
+ const k = await keysRes.json();
183
+ // Reset then patch by row · the API returns one row per
184
+ // configured provider, with the `provider` slug + `configured`
185
+ // boolean. Unknown providers (e.g. brave) get ignored.
186
+ for (const slug of Object.keys(providerConfigured)) {
187
+ providerConfigured[slug] = false;
188
+ }
189
+ for (const row of (k.keys || [])) {
190
+ if (row && typeof row.provider === "string" && row.provider in providerConfigured) {
191
+ providerConfigured[row.provider] = !!row.configured;
192
+ }
193
+ }
194
+ // Sticky default · land on the first provider that's already
195
+ // configured so re-entering onboarding feels continuous.
196
+ const firstConfigured = KEY_PROVIDERS.find((p) => providerConfigured[p.slug]);
197
+ if (firstConfigured) activeProvider = firstConfigured.slug;
198
+ }
199
+ } catch (e) { /* keep defaults */ }
200
+ }
201
+
202
+ async function shouldShow() {
203
+ // Server is authoritative · localStorage is just an optimization
204
+ // marker. If the server reports no key configured, we MUST show
205
+ // onboarding even when localStorage thinks we've onboarded — the
206
+ // most common reason for the mismatch is a DB wipe / fresh install
207
+ // on a browser that previously onboarded a different DB.
208
+ await loadInitial();
209
+ if (!anyKeyConfigured()) {
210
+ return true;
211
+ }
212
+ // Has at least one key. Mark localStorage so we skip the server
213
+ // roundtrip on subsequent boots that don't change key state.
214
+ try { localStorage.setItem(ONBOARDED_KEY, "true"); } catch (e) {}
215
+ return false;
216
+ }
217
+
218
+ async function saveName(name) {
219
+ const v = (name || "").trim();
220
+ prefsCache.name = v;
221
+ try {
222
+ await fetch("/api/prefs", {
223
+ method: "PUT",
224
+ headers: { "content-type": "application/json" },
225
+ body: JSON.stringify({ name: v || "You" }),
226
+ });
227
+ } catch (e) { /* */ }
228
+ }
229
+
230
+ function applyThemeImmediate(slug) {
231
+ const theme = slug || "regent";
232
+ document.documentElement.setAttribute("data-theme", theme);
233
+ try { localStorage.setItem("boardroom.theme", theme); } catch (e) {}
234
+ prefsCache.theme = theme;
235
+ }
236
+
237
+ async function saveTheme(slug) {
238
+ applyThemeImmediate(slug);
239
+ try {
240
+ await fetch("/api/prefs", {
241
+ method: "PUT",
242
+ headers: { "content-type": "application/json" },
243
+ body: JSON.stringify({ theme: slug }),
244
+ });
245
+ } catch (e) { /* */ }
246
+ }
247
+
248
+ /** Save a key for a given provider. Backend endpoint is consistent:
249
+ * PUT /api/keys/{provider} with { key: "...", makeDefault: true }.
250
+ * The `makeDefault` flag tells the server "the user just picked
251
+ * this provider as their primary in onboarding" — it flips
252
+ * prefs.defaultModelV to the new provider's flagship and force-
253
+ * switches every existing agent to that primary, even ones that
254
+ * were still reachable on a different carrier. Without it, a
255
+ * user who had OpenRouter configured before and now picks Gemini
256
+ * in onboarding would see the chair stay on opus-4-7 (reachable
257
+ * via OR) instead of swinging to gemini-3-flash.
258
+ *
259
+ * Empty input doesn't fire a request — it's a no-op (DELETE flow
260
+ * lives in user-settings, not in onboarding). */
261
+ async function deleteProviderKey(provider) {
262
+ try {
263
+ await fetch("/api/keys/" + encodeURIComponent(provider), { method: "DELETE" });
264
+ } catch (e) { /* */ }
265
+ providerConfigured[provider] = false;
266
+ }
267
+
268
+ async function saveProviderKey(provider, value) {
269
+ const trimmed = (value || "").trim();
270
+ if (!trimmed) return false;
271
+ try {
272
+ const r = await fetch("/api/keys/" + encodeURIComponent(provider), {
273
+ method: "PUT",
274
+ headers: { "content-type": "application/json" },
275
+ body: JSON.stringify({ key: trimmed, makeDefault: true }),
276
+ });
277
+ if (!r.ok) return false;
278
+ const data = await r.json();
279
+ const ok = !!data.configured;
280
+ providerConfigured[provider] = ok;
281
+ return ok;
282
+ } catch (e) {
283
+ return false;
284
+ }
285
+ }
286
+
287
+ // ── Render ─────────────────────────────────────────────
288
+ function modalHTML() {
289
+ return `
290
+ <div class="onb-overlay" id="onb-overlay" role="dialog" aria-modal="true">
291
+ <div class="onb-modal" role="document">
292
+ <div class="onb-classification">
293
+ <span><span class="dot">●</span> first run · setup</span>
294
+ <span class="right">// local · ~30 seconds</span>
295
+ </div>
296
+
297
+ <div class="onb-progress" data-onb-progress>
298
+ <div class="onb-dot active"></div>
299
+ <div class="onb-dot"></div>
300
+ <div class="onb-dot"></div>
301
+ <div class="onb-dot"></div>
302
+ </div>
303
+
304
+ <div class="onb-head" data-onb-head></div>
305
+ <div class="onb-body" data-onb-body></div>
306
+
307
+ <footer class="onb-foot">
308
+ <div class="onb-foot-left">
309
+ <button type="button" class="onb-btn" data-onb-back>[ ◂ Back ]</button>
310
+ </div>
311
+ <div class="onb-foot-right" data-onb-actions></div>
312
+ </footer>
313
+ </div>
314
+ </div>
315
+ `;
316
+ }
317
+
318
+ function renderStep() {
319
+ const head = overlay.querySelector("[data-onb-head]");
320
+ const body = overlay.querySelector("[data-onb-body]");
321
+ const back = overlay.querySelector("[data-onb-back]");
322
+ const actions = overlay.querySelector("[data-onb-actions]");
323
+
324
+ // Update progress dots
325
+ const dots = overlay.querySelectorAll(".onb-dot");
326
+ dots.forEach((d, i) => {
327
+ d.classList.toggle("active", i === currentStep);
328
+ d.classList.toggle("done", i < currentStep);
329
+ });
330
+
331
+ back.style.visibility = currentStep === 0 ? "hidden" : "visible";
332
+
333
+ if (currentStep === 0) {
334
+ head.innerHTML = `
335
+ <div class="onb-tag">▸ welcome</div>
336
+ <div class="onb-title">A private boardroom for you.</div>
337
+ <div class="onb-deck">A board of stubborn advisors for the questions you take seriously. Not a chatbot. Three quick steps and you're in.</div>
338
+ `;
339
+ body.innerHTML = `
340
+ <div class="onb-field">
341
+ <div class="onb-field-label">What should the room call you?</div>
342
+ <div class="onb-input-wrap">
343
+ <input class="onb-input" data-onb-name maxlength="32" placeholder="e.g. Kay" value="${escape(prefsCache.name || "")}" autofocus>
344
+ </div>
345
+ <div class="onb-field-hint">Used in the directors' system context. You can change it later in Preference.</div>
346
+ </div>
347
+ `;
348
+ actions.innerHTML = `<button type="button" class="onb-btn primary" data-onb-next>[ Next ▸ ]</button>`;
349
+ }
350
+
351
+ else if (currentStep === 1) {
352
+ head.innerHTML = `
353
+ <div class="onb-tag">▸ theme</div>
354
+ <div class="onb-title">Pick a palette.</div>
355
+ <div class="onb-deck">Applied instantly. You can change it any time in Preference → Theme.</div>
356
+ `;
357
+ const swatches = (cs) => cs.map((c) => `<span style="background:${c}"></span>`).join("");
358
+ body.innerHTML = `
359
+ <div class="onb-theme-grid">
360
+ ${THEMES.map((t) => `
361
+ <a href="#" class="onb-theme${t.slug === prefsCache.theme ? " active" : ""}" data-onb-theme="${t.slug}">
362
+ <span class="onb-theme-swatch">${swatches(t.swatches)}</span>
363
+ <span>
364
+ <span class="onb-theme-name">${escape(t.name)}</span>
365
+ <div class="onb-theme-desc">${escape(t.desc)}</div>
366
+ </span>
367
+ <span></span>
368
+ </a>
369
+ `).join("")}
370
+ </div>
371
+ `;
372
+ actions.innerHTML = `<button type="button" class="onb-btn primary" data-onb-next>[ Next ▸ ]</button>`;
373
+ }
374
+
375
+ else if (currentStep === 2) {
376
+ const active = KEY_PROVIDERS.find((p) => p.slug === activeProvider) || KEY_PROVIDERS[0];
377
+ const tabs = KEY_PROVIDERS.map((p) => {
378
+ const isActive = p.slug === active.slug;
379
+ const isConfigured = providerConfigured[p.slug];
380
+ return `
381
+ <button type="button"
382
+ class="onb-key-tab${isActive ? " active" : ""}${isConfigured ? " configured" : ""}"
383
+ data-onb-provider="${escape(p.slug)}">
384
+ <span class="onb-key-tab-label">${escape(p.label)}</span>
385
+ <span class="onb-key-tab-sub">${escape(p.sub)}</span>
386
+ ${isConfigured ? `<span class="onb-key-tab-dot" title="configured">●</span>` : ""}
387
+ </button>
388
+ `;
389
+ }).join("");
390
+
391
+ const inputValue = ""; // never echo back the saved key
392
+ const status = providerConfigured[active.slug]
393
+ ? `<div class="onb-key-status ok">● ${escape(active.label)} key configured</div>`
394
+ : "";
395
+
396
+ head.innerHTML = `
397
+ <div class="onb-tag">▸ api key</div>
398
+ <div class="onb-title">Bring your own key.</div>
399
+ <div class="onb-deck">Boardroom runs against your model provider. Pick one below — any single key is enough to get started. Stored locally on this machine, never uploaded.</div>
400
+ `;
401
+ body.innerHTML = `
402
+ <div class="onb-key-tabs">${tabs}</div>
403
+ <div class="onb-field">
404
+ <div class="onb-field-label" data-onb-field-label>${escape(active.label)} API key</div>
405
+ <div class="onb-input-wrap">
406
+ <input class="onb-input" data-onb-key type="password" placeholder="${escape(active.placeholder)}" autocomplete="off" spellcheck="false" value="${escape(inputValue)}">
407
+ <button type="button" class="onb-input-reveal" data-onb-reveal aria-label="Show key" aria-pressed="false">show</button>
408
+ </div>
409
+ ${status}
410
+ <div class="onb-field-hint">
411
+ Don't have one? <a href="${escape(active.helpUrl)}" target="_blank" rel="noopener" data-onb-help-link>Generate at ${escape(active.help)} →</a>
412
+ <br>
413
+ You can add or change provider keys later in Preferences.
414
+ </div>
415
+ </div>
416
+ `;
417
+ const enableNext = anyKeyConfigured();
418
+ actions.innerHTML = `<button type="button" class="onb-btn primary" data-onb-next ${enableNext ? "" : "disabled"}>[ Next ▸ ]</button>`;
419
+ }
420
+
421
+ else if (currentStep === 3) {
422
+ head.innerHTML = `
423
+ <div class="onb-tag">▸ done · pick a starting question</div>
424
+ <div class="onb-title">${prefsCache.name ? escape(prefsCache.name) + ", y" : "Y"}our boardroom is open.</div>
425
+ <div class="onb-deck">Pick a starter to take one full journey: convene a room, watch three directors stress-test the question, then file the brief. Or convene your own from scratch.</div>
426
+ `;
427
+ const cards = STARTER_QUESTIONS.map((q, idx) => {
428
+ const cast = (q.agents || []).map((slug) => `
429
+ <span class="onb-starter-agent" title="${escape(NAMES[slug] || slug)}">
430
+ <img class="onb-starter-av" src="avatars/${escape(slug)}.svg" alt="${escape(NAMES[slug] || slug)}">
431
+ <span class="onb-starter-name">${escape(NAMES[slug] || slug)}</span>
432
+ </span>
433
+ `).join("");
434
+ return `
435
+ <div class="onb-starter" data-onb-action="starter" data-onb-starter-idx="${idx}">
436
+ <div class="onb-starter-tag">${escape(q.tag)}</div>
437
+ <div class="onb-starter-main">
438
+ <div class="onb-starter-text">${escape(q.text)}</div>
439
+ <div class="onb-starter-hint">${escape(q.hint)}</div>
440
+ <div class="onb-starter-meta">
441
+ <span class="meta-tag tag-tone"><span class="k">tone</span><span class="v">${escape(q.tone)}</span></span>
442
+ <span class="meta-tag tag-intensity"><span class="k">intensity</span><span class="v">${escape(q.intensity)}</span></span>
443
+ </div>
444
+ </div>
445
+ <!-- data-no-agent-overlay · these avatars are decorative
446
+ cast indicators, not profile triggers. agent-overlay.js
447
+ honours this attribute on autotag + click. -->
448
+ <div class="onb-starter-cast" data-no-agent-overlay>${cast}</div>
449
+ <button type="button" class="onb-starter-start" data-onb-action="starter" data-onb-starter-idx="${idx}">
450
+ <span class="onb-starter-start-arrow">▶</span>
451
+ <span class="onb-starter-start-label">Start</span>
452
+ </button>
453
+ </div>
454
+ `;
455
+ }).join("");
456
+ body.innerHTML = `
457
+ <div class="onb-starters">${cards}</div>
458
+ <div class="onb-final-divider"><span>or</span></div>
459
+ <div class="onb-final">
460
+ <button type="button" class="onb-final-card" data-onb-action="convene">
461
+ <div class="onb-final-mark">▸ CONVENE</div>
462
+ <div class="onb-final-title">Convene your own</div>
463
+ <div class="onb-final-deck">Pick directors, write your own question, start the room.</div>
464
+ </button>
465
+ </div>
466
+ `;
467
+ actions.innerHTML = `<button type="button" class="onb-btn" data-onb-action="skip">[ I'll explore on my own ]</button>`;
468
+ }
469
+ }
470
+
471
+ // ── Actions ────────────────────────────────────────────
472
+ function next() {
473
+ if (currentStep === 0) {
474
+ const v = overlay.querySelector("[data-onb-name]").value;
475
+ void saveName(v);
476
+ } else if (currentStep === 2) {
477
+ // Gated · the user must have AT LEAST ONE provider key
478
+ // configured (any of OpenRouter / OpenAI / Google) before
479
+ // they can proceed to the starter screen.
480
+ if (!anyKeyConfigured()) return;
481
+ }
482
+ currentStep = Math.min(currentStep + 1, 3);
483
+ renderStep();
484
+ }
485
+ function back() {
486
+ currentStep = Math.max(currentStep - 1, 0);
487
+ renderStep();
488
+ }
489
+
490
+ /** Switch the active provider tab on step 2. Inline DOM update —
491
+ * NOT a full re-render — so the single input element survives the
492
+ * switch and whatever the user has typed stays in the field. The
493
+ * tabs above are just a label declaring what the input is for. */
494
+ function selectProvider(slug) {
495
+ if (!(slug in providerConfigured)) return;
496
+ if (activeProvider === slug) return;
497
+ activeProvider = slug;
498
+
499
+ const active = KEY_PROVIDERS.find((p) => p.slug === slug) || KEY_PROVIDERS[0];
500
+
501
+ overlay.querySelectorAll(".onb-key-tab").forEach((el) => {
502
+ el.classList.toggle("active", el.getAttribute("data-onb-provider") === slug);
503
+ });
504
+
505
+ const fieldLabel = overlay.querySelector("[data-onb-field-label]");
506
+ if (fieldLabel) fieldLabel.textContent = `${active.label} API key`;
507
+
508
+ const input = overlay.querySelector("[data-onb-key]");
509
+ if (input) input.setAttribute("placeholder", active.placeholder);
510
+
511
+ const helpLink = overlay.querySelector("[data-onb-help-link]");
512
+ if (helpLink) {
513
+ helpLink.setAttribute("href", active.helpUrl);
514
+ helpLink.textContent = `Generate at ${active.help} →`;
515
+ }
516
+
517
+ // Status pill reflects the now-active provider's saved state.
518
+ const wrap = input ? input.closest(".onb-field") : null;
519
+ const existing = wrap ? wrap.querySelector(".onb-key-status") : null;
520
+ if (existing) existing.remove();
521
+ if (providerConfigured[slug] && wrap) {
522
+ wrap.querySelector(".onb-input-wrap").insertAdjacentHTML(
523
+ "afterend",
524
+ `<div class="onb-key-status ok">● ${escape(active.label)} key configured</div>`,
525
+ );
526
+ }
527
+ }
528
+
529
+ async function trySaveKey(value) {
530
+ const provider = activeProvider;
531
+ const label = (KEY_PROVIDERS.find((p) => p.slug === provider) || {}).label || provider;
532
+ const status = overlay.querySelector(".onb-key-status");
533
+ const nextBtn = overlay.querySelector("[data-onb-next]");
534
+ if (status) status.outerHTML = `<div class="onb-key-status warn">○ checking…</div>`;
535
+ const ok = await saveProviderKey(provider, value);
536
+ const fresh = overlay.querySelector(".onb-key-status, [class^=onb-key-status]");
537
+ if (ok) {
538
+ // Single-provider invariant · the onboarding step has one input;
539
+ // the tabs above only declare which provider that input is for.
540
+ // After a successful save, retire any other provider keys so the
541
+ // user leaves onboarding with exactly one configured carrier.
542
+ for (const slug of Object.keys(providerConfigured)) {
543
+ if (slug === provider || !providerConfigured[slug]) continue;
544
+ await deleteProviderKey(slug);
545
+ const otherTab = overlay.querySelector(`.onb-key-tab[data-onb-provider="${slug}"]`);
546
+ if (otherTab) {
547
+ otherTab.classList.remove("configured");
548
+ otherTab.querySelector(".onb-key-tab-dot")?.remove();
549
+ }
550
+ }
551
+ if (fresh) fresh.outerHTML = `<div class="onb-key-status ok">● ${escape(label)} key configured</div>`;
552
+ else {
553
+ const wrap = overlay.querySelector("[data-onb-key]")?.closest(".onb-field");
554
+ if (wrap) wrap.querySelector(".onb-input-wrap").insertAdjacentHTML(
555
+ "afterend",
556
+ `<div class="onb-key-status ok">● ${escape(label)} key configured</div>`,
557
+ );
558
+ }
559
+ // Update the matching tab's "configured" state inline rather
560
+ // than re-rendering the whole step (would steal input focus).
561
+ const tab = overlay.querySelector(`.onb-key-tab[data-onb-provider="${provider}"]`);
562
+ if (tab && !tab.classList.contains("configured")) {
563
+ tab.classList.add("configured");
564
+ tab.insertAdjacentHTML("beforeend", `<span class="onb-key-tab-dot" title="configured">●</span>`);
565
+ }
566
+ if (nextBtn) nextBtn.disabled = false;
567
+ } else {
568
+ if (fresh) fresh.outerHTML = `<div class="onb-key-status error">✗ not saved</div>`;
569
+ if (nextBtn) nextBtn.disabled = !anyKeyConfigured();
570
+ }
571
+ }
572
+
573
+ function complete(then) {
574
+ try { localStorage.setItem(ONBOARDED_KEY, "true"); } catch (e) {}
575
+ document.body.classList.remove("onb-locked");
576
+ overlay.classList.remove("open");
577
+ setTimeout(() => {
578
+ overlay.remove();
579
+ overlay = null;
580
+ }, 220);
581
+ // Sync every client-side cache the server just mutated. The PUT
582
+ // /api/keys/:provider call with `makeDefault:true` did three
583
+ // server-side writes: the key row, prefs.defaultModelV (flipped
584
+ // to the new carrier's primary), and every agent's modelV (via
585
+ // reconcile). Each of those has its own client cache · refresh
586
+ // them all so user-settings, sidebar, and pickers reflect the
587
+ // new state immediately, no full reload needed. Wait on all
588
+ // three before running the continuation so any post-onboarding
589
+ // action (createRoom, open convene) sees fresh state.
590
+ const continuation = typeof then === "function" ? then : null;
591
+ const refreshes = [];
592
+ if (window.app && typeof window.app.refreshKeys === "function") {
593
+ refreshes.push(Promise.resolve(window.app.refreshKeys()).catch(() => {}));
594
+ }
595
+ if (window.app && typeof window.app.refreshAgents === "function") {
596
+ refreshes.push(Promise.resolve(window.app.refreshAgents()).catch(() => {}));
597
+ }
598
+ if (typeof window.boardroomModelsRefresh === "function") {
599
+ refreshes.push(Promise.resolve(window.boardroomModelsRefresh()).catch(() => {}));
600
+ }
601
+ Promise.all(refreshes).finally(() => { if (continuation) continuation(); });
602
+ }
603
+
604
+ async function createDemoRoom(spec) {
605
+ if (!window.app || typeof window.app.createRoom !== "function") {
606
+ // app hasn't booted yet — wait briefly then retry once.
607
+ await new Promise((r) => setTimeout(r, 200));
608
+ }
609
+ // Accept either a full starter spec object (preferred), a plain subject
610
+ // string (legacy), or nothing (default to the first starter).
611
+ let s = spec;
612
+ if (!s) s = STARTER_QUESTIONS[0];
613
+ else if (typeof s === "string") {
614
+ const text = s.trim();
615
+ s = STARTER_QUESTIONS.find((q) => q.text === text) || { text };
616
+ }
617
+ try {
618
+ await window.app.createRoom({
619
+ subject: s.text,
620
+ agentIds: s.agents && s.agents.length >= 2 ? s.agents : DEMO_AGENTS,
621
+ mode: s.tone || "constructive",
622
+ intensity: s.intensity || "sharp",
623
+ briefStyle: s.briefStyle || "auto",
624
+ });
625
+ } catch (e) {
626
+ alert("Couldn't create a starter room: " + (e && e.message ? e.message : e));
627
+ }
628
+ }
629
+
630
+ function openConveneAfter() {
631
+ setTimeout(() => {
632
+ // Convene-overlay was retired in favour of the inline composer.
633
+ // Fall back to closing the active room so the composer shows; if
634
+ // app.closeRoom isn't ready yet, no-op (the user is on the
635
+ // dashboard already and will see it on next interaction).
636
+ try {
637
+ if (window.app && typeof window.app.closeRoom === "function") {
638
+ window.app.closeRoom();
639
+ } else if (typeof window.openConveneOverlay === "function") {
640
+ window.openConveneOverlay();
641
+ }
642
+ } catch { /* ignore */ }
643
+ }, 250);
644
+ }
645
+
646
+ // ── Wiring ─────────────────────────────────────────────
647
+ function wire() {
648
+ overlay.addEventListener("click", async (e) => {
649
+ const t = e.target;
650
+
651
+ if (t.closest("[data-onb-next]")) {
652
+ e.preventDefault();
653
+ next();
654
+ return;
655
+ }
656
+ if (t.closest("[data-onb-back]")) {
657
+ e.preventDefault();
658
+ back();
659
+ return;
660
+ }
661
+ const themeChoice = t.closest("[data-onb-theme]");
662
+ if (themeChoice) {
663
+ e.preventDefault();
664
+ const slug = themeChoice.getAttribute("data-onb-theme");
665
+ overlay.querySelectorAll(".onb-theme").forEach((el) => el.classList.remove("active"));
666
+ themeChoice.classList.add("active");
667
+ void saveTheme(slug);
668
+ return;
669
+ }
670
+ const providerChoice = t.closest("[data-onb-provider]");
671
+ if (providerChoice) {
672
+ e.preventDefault();
673
+ const slug = providerChoice.getAttribute("data-onb-provider");
674
+ if (slug) selectProvider(slug);
675
+ return;
676
+ }
677
+ const reveal = t.closest("[data-onb-reveal]");
678
+ if (reveal) {
679
+ e.preventDefault();
680
+ const input = overlay.querySelector("[data-onb-key]");
681
+ if (!input) return;
682
+ const showing = input.getAttribute("type") === "text";
683
+ input.setAttribute("type", showing ? "password" : "text");
684
+ reveal.setAttribute("aria-pressed", showing ? "false" : "true");
685
+ reveal.setAttribute("aria-label", showing ? "Show key" : "Hide key");
686
+ reveal.textContent = showing ? "show" : "hide";
687
+ return;
688
+ }
689
+ const action = t.closest("[data-onb-action]");
690
+ if (action) {
691
+ e.preventDefault();
692
+ e.stopPropagation();
693
+ const a = action.getAttribute("data-onb-action");
694
+ if (a === "starter") {
695
+ const idx = parseInt(action.getAttribute("data-onb-starter-idx") || "-1", 10);
696
+ const spec = Number.isFinite(idx) ? STARTER_QUESTIONS[idx] : null;
697
+ complete(() => { void createDemoRoom(spec); });
698
+ } else if (a === "demo") {
699
+ complete(() => { void createDemoRoom(); });
700
+ } else if (a === "convene") {
701
+ complete(() => openConveneAfter());
702
+ } else if (a === "skip") {
703
+ complete();
704
+ }
705
+ return;
706
+ }
707
+ });
708
+
709
+ // Live updates for the API key field — debounce, validate, save.
710
+ overlay.addEventListener("input", (e) => {
711
+ const t = e.target;
712
+ if (t.matches("[data-onb-name]")) {
713
+ prefsCache.name = t.value;
714
+ } else if (t.matches("[data-onb-key]")) {
715
+ clearTimeout(t.__onbTimer);
716
+ t.__onbTimer = setTimeout(() => trySaveKey(t.value), 280);
717
+ }
718
+ });
719
+
720
+ // Enter on name → next
721
+ overlay.addEventListener("keydown", (e) => {
722
+ if (e.key !== "Enter") return;
723
+ if (e.target.matches("[data-onb-name]")) {
724
+ e.preventDefault();
725
+ next();
726
+ }
727
+ });
728
+ }
729
+
730
+ function show() {
731
+ if (document.getElementById("onb-overlay")) return;
732
+ // Claim the dashboard sub-state · prototype-dashboard.html runs a
733
+ // restore tick (~2.5s of 250ms retries) that re-opens whatever
734
+ // agent profile the user last viewed once refreshAgents mounts the
735
+ // sidebar rows. Onboarding always lands the user on a fresh room
736
+ // (or the composer), so claim "rooms" + clear the saved agent id
737
+ // up-front. Without this, finishing onboarding fast enough races
738
+ // the tick and the user occasionally lands on a stale agent
739
+ // profile instead of the room they just convened.
740
+ try {
741
+ localStorage.setItem("boardroom.sidebar.tab", "rooms");
742
+ localStorage.setItem("boardroom.sidebar.agents", "new");
743
+ } catch (e) {}
744
+ const wrap = document.createElement("div");
745
+ wrap.innerHTML = modalHTML().trim();
746
+ document.body.appendChild(wrap.firstChild);
747
+ overlay = document.getElementById("onb-overlay");
748
+ overlay.classList.add("open");
749
+ document.body.classList.add("onb-locked");
750
+ renderStep();
751
+ wire();
752
+
753
+ // First-run: for the user-settings.js bootstrap, suppress ⚙ click
754
+ // while overlay is open by capture-phase guard.
755
+ document.addEventListener("click", guardSettingsTrigger, true);
756
+ }
757
+
758
+ function guardSettingsTrigger(e) {
759
+ if (!overlay || !overlay.classList.contains("open")) {
760
+ document.removeEventListener("click", guardSettingsTrigger, true);
761
+ return;
762
+ }
763
+ if (e.target.closest("[data-user-settings-trigger]")) {
764
+ e.stopPropagation();
765
+ e.preventDefault();
766
+ }
767
+ }
768
+
769
+ async function init() {
770
+ if (await shouldShow()) {
771
+ show();
772
+ }
773
+ }
774
+
775
+ if (document.readyState === "loading") {
776
+ document.addEventListener("DOMContentLoaded", init);
777
+ } else {
778
+ init();
779
+ }
780
+
781
+ window.boardroomShowOnboarding = show; // exposed for testing
782
+ })();