privateboard 0.1.13 → 0.1.16

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.
@@ -1,58 +1,43 @@
1
1
  /* ═══════════════════════════════════════════
2
- ONBOARDING · first-run wizard
2
+ ONBOARDING · first-run storyline (v2)
3
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.
4
+ Four short story beats, then we hand the user to the composer.
7
5
 
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
6
+ 0 welcome / name — sit down
7
+ 1 what is this — a meeting, not a chatbot
8
+ 2 api key — hand over a key
9
+ 3 cast preview — your board
13
10
 
14
- Each step persists immediately (PUT /api/prefs · PUT /api/keys/openrouter).
11
+ After step 3 the overlay dismisses and a one-shot tooltip appears
12
+ over the composer pointing at the subject input. The user opens
13
+ their first room themselves — onboarding teaches; the user does.
14
+
15
+ Each step persists what it touches immediately
16
+ (PUT /api/prefs · PUT /api/keys/{provider}).
15
17
  */
16
18
  (function () {
17
19
  const ONBOARDED_KEY = "boardroom.onboarded";
20
+ const FIRST_HINT_KEY = "boardroom.onb.firstHint";
18
21
 
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"] }
22
+ /** Sample of the bench shown on step 3. Avatar files live in
23
+ * public/avatars/<slug>.svg only slugs that have a real SVG
24
+ * there should appear, otherwise the broken-image glyph shows
25
+ * through. chair leads (always first), then five directors. */
26
+ const CAST_PREVIEW = [
27
+ { slug: "chair", name: "Chair", role: "host" },
28
+ { slug: "socrates", name: "Socrates", role: "skeptic" },
29
+ { slug: "value-investor", name: "Value Investor", role: "long memory" },
30
+ { slug: "first-principles", name: "First Principles", role: "causal reasoner" },
31
+ { slug: "long-horizon", name: "Long Horizon", role: "patient mind" },
32
+ { slug: "user-empathy", name: "User-Empathy", role: "field empath" },
40
33
  ];
41
34
 
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.
35
+ /** Composer empty-state fallback · the new-room composer in app.js
36
+ * shows topic recommendations first, then falls back to these
37
+ * hardcoded starters via `window.BOARDROOM_STARTERS` if recs are
38
+ * empty or still loading. Onboarding itself no longer renders
39
+ * them they live here purely to feed the composer's tray on a
40
+ * brand-new install. */
56
41
  const STARTER_QUESTIONS = [
57
42
  {
58
43
  tag: "// ai-startup",
@@ -100,9 +85,65 @@
100
85
  agents: ["first-principles", "user-empathy", "value-investor"],
101
86
  },
102
87
  ];
103
- // Make available for app.js's empty-state.
104
88
  try { window.BOARDROOM_STARTERS = STARTER_QUESTIONS; } catch (e) {}
105
89
 
90
+ /** EN-locked fallback for every onb_v2_* key — used when window.I18n
91
+ * hasn't booted yet, or the active locale is missing the key. Mirrors
92
+ * the EN block in i18n.js exactly; keep these in sync if you edit the
93
+ * strings there. */
94
+ const EN_FALLBACK = {
95
+ onb_v2_classification_left: "first run · welcome",
96
+ onb_v2_classification_right: "// local · ~60 seconds",
97
+ onb_v2_back: "[ ◂ Back ]",
98
+ onb_v2_next: "[ Next ▸ ]",
99
+ onb_v2_continue: "[ Continue ▸ ]",
100
+ onb_v2_ready: "[ I'm ready ▸ ]",
101
+ onb_v2_enter: "[ Step in ▸ ]",
102
+ onb_v2_name_kicker: "00 — Sit down",
103
+ onb_v2_name_title: "How should the room call you?",
104
+ onb_v2_name_sub: "From here on, this is your room.",
105
+ onb_v2_name_placeholder: "e.g. Kay",
106
+ onb_v2_what_kicker: "01 — A meeting, not a chatbot",
107
+ onb_v2_what_title: "You convene stubborn advisors and let them argue, in front of you, over a question that matters.",
108
+ onb_v2_what_body: "Three acts · convene · sharpen · adjourn. You sit as chair, the directors take sides, and on adjourn you walk away with a brief in hand.",
109
+ onb_v2_what_note: "Not a chatbot. The directors don't agree with you — they pressure-test the question until it sharpens.",
110
+ onb_v2_key_kicker: "02 — Hand over a key",
111
+ onb_v2_key_title: "Pick one brain — or pick many.",
112
+ onb_v2_key_body: "Your key stays on this machine. We never upload it.",
113
+ onb_v2_key_recommend_badge: "// recommended",
114
+ onb_v2_key_recommend_name: "OpenRouter · one key, every model",
115
+ onb_v2_key_recommend_body: "Each director can run on a different model — Claude as the skeptic, GPT as the pattern hunter, Gemini as the long-horizon strategist. The chair routes each turn to the right brain.",
116
+ onb_v2_key_or: "or — a direct provider",
117
+ onb_v2_key_or_body: "Same model for every director. Personas stay distinct, but they all share one brain underneath.",
118
+ onb_v2_voice_kicker: "03 — Give them a voice · optional",
119
+ onb_v2_voice_title: "Want them to speak aloud?",
120
+ onb_v2_voice_body: "Add a TTS key and the boardroom turns into a round table. Directors take seats, raise their head when speaking, fade back when listening.",
121
+ onb_v2_voice_pitch: "It plays like a slow strategy game — you watch your bench think, debate, and challenge each other in real voices. Each director gets a distinct voice on first key. Skip if you'd rather stay text-only; you can flip this on anytime in Settings.",
122
+ onb_v2_voice_skip: "[ Skip for now ]",
123
+ onb_v2_voice_skip_cta: "[ Skip → ]",
124
+ onb_v2_cast_kicker: "04 — Your board",
125
+ onb_v2_cast_title: "Chair runs the room. Directors disagree. The brief closes the loop.",
126
+ onb_v2_cast_body: "By default the chair picks three directors for you; you can also pick your own. That choice — and your question — happens in the composer next.",
127
+ onb_v2_cast_lineup: "// preview only · pick later in the composer",
128
+ onb_v2_cast_next_kicker: "// next · in the composer",
129
+ onb_v2_cast_next_step_1: "Write your question",
130
+ onb_v2_cast_next_step_2: "Chair picks 3 directors",
131
+ onb_v2_cast_next_step_3: "Hit Convene",
132
+ onb_v2_hint_kicker: "// your seat",
133
+ onb_v2_hint_body: "Write the question that's been sitting on your mind — then press Convene.",
134
+ onb_v2_hint_dismiss: "Got it",
135
+ };
136
+
137
+ function t(key) {
138
+ try {
139
+ if (window.I18n && typeof window.I18n.t === "function") {
140
+ const v = window.I18n.t(key);
141
+ if (typeof v === "string" && v && v !== key) return v;
142
+ }
143
+ } catch (e) { /* fall through */ }
144
+ return EN_FALLBACK[key] || key;
145
+ }
146
+
106
147
  function escape(s) {
107
148
  return String(s).replace(/[&<>"']/g, (c) => ({
108
149
  "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
@@ -149,31 +190,69 @@
149
190
  },
150
191
  ];
151
192
 
193
+ /** TTS providers offered on step 3 (optional). MiniMax leads because
194
+ * it ships a richer roster of localised Chinese voices and an
195
+ * affordable starter tier; ElevenLabs is the premium English-leaning
196
+ * alternative. Unlike LLM providers (single-active invariant) both
197
+ * can be configured side-by-side — backend keeps voices from each
198
+ * carrier in one registry. */
199
+ const VOICE_PROVIDERS = [
200
+ {
201
+ slug: "minimax",
202
+ label: "MiniMax",
203
+ sub: "speech · CN/EN voices, cloning",
204
+ placeholder: "mm-…",
205
+ help: "minimax.io",
206
+ helpUrl: "https://www.minimax.io/platform/user-center/basic-information/interface-key",
207
+ },
208
+ {
209
+ slug: "elevenlabs",
210
+ label: "ElevenLabs",
211
+ sub: "speech · premium EN voices",
212
+ placeholder: "xi-…",
213
+ help: "elevenlabs.io",
214
+ helpUrl: "https://elevenlabs.io/app/settings/api-keys",
215
+ },
216
+ ];
217
+
218
+ /** Themes shown in the voice-room preview banner on step 3. Each
219
+ * key matches a `<article data-preview-theme=…>` in the
220
+ * `<template id="vonb-themes">` block in index.html (which the
221
+ * marketing voice-onboarding overlay also clones from). */
222
+ const VOICE_PREVIEW_THEMES = ["eastwood", "regent", "atrium", "nintendo"];
223
+
224
+ const STEP_COUNT = 5;
225
+
152
226
  // ── State ──────────────────────────────────────────────
153
227
  let currentStep = 0;
154
228
  let prefsCache = { name: "", intro: "", theme: "regent" };
155
- /** Per-provider configured flag · true when /api/keys reports the
156
- * provider's row as configured. Drives the green dot on each tab
157
- * and the Next-button enable state. */
158
229
  let providerConfigured = {
159
230
  openrouter: false,
160
231
  anthropic: false,
161
232
  openai: false,
162
233
  google: false,
163
234
  };
164
- /** Currently-selected provider tab on step 2. Defaults to the first
165
- * configured provider (sticky after partial completion) or to
166
- * OpenRouter for fresh users — the lowest-friction starting point. */
235
+ let voiceProviderConfigured = {
236
+ minimax: false,
237
+ elevenlabs: false,
238
+ };
167
239
  let activeProvider = "openrouter";
240
+ let activeVoiceProvider = "minimax";
241
+ /** Theme key chosen for the step-3 room preview · picked once per
242
+ * session (when the modal mounts) so re-rendering the step doesn't
243
+ * flicker the visual. Cycled via VOICE_PREVIEW_THEMES round-robin
244
+ * on each fresh show() to keep replay feeling alive. */
245
+ let voicePreviewTheme = "regent";
168
246
  let overlay = null;
169
247
 
170
- /** True when ANY model provider has a key. Replaces the legacy
171
- * single-provider flag; downstream callers that just need to know
172
- * "can the user use the product?" use this. */
173
248
  function anyKeyConfigured() {
174
249
  return Object.values(providerConfigured).some(Boolean);
175
250
  }
176
251
 
252
+ function anyVoiceKeyConfigured() {
253
+ return Object.values(voiceProviderConfigured).some(Boolean);
254
+ }
255
+
177
256
  // ── Persistence ────────────────────────────────────────
178
257
  async function loadInitial() {
179
258
  try {
@@ -187,37 +266,34 @@
187
266
  }
188
267
  if (keysRes.ok) {
189
268
  const k = await keysRes.json();
190
- // Reset then patch by row · the API returns one row per
191
- // configured provider, with the `provider` slug + `configured`
192
- // boolean. Unknown providers (e.g. brave) get ignored.
193
269
  for (const slug of Object.keys(providerConfigured)) {
194
270
  providerConfigured[slug] = false;
195
271
  }
272
+ for (const slug of Object.keys(voiceProviderConfigured)) {
273
+ voiceProviderConfigured[slug] = false;
274
+ }
196
275
  for (const row of (k.keys || [])) {
197
- if (row && typeof row.provider === "string" && row.provider in providerConfigured) {
276
+ if (!row || typeof row.provider !== "string") continue;
277
+ if (row.provider in providerConfigured) {
198
278
  providerConfigured[row.provider] = !!row.configured;
199
279
  }
280
+ if (row.provider in voiceProviderConfigured) {
281
+ voiceProviderConfigured[row.provider] = !!row.configured;
282
+ }
200
283
  }
201
- // Sticky default · land on the first provider that's already
202
- // configured so re-entering onboarding feels continuous.
203
284
  const firstConfigured = KEY_PROVIDERS.find((p) => providerConfigured[p.slug]);
204
285
  if (firstConfigured) activeProvider = firstConfigured.slug;
286
+ const firstVoiceConfigured = VOICE_PROVIDERS.find((p) => voiceProviderConfigured[p.slug]);
287
+ if (firstVoiceConfigured) activeVoiceProvider = firstVoiceConfigured.slug;
205
288
  }
206
289
  } catch (e) { /* keep defaults */ }
207
290
  }
208
291
 
209
292
  async function shouldShow() {
210
- // Server is authoritative · localStorage is just an optimization
211
- // marker. If the server reports no key configured, we MUST show
212
- // onboarding even when localStorage thinks we've onboarded — the
213
- // most common reason for the mismatch is a DB wipe / fresh install
214
- // on a browser that previously onboarded a different DB.
215
293
  await loadInitial();
216
294
  if (!anyKeyConfigured()) {
217
295
  return true;
218
296
  }
219
- // Has at least one key. Mark localStorage so we skip the server
220
- // roundtrip on subsequent boots that don't change key state.
221
297
  try { localStorage.setItem(ONBOARDED_KEY, "true"); } catch (e) {}
222
298
  return false;
223
299
  }
@@ -234,37 +310,10 @@
234
310
  } catch (e) { /* */ }
235
311
  }
236
312
 
237
- function applyThemeImmediate(slug) {
238
- const theme = slug || "regent";
239
- document.documentElement.setAttribute("data-theme", theme);
240
- try { localStorage.setItem("boardroom.theme", theme); } catch (e) {}
241
- prefsCache.theme = theme;
242
- }
243
-
244
- async function saveTheme(slug) {
245
- applyThemeImmediate(slug);
246
- try {
247
- await fetch("/api/prefs", {
248
- method: "PUT",
249
- headers: { "content-type": "application/json" },
250
- body: JSON.stringify({ theme: slug }),
251
- });
252
- } catch (e) { /* */ }
253
- }
254
-
255
- /** Save a key for a given provider. Backend endpoint is consistent:
256
- * PUT /api/keys/{provider} with { key: "...", makeDefault: true }.
257
- * The `makeDefault` flag tells the server "the user just picked
258
- * this provider as their primary in onboarding" — it flips
259
- * prefs.defaultModelV to the new provider's flagship and force-
260
- * switches every existing agent to that primary, even ones that
261
- * were still reachable on a different carrier. Without it, a
262
- * user who had OpenRouter configured before and now picks Gemini
263
- * in onboarding would see the chair stay on opus-4-7 (reachable
264
- * via OR) instead of swinging to gemini-3-flash.
265
- *
266
- * Empty input doesn't fire a request — it's a no-op (DELETE flow
267
- * lives in user-settings, not in onboarding). */
313
+ /** Save a key for a given provider. PUT /api/keys/{provider} with
314
+ * { key, makeDefault: true } — the makeDefault flag flips
315
+ * prefs.defaultModelV server-side and reconciles every agent's
316
+ * modelV to the new provider's flagship. */
268
317
  async function deleteProviderKey(provider) {
269
318
  try {
270
319
  await fetch("/api/keys/" + encodeURIComponent(provider), { method: "DELETE" });
@@ -291,29 +340,52 @@
291
340
  }
292
341
  }
293
342
 
343
+ /** Save a voice / TTS provider key (MiniMax · ElevenLabs).
344
+ * Unlike LLM providers, voice providers coexist — the backend
345
+ * registry lets both carriers serve voices side-by-side — so we
346
+ * do NOT pass `makeDefault` and do NOT retire siblings. The
347
+ * backend's first-voice-key 0→1 transition still triggers
348
+ * per-agent voice auto-assignment server-side. */
349
+ async function saveVoiceKey(provider, value) {
350
+ const trimmed = (value || "").trim();
351
+ if (!trimmed) return false;
352
+ try {
353
+ const r = await fetch("/api/keys/" + encodeURIComponent(provider), {
354
+ method: "PUT",
355
+ headers: { "content-type": "application/json" },
356
+ body: JSON.stringify({ key: trimmed }),
357
+ });
358
+ if (!r.ok) return false;
359
+ const data = await r.json();
360
+ const ok = !!data.configured;
361
+ voiceProviderConfigured[provider] = ok;
362
+ return ok;
363
+ } catch (e) {
364
+ return false;
365
+ }
366
+ }
367
+
294
368
  // ── Render ─────────────────────────────────────────────
295
369
  function modalHTML() {
370
+ const dots = Array.from({ length: STEP_COUNT }, (_, i) =>
371
+ `<div class="onb-dot${i === 0 ? " active" : ""}"></div>`
372
+ ).join("");
296
373
  return `
297
374
  <div class="onb-overlay" id="onb-overlay" role="dialog" aria-modal="true">
298
- <div class="onb-modal" role="document">
375
+ <div class="onb-modal onb-modal-v2" role="document">
299
376
  <div class="onb-classification">
300
- <span><span class="dot">●</span> first run · setup</span>
301
- <span class="right">// local · ~30 seconds</span>
377
+ <span><span class="dot">●</span> ${escape(t("onb_v2_classification_left"))}</span>
378
+ <span class="right">${escape(t("onb_v2_classification_right"))}</span>
302
379
  </div>
303
380
 
304
- <div class="onb-progress" data-onb-progress>
305
- <div class="onb-dot active"></div>
306
- <div class="onb-dot"></div>
307
- <div class="onb-dot"></div>
308
- <div class="onb-dot"></div>
309
- </div>
381
+ <div class="onb-progress" data-onb-progress>${dots}</div>
310
382
 
311
383
  <div class="onb-head" data-onb-head></div>
312
384
  <div class="onb-body" data-onb-body></div>
313
385
 
314
386
  <footer class="onb-foot">
315
387
  <div class="onb-foot-left">
316
- <button type="button" class="onb-btn" data-onb-back>[ ◂ Back ]</button>
388
+ <button type="button" class="onb-btn" data-onb-back>${escape(t("onb_v2_back"))}</button>
317
389
  </div>
318
390
  <div class="onb-foot-right" data-onb-actions></div>
319
391
  </footer>
@@ -328,7 +400,6 @@
328
400
  const back = overlay.querySelector("[data-onb-back]");
329
401
  const actions = overlay.querySelector("[data-onb-actions]");
330
402
 
331
- // Update progress dots
332
403
  const dots = overlay.querySelectorAll(".onb-dot");
333
404
  dots.forEach((d, i) => {
334
405
  d.classList.toggle("active", i === currentStep);
@@ -339,140 +410,223 @@
339
410
 
340
411
  if (currentStep === 0) {
341
412
  head.innerHTML = `
342
- <div class="onb-tag">▸ welcome</div>
343
- <div class="onb-title">A private boardroom for you.</div>
344
- <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>
413
+ <div class="onb-tag">${escape(t("onb_v2_name_kicker"))}</div>
414
+ <div class="onb-title onb-title-serif">${escape(t("onb_v2_name_title"))}</div>
415
+ <div class="onb-deck">${escape(t("onb_v2_name_sub"))}</div>
345
416
  `;
346
417
  body.innerHTML = `
347
- <div class="onb-field">
348
- <div class="onb-field-label">What should the room call you?</div>
418
+ <div class="onb-field onb-v2-name-field">
349
419
  <div class="onb-input-wrap">
350
- <input class="onb-input" data-onb-name maxlength="32" placeholder="e.g. Kay" value="${escape(prefsCache.name || "")}" autofocus>
420
+ <input class="onb-input onb-input-serif" data-onb-name maxlength="32" placeholder="${escape(t("onb_v2_name_placeholder"))}" value="${escape(prefsCache.name || "")}" autofocus>
351
421
  </div>
352
- <div class="onb-field-hint">Used in the directors' system context. You can change it later in Preference.</div>
353
422
  </div>
354
423
  `;
355
- actions.innerHTML = `<button type="button" class="onb-btn primary" data-onb-next>[ Next ▸ ]</button>`;
424
+ actions.innerHTML = `<button type="button" class="onb-btn primary" data-onb-next>${escape(t("onb_v2_enter"))}</button>`;
356
425
  }
357
426
 
358
427
  else if (currentStep === 1) {
359
428
  head.innerHTML = `
360
- <div class="onb-tag">▸ theme</div>
361
- <div class="onb-title">Pick a palette.</div>
362
- <div class="onb-deck">Applied instantly. You can change it any time in Preference → Theme.</div>
429
+ <div class="onb-tag">${escape(t("onb_v2_what_kicker"))}</div>
430
+ <div class="onb-title onb-title-serif">${escape(t("onb_v2_what_title"))}</div>
363
431
  `;
364
- const swatches = (cs) => cs.map((c) => `<span style="background:${c}"></span>`).join("");
365
432
  body.innerHTML = `
366
- <div class="onb-theme-grid">
367
- ${THEMES.map((t) => `
368
- <a href="#" class="onb-theme${t.slug === prefsCache.theme ? " active" : ""}" data-onb-theme="${t.slug}">
369
- <span class="onb-theme-swatch">${swatches(t.swatches)}</span>
370
- <span>
371
- <span class="onb-theme-name">${escape(t.name)}</span>
372
- <div class="onb-theme-desc">${escape(t.desc)}</div>
373
- </span>
374
- <span></span>
375
- </a>
376
- `).join("")}
433
+ <div class="onb-narrative">
434
+ <p class="onb-narrative-p">${escape(t("onb_v2_what_body"))}</p>
435
+ <p class="onb-narrative-note">${escape(t("onb_v2_what_note"))}</p>
377
436
  </div>
378
437
  `;
379
- actions.innerHTML = `<button type="button" class="onb-btn primary" data-onb-next>[ Next ▸ ]</button>`;
438
+ actions.innerHTML = `<button type="button" class="onb-btn primary" data-onb-next>${escape(t("onb_v2_continue"))}</button>`;
380
439
  }
381
440
 
382
441
  else if (currentStep === 2) {
383
442
  const active = KEY_PROVIDERS.find((p) => p.slug === activeProvider) || KEY_PROVIDERS[0];
384
- const tabs = KEY_PROVIDERS.map((p) => {
443
+ const isOr = active.slug === "openrouter";
444
+
445
+ // Recommended path · OpenRouter as a value-prop card.
446
+ const orConfigured = providerConfigured.openrouter;
447
+ const recommendCard = `
448
+ <button type="button"
449
+ class="onb-key-recommend${isOr ? " active" : ""}${orConfigured ? " configured" : ""}"
450
+ data-onb-provider="openrouter">
451
+ <div class="onb-key-recommend-head">
452
+ <span class="onb-key-recommend-badge">${escape(t("onb_v2_key_recommend_badge"))}</span>
453
+ <span class="onb-key-recommend-name">${escape(t("onb_v2_key_recommend_name"))}</span>
454
+ ${orConfigured ? `<span class="onb-key-recommend-dot" title="configured">●</span>` : ""}
455
+ </div>
456
+ <div class="onb-key-recommend-body">${escape(t("onb_v2_key_recommend_body"))}</div>
457
+ </button>
458
+ `;
459
+
460
+ // Direct providers · same-model alternative.
461
+ const directProviders = KEY_PROVIDERS.filter((p) => p.slug !== "openrouter");
462
+ const directChips = directProviders.map((p) => {
385
463
  const isActive = p.slug === active.slug;
386
464
  const isConfigured = providerConfigured[p.slug];
387
465
  return `
388
466
  <button type="button"
389
- class="onb-key-tab${isActive ? " active" : ""}${isConfigured ? " configured" : ""}"
467
+ class="onb-key-direct${isActive ? " active" : ""}${isConfigured ? " configured" : ""}"
390
468
  data-onb-provider="${escape(p.slug)}">
391
- <span class="onb-key-tab-label">${escape(p.label)}</span>
392
- <span class="onb-key-tab-sub">${escape(p.sub)}</span>
393
- ${isConfigured ? `<span class="onb-key-tab-dot" title="configured">●</span>` : ""}
469
+ <span class="onb-key-direct-label">${escape(p.label)}</span>
470
+ ${isConfigured ? `<span class="onb-key-direct-dot" title="configured">●</span>` : ""}
394
471
  </button>
395
472
  `;
396
473
  }).join("");
397
474
 
398
- const inputValue = ""; // never echo back the saved key
399
475
  const status = providerConfigured[active.slug]
400
476
  ? `<div class="onb-key-status ok">● ${escape(active.label)} key configured</div>`
401
477
  : "";
402
478
 
403
479
  head.innerHTML = `
404
- <div class="onb-tag">▸ api key</div>
405
- <div class="onb-title">Bring your own key.</div>
406
- <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>
480
+ <div class="onb-tag">${escape(t("onb_v2_key_kicker"))}</div>
481
+ <div class="onb-title onb-title-serif">${escape(t("onb_v2_key_title"))}</div>
482
+ <div class="onb-deck">${escape(t("onb_v2_key_body"))}</div>
407
483
  `;
408
484
  body.innerHTML = `
409
- <div class="onb-key-tabs">${tabs}</div>
410
- <div class="onb-field">
411
- <div class="onb-field-label" data-onb-field-label>${escape(active.label)} API key</div>
412
- <div class="onb-input-wrap">
413
- <input class="onb-input" data-onb-key type="password" placeholder="${escape(active.placeholder)}" autocomplete="one-time-code" data-lpignore="true" data-1p-ignore="true" data-form-type="other" spellcheck="false" value="${escape(inputValue)}">
414
- <button type="button" class="onb-input-reveal" data-onb-reveal aria-label="Show key" aria-pressed="false">show</button>
485
+ <div class="onb-key-frame">
486
+ ${recommendCard}
487
+ <div class="onb-key-or">
488
+ <span class="onb-key-or-line"></span>
489
+ <span class="onb-key-or-text">${escape(t("onb_v2_key_or"))}</span>
490
+ <span class="onb-key-or-line"></span>
415
491
  </div>
416
- ${status}
417
- <div class="onb-field-hint">
418
- Don't have one? <a href="${escape(active.helpUrl)}" target="_blank" rel="noopener" data-onb-help-link>Generate at ${escape(active.help)} →</a>
419
- <br>
420
- You can add or change provider keys later in Preferences.
492
+ <div class="onb-key-or-body">${escape(t("onb_v2_key_or_body"))}</div>
493
+ <div class="onb-key-directs">${directChips}</div>
494
+ <div class="onb-field">
495
+ <div class="onb-field-label" data-onb-field-label>${escape(active.label)} API key</div>
496
+ <div class="onb-input-wrap">
497
+ <input class="onb-input" data-onb-key type="password" placeholder="${escape(active.placeholder)}" autocomplete="one-time-code" data-lpignore="true" data-1p-ignore="true" data-form-type="other" spellcheck="false" value="">
498
+ <button type="button" class="onb-input-reveal" data-onb-reveal aria-label="Show key" aria-pressed="false">show</button>
499
+ </div>
500
+ ${status}
501
+ <div class="onb-field-hint">
502
+ <a href="${escape(active.helpUrl)}" target="_blank" rel="noopener" data-onb-help-link>${escape(active.help)} →</a>
503
+ </div>
421
504
  </div>
422
505
  </div>
423
506
  `;
424
507
  const enableNext = anyKeyConfigured();
425
- actions.innerHTML = `<button type="button" class="onb-btn primary" data-onb-next ${enableNext ? "" : "disabled"}>[ Next ▸ ]</button>`;
508
+ actions.innerHTML = `<button type="button" class="onb-btn primary" data-onb-next ${enableNext ? "" : "disabled"}>${escape(t("onb_v2_continue"))}</button>`;
426
509
  }
427
510
 
428
511
  else if (currentStep === 3) {
512
+ // Voice / TTS · OPTIONAL. Skippable for users who want to stay
513
+ // text-only. Reuses the `<template id="vonb-themes">` voice-room
514
+ // preview (also used by the marketing voice-onboarding overlay)
515
+ // for the banner image — the styles ship in voice-onboarding.css
516
+ // scoped under `.vonb-banner`, which we mirror here.
517
+ const activeVoice = VOICE_PROVIDERS.find((p) => p.slug === activeVoiceProvider) || VOICE_PROVIDERS[0];
518
+
519
+ const voiceChips = VOICE_PROVIDERS.map((p) => {
520
+ const isActive = p.slug === activeVoice.slug;
521
+ const isConfigured = voiceProviderConfigured[p.slug];
522
+ return `
523
+ <button type="button"
524
+ class="onb-key-direct${isActive ? " active" : ""}${isConfigured ? " configured" : ""}"
525
+ data-onb-voice-provider="${escape(p.slug)}">
526
+ <span class="onb-key-direct-label">${escape(p.label)}</span>
527
+ ${isConfigured ? `<span class="onb-key-direct-dot" title="configured">●</span>` : ""}
528
+ </button>
529
+ `;
530
+ }).join("");
531
+
532
+ const voiceStatus = voiceProviderConfigured[activeVoice.slug]
533
+ ? `<div class="onb-key-status ok">● ${escape(activeVoice.label)} key configured</div>`
534
+ : "";
535
+
429
536
  head.innerHTML = `
430
- <div class="onb-tag">▸ done · pick a starting question</div>
431
- <div class="onb-title">${prefsCache.name ? escape(prefsCache.name) + ", y" : "Y"}our boardroom is open.</div>
432
- <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>
537
+ <div class="onb-tag">${escape(t("onb_v2_voice_kicker"))}</div>
538
+ <div class="onb-title onb-title-serif">${escape(t("onb_v2_voice_title"))}</div>
539
+ <div class="onb-deck">${escape(t("onb_v2_voice_body"))}</div>
433
540
  `;
434
- const cards = STARTER_QUESTIONS.map((q, idx) => {
435
- const cast = (q.agents || []).map((slug) => `
436
- <span class="onb-starter-agent" title="${escape(NAMES[slug] || slug)}">
437
- <img class="onb-starter-av" src="avatars/${escape(slug)}.svg" alt="${escape(NAMES[slug] || slug)}">
438
- <span class="onb-starter-name">${escape(NAMES[slug] || slug)}</span>
439
- </span>
440
- `).join("");
441
- return `
442
- <div class="onb-starter" data-onb-action="starter" data-onb-starter-idx="${idx}">
443
- <div class="onb-starter-tag">${escape(q.tag)}</div>
444
- <div class="onb-starter-main">
445
- <div class="onb-starter-text">${escape(q.text)}</div>
446
- <div class="onb-starter-hint">${escape(q.hint)}</div>
447
- <div class="onb-starter-meta">
448
- <span class="meta-tag tag-tone"><span class="k">tone</span><span class="v">${escape(q.tone)}</span></span>
449
- <span class="meta-tag tag-intensity"><span class="k">intensity</span><span class="v">${escape(q.intensity)}</span></span>
450
- </div>
541
+ body.innerHTML = `
542
+ <div class="onb-voice">
543
+ <div class="vonb-banner onb-voice-banner" data-onb-voice-banner></div>
544
+ <p class="onb-voice-pitch">${escape(t("onb_v2_voice_pitch"))}</p>
545
+ <div class="onb-key-directs onb-voice-providers">${voiceChips}</div>
546
+ <div class="onb-field onb-voice-field">
547
+ <div class="onb-field-label" data-onb-voice-field-label>${escape(activeVoice.label)} API key</div>
548
+ <div class="onb-input-wrap">
549
+ <input class="onb-input" data-onb-voice-key type="password" placeholder="${escape(activeVoice.placeholder)}" autocomplete="one-time-code" data-lpignore="true" data-1p-ignore="true" data-form-type="other" spellcheck="false" value="">
550
+ <button type="button" class="onb-input-reveal" data-onb-voice-reveal aria-label="Show key" aria-pressed="false">show</button>
551
+ </div>
552
+ ${voiceStatus}
553
+ <div class="onb-field-hint">
554
+ <a href="${escape(activeVoice.helpUrl)}" target="_blank" rel="noopener" data-onb-voice-help-link>${escape(activeVoice.help)} →</a>
451
555
  </div>
452
- <!-- data-no-agent-overlay · these avatars are decorative
453
- cast indicators, not profile triggers. agent-overlay.js
454
- honours this attribute on autotag + click. -->
455
- <div class="onb-starter-cast" data-no-agent-overlay>${cast}</div>
456
- <button type="button" class="onb-starter-start" data-onb-action="starter" data-onb-starter-idx="${idx}">
457
- <span class="onb-starter-start-arrow">▶</span>
458
- <span class="onb-starter-start-label">Start</span>
459
- </button>
460
556
  </div>
461
- `;
462
- }).join("");
557
+ </div>
558
+ `;
559
+ // Mount the room preview by cloning the chosen theme from the
560
+ // shared template. Cycle the theme on each fresh render so a
561
+ // user who Back-and-forwards sees variety.
562
+ mountVoiceBanner();
563
+ // Skip is always available; Continue mirrors it for users who
564
+ // typed a key (style-emphasises completion). Both advance to
565
+ // the cast step.
566
+ const hasVoice = anyVoiceKeyConfigured();
567
+ actions.innerHTML = `
568
+ <button type="button" class="onb-btn" data-onb-action="voice-skip">${escape(t("onb_v2_voice_skip"))}</button>
569
+ <button type="button" class="onb-btn primary" data-onb-action="voice-continue">${escape(hasVoice ? t("onb_v2_continue") : t("onb_v2_voice_skip_cta"))}</button>
570
+ `;
571
+ }
572
+
573
+ else if (currentStep === 4) {
574
+ head.innerHTML = `
575
+ <div class="onb-tag">${escape(t("onb_v2_cast_kicker"))}</div>
576
+ <div class="onb-title onb-title-serif">${escape(t("onb_v2_cast_title"))}</div>
577
+ <div class="onb-deck">${escape(t("onb_v2_cast_body"))}</div>
578
+ `;
579
+ const cells = CAST_PREVIEW.map((c) => `
580
+ <span class="onb-cast-cell" title="${escape(c.name)} · ${escape(c.role)}">
581
+ <img class="onb-cast-av" src="avatars/${escape(c.slug)}.svg" alt="${escape(c.name)}">
582
+ <span class="onb-cast-name">${escape(c.name)}</span>
583
+ <span class="onb-cast-role">${escape(c.role)}</span>
584
+ </span>
585
+ `).join("");
586
+ // Three-stage microflow connecting "cast preview" → what the
587
+ // user does next at the composer. Disambiguates the otherwise-
588
+ // floating CTA: pressing "I'm ready" lands them on an empty
589
+ // composer where these three steps actually happen.
590
+ const nextSteps = [1, 2, 3].map((n) => `
591
+ <div class="onb-cast-next-step">
592
+ <span class="onb-cast-next-num">0${n}</span>
593
+ <span class="onb-cast-next-label">${escape(t("onb_v2_cast_next_step_" + n))}</span>
594
+ </div>
595
+ `).join(`<span class="onb-cast-next-arrow" aria-hidden="true">→</span>`);
463
596
  body.innerHTML = `
464
- <div class="onb-starters">${cards}</div>
465
- <div class="onb-final-divider"><span>or</span></div>
466
- <div class="onb-final">
467
- <button type="button" class="onb-final-card" data-onb-action="convene">
468
- <div class="onb-final-mark">▸ CONVENE</div>
469
- <div class="onb-final-title">Convene your own</div>
470
- <div class="onb-final-deck">Pick directors, write your own question, start the room.</div>
471
- </button>
597
+ <div class="onb-cast">
598
+ <div class="onb-cast-kicker">${escape(t("onb_v2_cast_lineup"))}</div>
599
+ <div class="onb-cast-grid" data-no-agent-overlay>${cells}</div>
600
+ <div class="onb-cast-next">
601
+ <div class="onb-cast-next-kicker">${escape(t("onb_v2_cast_next_kicker"))}</div>
602
+ <div class="onb-cast-next-flow">${nextSteps}</div>
603
+ </div>
472
604
  </div>
473
605
  `;
474
- actions.innerHTML = `<button type="button" class="onb-btn" data-onb-action="skip">[ I'll explore on my own ]</button>`;
606
+ actions.innerHTML = `<button type="button" class="onb-btn primary" data-onb-action="finish">${escape(t("onb_v2_ready"))}</button>`;
607
+ }
608
+ }
609
+
610
+ /** Clone the chosen voice-room preview from the shared
611
+ * `<template id="vonb-themes">` into the step-3 banner slot. If
612
+ * the template isn't present (e.g. running outside index.html),
613
+ * fall back to a quiet placeholder so the step still renders. */
614
+ function mountVoiceBanner() {
615
+ const slot = overlay && overlay.querySelector("[data-onb-voice-banner]");
616
+ if (!slot) return;
617
+ const tpl = document.getElementById("vonb-themes");
618
+ if (!tpl || !tpl.content) {
619
+ slot.innerHTML = `<div class="onb-voice-banner-fallback"></div>`;
620
+ return;
475
621
  }
622
+ const cards = Array.from(tpl.content.querySelectorAll(".voice-room-preview"));
623
+ if (cards.length === 0) {
624
+ slot.innerHTML = `<div class="onb-voice-banner-fallback"></div>`;
625
+ return;
626
+ }
627
+ const match = cards.find((c) => c.getAttribute("data-preview-theme") === voicePreviewTheme);
628
+ const card = (match || cards[0]).cloneNode(true);
629
+ slot.replaceChildren(card);
476
630
  }
477
631
 
478
632
  // ── Actions ────────────────────────────────────────────
@@ -481,12 +635,10 @@
481
635
  const v = overlay.querySelector("[data-onb-name]").value;
482
636
  void saveName(v);
483
637
  } else if (currentStep === 2) {
484
- // Gated · the user must have AT LEAST ONE provider key
485
- // configured (any of OpenRouter / OpenAI / Google) before
486
- // they can proceed to the starter screen.
638
+ // Gated · MUST have at least one provider key configured to advance.
487
639
  if (!anyKeyConfigured()) return;
488
640
  }
489
- currentStep = Math.min(currentStep + 1, 3);
641
+ currentStep = Math.min(currentStep + 1, STEP_COUNT - 1);
490
642
  renderStep();
491
643
  }
492
644
  function back() {
@@ -494,10 +646,26 @@
494
646
  renderStep();
495
647
  }
496
648
 
497
- /** Switch the active provider tab on step 2. Inline DOM update —
498
- * NOT a full re-render so the single input element survives the
499
- * switch and whatever the user has typed stays in the field. The
500
- * tabs above are just a label declaring what the input is for. */
649
+ /** Paint the "configured" green dot on whatever element represents
650
+ * `slug` on step 2. The recommended OpenRouter card uses
651
+ * `.onb-key-recommend-dot` (anchored inside the head row); direct
652
+ * provider chips use `.onb-key-direct-dot` (trailing the label).
653
+ * Abstracts the structural difference so the single-provider
654
+ * invariant + initial render share one helper. */
655
+ function paintProviderConfigured(slug, isConfigured) {
656
+ const el = overlay && overlay.querySelector(`[data-onb-provider="${slug}"]`);
657
+ if (!el) return;
658
+ el.classList.toggle("configured", isConfigured);
659
+ el.querySelectorAll(".onb-key-recommend-dot, .onb-key-direct-dot").forEach((d) => d.remove());
660
+ if (!isConfigured) return;
661
+ if (el.classList.contains("onb-key-recommend")) {
662
+ const head = el.querySelector(".onb-key-recommend-head") || el;
663
+ head.insertAdjacentHTML("beforeend", `<span class="onb-key-recommend-dot" title="configured">●</span>`);
664
+ } else {
665
+ el.insertAdjacentHTML("beforeend", `<span class="onb-key-direct-dot" title="configured">●</span>`);
666
+ }
667
+ }
668
+
501
669
  function selectProvider(slug) {
502
670
  if (!(slug in providerConfigured)) return;
503
671
  if (activeProvider === slug) return;
@@ -505,7 +673,10 @@
505
673
 
506
674
  const active = KEY_PROVIDERS.find((p) => p.slug === slug) || KEY_PROVIDERS[0];
507
675
 
508
- overlay.querySelectorAll(".onb-key-tab").forEach((el) => {
676
+ // Toggle active on every provider-bearing element (the recommend
677
+ // card + the direct chips share the same data-attr so the same
678
+ // query covers both).
679
+ overlay.querySelectorAll("[data-onb-provider]").forEach((el) => {
509
680
  el.classList.toggle("active", el.getAttribute("data-onb-provider") === slug);
510
681
  });
511
682
 
@@ -518,10 +689,9 @@
518
689
  const helpLink = overlay.querySelector("[data-onb-help-link]");
519
690
  if (helpLink) {
520
691
  helpLink.setAttribute("href", active.helpUrl);
521
- helpLink.textContent = `Generate at ${active.help} →`;
692
+ helpLink.textContent = `${active.help} →`;
522
693
  }
523
694
 
524
- // Status pill reflects the now-active provider's saved state.
525
695
  const wrap = input ? input.closest(".onb-field") : null;
526
696
  const existing = wrap ? wrap.querySelector(".onb-key-status") : null;
527
697
  if (existing) existing.remove();
@@ -542,18 +712,11 @@
542
712
  const ok = await saveProviderKey(provider, value);
543
713
  const fresh = overlay.querySelector(".onb-key-status, [class^=onb-key-status]");
544
714
  if (ok) {
545
- // Single-provider invariant · the onboarding step has one input;
546
- // the tabs above only declare which provider that input is for.
547
- // After a successful save, retire any other provider keys so the
548
- // user leaves onboarding with exactly one configured carrier.
715
+ // Single-provider invariant · retire other configured providers.
549
716
  for (const slug of Object.keys(providerConfigured)) {
550
717
  if (slug === provider || !providerConfigured[slug]) continue;
551
718
  await deleteProviderKey(slug);
552
- const otherTab = overlay.querySelector(`.onb-key-tab[data-onb-provider="${slug}"]`);
553
- if (otherTab) {
554
- otherTab.classList.remove("configured");
555
- otherTab.querySelector(".onb-key-tab-dot")?.remove();
556
- }
719
+ paintProviderConfigured(slug, false);
557
720
  }
558
721
  if (fresh) fresh.outerHTML = `<div class="onb-key-status ok">● ${escape(label)} key configured</div>`;
559
722
  else {
@@ -563,13 +726,7 @@
563
726
  `<div class="onb-key-status ok">● ${escape(label)} key configured</div>`,
564
727
  );
565
728
  }
566
- // Update the matching tab's "configured" state inline rather
567
- // than re-rendering the whole step (would steal input focus).
568
- const tab = overlay.querySelector(`.onb-key-tab[data-onb-provider="${provider}"]`);
569
- if (tab && !tab.classList.contains("configured")) {
570
- tab.classList.add("configured");
571
- tab.insertAdjacentHTML("beforeend", `<span class="onb-key-tab-dot" title="configured">●</span>`);
572
- }
729
+ paintProviderConfigured(provider, true);
573
730
  if (nextBtn) nextBtn.disabled = false;
574
731
  } else {
575
732
  if (fresh) fresh.outerHTML = `<div class="onb-key-status error">✗ not saved</div>`;
@@ -577,24 +734,181 @@
577
734
  }
578
735
  }
579
736
 
580
- function complete(then) {
737
+ /** Toggle the active voice chip (MiniMax · ElevenLabs) on step 3
738
+ * and swap the input affordances (label / placeholder / help link
739
+ * / status pill) without re-rendering the step. Mirrors
740
+ * selectProvider() but for the voice-only chip group. */
741
+ function selectVoiceProvider(slug) {
742
+ if (!(slug in voiceProviderConfigured)) return;
743
+ if (activeVoiceProvider === slug) return;
744
+ activeVoiceProvider = slug;
745
+ const active = VOICE_PROVIDERS.find((p) => p.slug === slug) || VOICE_PROVIDERS[0];
746
+
747
+ overlay.querySelectorAll("[data-onb-voice-provider]").forEach((el) => {
748
+ el.classList.toggle("active", el.getAttribute("data-onb-voice-provider") === slug);
749
+ });
750
+
751
+ const fieldLabel = overlay.querySelector("[data-onb-voice-field-label]");
752
+ if (fieldLabel) fieldLabel.textContent = `${active.label} API key`;
753
+
754
+ const input = overlay.querySelector("[data-onb-voice-key]");
755
+ if (input) input.setAttribute("placeholder", active.placeholder);
756
+
757
+ const helpLink = overlay.querySelector("[data-onb-voice-help-link]");
758
+ if (helpLink) {
759
+ helpLink.setAttribute("href", active.helpUrl);
760
+ helpLink.textContent = `${active.help} →`;
761
+ }
762
+
763
+ const wrap = input ? input.closest(".onb-field") : null;
764
+ const existing = wrap ? wrap.querySelector(".onb-key-status") : null;
765
+ if (existing) existing.remove();
766
+ if (voiceProviderConfigured[slug] && wrap) {
767
+ wrap.querySelector(".onb-input-wrap").insertAdjacentHTML(
768
+ "afterend",
769
+ `<div class="onb-key-status ok">● ${escape(active.label)} key configured</div>`,
770
+ );
771
+ }
772
+ }
773
+
774
+ /** Paint the "configured" green dot on a voice-provider chip. Voice
775
+ * chips are always `.onb-key-direct` so this is simpler than the
776
+ * LLM equivalent. */
777
+ function paintVoiceProviderConfigured(slug, isConfigured) {
778
+ const el = overlay && overlay.querySelector(`[data-onb-voice-provider="${slug}"]`);
779
+ if (!el) return;
780
+ el.classList.toggle("configured", isConfigured);
781
+ el.querySelectorAll(".onb-key-direct-dot").forEach((d) => d.remove());
782
+ if (isConfigured) {
783
+ el.insertAdjacentHTML("beforeend", `<span class="onb-key-direct-dot" title="configured">●</span>`);
784
+ }
785
+ }
786
+
787
+ async function trySaveVoiceKey(value) {
788
+ const provider = activeVoiceProvider;
789
+ const label = (VOICE_PROVIDERS.find((p) => p.slug === provider) || {}).label || provider;
790
+ const status = overlay.querySelector("[data-onb-voice-key]")?.closest(".onb-field")?.querySelector(".onb-key-status");
791
+ if (status) status.outerHTML = `<div class="onb-key-status warn">○ checking…</div>`;
792
+ const ok = await saveVoiceKey(provider, value);
793
+ const wrap = overlay.querySelector("[data-onb-voice-key]")?.closest(".onb-field");
794
+ const fresh = wrap && wrap.querySelector(".onb-key-status");
795
+ if (ok) {
796
+ if (fresh) fresh.outerHTML = `<div class="onb-key-status ok">● ${escape(label)} key configured</div>`;
797
+ else if (wrap) wrap.querySelector(".onb-input-wrap").insertAdjacentHTML(
798
+ "afterend",
799
+ `<div class="onb-key-status ok">● ${escape(label)} key configured</div>`,
800
+ );
801
+ paintVoiceProviderConfigured(provider, true);
802
+ // Update the continue button label · "Skip for now" → "Continue".
803
+ const cont = overlay.querySelector("[data-onb-action='voice-continue']");
804
+ if (cont) cont.textContent = t("onb_v2_continue");
805
+ } else {
806
+ if (fresh) fresh.outerHTML = `<div class="onb-key-status error">✗ not saved</div>`;
807
+ }
808
+ }
809
+
810
+ /** rAF with a setTimeout fallback for environments without
811
+ * requestAnimationFrame (test harnesses, very old browsers). */
812
+ const raf = (cb) => (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function")
813
+ ? window.requestAnimationFrame(cb)
814
+ : setTimeout(cb, 16);
815
+
816
+ /** Mount the once-only composer-hint tooltip. Polls briefly for the
817
+ * composer textarea since app.js renders it after onboarding closes,
818
+ * on the next animation frame. Bails after ~3 seconds if it never
819
+ * appears (e.g., user navigated elsewhere). */
820
+ function installFirstComposerHint() {
821
+ try {
822
+ if (localStorage.getItem(FIRST_HINT_KEY) === "seen") return;
823
+ } catch (e) { /* */ }
824
+
825
+ const deadline = Date.now() + 3000;
826
+ const tryMount = () => {
827
+ const ta = document.querySelector("[data-composer-subject]");
828
+ if (!ta) {
829
+ if (Date.now() > deadline) return;
830
+ raf(tryMount);
831
+ return;
832
+ }
833
+
834
+ const hint = document.createElement("div");
835
+ hint.className = "onb-composer-hint";
836
+ hint.setAttribute("role", "tooltip");
837
+ hint.innerHTML = `
838
+ <div class="onb-composer-hint-arrow"></div>
839
+ <div class="onb-composer-hint-kicker">${escape(t("onb_v2_hint_kicker"))}</div>
840
+ <div class="onb-composer-hint-body">${escape(t("onb_v2_hint_body"))}</div>
841
+ <button type="button" class="onb-composer-hint-dismiss" data-onb-hint-dismiss>${escape(t("onb_v2_hint_dismiss"))}</button>
842
+ `;
843
+ document.body.appendChild(hint);
844
+
845
+ const position = () => {
846
+ const rect = ta.getBoundingClientRect();
847
+ // Anchor below the textarea, horizontally centered to it.
848
+ // viewportClamped — keep within 12px of edges.
849
+ const hintRect = hint.getBoundingClientRect();
850
+ const targetCenter = rect.left + rect.width / 2;
851
+ const half = hintRect.width / 2;
852
+ const minLeft = 12;
853
+ const maxLeft = window.innerWidth - hintRect.width - 12;
854
+ const left = Math.max(minLeft, Math.min(maxLeft, targetCenter - half));
855
+ const top = rect.bottom + 14;
856
+ hint.style.left = `${Math.round(left)}px`;
857
+ hint.style.top = `${Math.round(top)}px`;
858
+ // Arrow horizontal offset relative to hint, so it stays pointed
859
+ // at the textarea even when the hint is clamped.
860
+ const arrowLeft = targetCenter - left;
861
+ const arrow = hint.querySelector(".onb-composer-hint-arrow");
862
+ if (arrow) arrow.style.left = `${Math.round(arrowLeft)}px`;
863
+ };
864
+
865
+ // Two-pass: paint once for size, then position.
866
+ raf(() => {
867
+ position();
868
+ hint.classList.add("open");
869
+ });
870
+
871
+ let dismissed = false;
872
+ const dismiss = () => {
873
+ if (dismissed) return;
874
+ dismissed = true;
875
+ try { localStorage.setItem(FIRST_HINT_KEY, "seen"); } catch (e) {}
876
+ hint.classList.remove("open");
877
+ window.removeEventListener("resize", position);
878
+ window.removeEventListener("scroll", position, true);
879
+ document.removeEventListener("click", onAnyClick, true);
880
+ document.removeEventListener("keydown", onAnyKey, true);
881
+ setTimeout(() => { hint.remove(); }, 200);
882
+ };
883
+ const onAnyClick = (e) => {
884
+ // Let the dismiss button work normally; otherwise any click
885
+ // anywhere dismisses (including typing into the composer).
886
+ dismiss();
887
+ };
888
+ const onAnyKey = () => dismiss();
889
+
890
+ window.addEventListener("resize", position);
891
+ window.addEventListener("scroll", position, true);
892
+ // Slight delay so the click that closed the onboarding modal
893
+ // doesn't immediately register as "click anywhere".
894
+ setTimeout(() => {
895
+ document.addEventListener("click", onAnyClick, true);
896
+ document.addEventListener("keydown", onAnyKey, true);
897
+ }, 120);
898
+ };
899
+ raf(tryMount);
900
+ }
901
+
902
+ function complete() {
581
903
  try { localStorage.setItem(ONBOARDED_KEY, "true"); } catch (e) {}
582
904
  document.body.classList.remove("onb-locked");
583
905
  overlay.classList.remove("open");
584
906
  setTimeout(() => {
585
- overlay.remove();
907
+ if (overlay) overlay.remove();
586
908
  overlay = null;
587
909
  }, 220);
588
- // Sync every client-side cache the server just mutated. The PUT
589
- // /api/keys/:provider call with `makeDefault:true` did three
590
- // server-side writes: the key row, prefs.defaultModelV (flipped
591
- // to the new carrier's primary), and every agent's modelV (via
592
- // reconcile). Each of those has its own client cache · refresh
593
- // them all so user-settings, sidebar, and pickers reflect the
594
- // new state immediately, no full reload needed. Wait on all
595
- // three before running the continuation so any post-onboarding
596
- // action (createRoom, open convene) sees fresh state.
597
- const continuation = typeof then === "function" ? then : null;
910
+
911
+ // Sync caches the server mutated during key save.
598
912
  const refreshes = [];
599
913
  if (window.app && typeof window.app.refreshKeys === "function") {
600
914
  refreshes.push(Promise.resolve(window.app.refreshKeys()).catch(() => {}));
@@ -606,70 +920,15 @@
606
920
  refreshes.push(Promise.resolve(window.boardroomModelsRefresh()).catch(() => {}));
607
921
  }
608
922
  Promise.all(refreshes).finally(() => {
609
- if (continuation) {
610
- continuation();
611
- } else {
612
- // Default skip path · the user dismissed onboarding without
613
- // picking a starter or convene-your-own. Explicitly land
614
- // them on the new-room composer. Without this, whatever
615
- // composer mode the dashboard happened to settle into during
616
- // boot stays put — and on first-run flows that's
617
- // occasionally "agent" instead of "room", since the order
618
- // of restore() / app.init() / refreshAgents isn't strictly
619
- // guaranteed and the agents-tab restorer can win the race.
620
- if (window.app && typeof window.app.setComposerMode === "function") {
621
- window.app.setComposerMode("room");
622
- }
923
+ // Pin composer mode to "room" so we don't land on agent composer
924
+ // after a boot-race with refreshAgents.
925
+ if (window.app && typeof window.app.setComposerMode === "function") {
926
+ try { window.app.setComposerMode("room"); } catch (e) { /* */ }
623
927
  }
928
+ installFirstComposerHint();
624
929
  });
625
930
  }
626
931
 
627
- async function createDemoRoom(spec) {
628
- if (!window.app || typeof window.app.createRoom !== "function") {
629
- // app hasn't booted yet — wait briefly then retry once.
630
- await new Promise((r) => setTimeout(r, 200));
631
- }
632
- // Accept either a full starter spec object (preferred), a plain subject
633
- // string (legacy), or nothing (default to the first starter).
634
- let s = spec;
635
- if (!s) s = STARTER_QUESTIONS[0];
636
- else if (typeof s === "string") {
637
- const text = s.trim();
638
- s = STARTER_QUESTIONS.find((q) => q.text === text) || { text };
639
- }
640
- try {
641
- await window.app.createRoom({
642
- subject: s.text,
643
- agentIds: s.agents && s.agents.length >= 2 ? s.agents : DEMO_AGENTS,
644
- mode: s.tone || "constructive",
645
- intensity: s.intensity || "sharp",
646
- briefStyle: s.briefStyle || "auto",
647
- });
648
- } catch (e) {
649
- alert("Couldn't create a starter room: " + (e && e.message ? e.message : e));
650
- }
651
- }
652
-
653
- function openConveneAfter() {
654
- setTimeout(() => {
655
- // Convene-overlay was retired in favour of the inline composer.
656
- // Use setComposerMode("room") (not closeRoom) so we explicitly
657
- // pin the new-room composer · closeRoom inherits whatever
658
- // composerMode is set, and during a boot race that flag can be
659
- // "agent", which would land the user on the new-agent composer
660
- // instead of the new-room one.
661
- try {
662
- if (window.app && typeof window.app.setComposerMode === "function") {
663
- window.app.setComposerMode("room");
664
- } else if (window.app && typeof window.app.closeRoom === "function") {
665
- window.app.closeRoom();
666
- } else if (typeof window.openConveneOverlay === "function") {
667
- window.openConveneOverlay();
668
- }
669
- } catch { /* ignore */ }
670
- }, 250);
671
- }
672
-
673
932
  // ── Wiring ─────────────────────────────────────────────
674
933
  function wire() {
675
934
  overlay.addEventListener("click", async (e) => {
@@ -685,15 +944,6 @@
685
944
  back();
686
945
  return;
687
946
  }
688
- const themeChoice = t.closest("[data-onb-theme]");
689
- if (themeChoice) {
690
- e.preventDefault();
691
- const slug = themeChoice.getAttribute("data-onb-theme");
692
- overlay.querySelectorAll(".onb-theme").forEach((el) => el.classList.remove("active"));
693
- themeChoice.classList.add("active");
694
- void saveTheme(slug);
695
- return;
696
- }
697
947
  const providerChoice = t.closest("[data-onb-provider]");
698
948
  if (providerChoice) {
699
949
  e.preventDefault();
@@ -701,6 +951,13 @@
701
951
  if (slug) selectProvider(slug);
702
952
  return;
703
953
  }
954
+ const voiceChoice = t.closest("[data-onb-voice-provider]");
955
+ if (voiceChoice) {
956
+ e.preventDefault();
957
+ const slug = voiceChoice.getAttribute("data-onb-voice-provider");
958
+ if (slug) selectVoiceProvider(slug);
959
+ return;
960
+ }
704
961
  const reveal = t.closest("[data-onb-reveal]");
705
962
  if (reveal) {
706
963
  e.preventDefault();
@@ -713,27 +970,37 @@
713
970
  reveal.textContent = showing ? "show" : "hide";
714
971
  return;
715
972
  }
973
+ const voiceReveal = t.closest("[data-onb-voice-reveal]");
974
+ if (voiceReveal) {
975
+ e.preventDefault();
976
+ const input = overlay.querySelector("[data-onb-voice-key]");
977
+ if (!input) return;
978
+ const showing = input.getAttribute("type") === "text";
979
+ input.setAttribute("type", showing ? "password" : "text");
980
+ voiceReveal.setAttribute("aria-pressed", showing ? "false" : "true");
981
+ voiceReveal.setAttribute("aria-label", showing ? "Show key" : "Hide key");
982
+ voiceReveal.textContent = showing ? "show" : "hide";
983
+ return;
984
+ }
716
985
  const action = t.closest("[data-onb-action]");
717
986
  if (action) {
718
987
  e.preventDefault();
719
988
  e.stopPropagation();
720
989
  const a = action.getAttribute("data-onb-action");
721
- if (a === "starter") {
722
- const idx = parseInt(action.getAttribute("data-onb-starter-idx") || "-1", 10);
723
- const spec = Number.isFinite(idx) ? STARTER_QUESTIONS[idx] : null;
724
- complete(() => { void createDemoRoom(spec); });
725
- } else if (a === "demo") {
726
- complete(() => { void createDemoRoom(); });
727
- } else if (a === "convene") {
728
- complete(() => openConveneAfter());
729
- } else if (a === "skip") {
990
+ if (a === "finish") {
730
991
  complete();
992
+ } else if (a === "voice-skip" || a === "voice-continue") {
993
+ // Both buttons advance — Skip is the soft path, Continue is
994
+ // styled as primary once a key landed. The user's typed-but-
995
+ // unsaved key is discarded by design (the debounce already
996
+ // saved any complete key); we don't force-finalize on advance.
997
+ currentStep = Math.min(currentStep + 1, STEP_COUNT - 1);
998
+ renderStep();
731
999
  }
732
1000
  return;
733
1001
  }
734
1002
  });
735
1003
 
736
- // Live updates for the API key field — debounce, validate, save.
737
1004
  overlay.addEventListener("input", (e) => {
738
1005
  const t = e.target;
739
1006
  if (t.matches("[data-onb-name]")) {
@@ -741,10 +1008,12 @@
741
1008
  } else if (t.matches("[data-onb-key]")) {
742
1009
  clearTimeout(t.__onbTimer);
743
1010
  t.__onbTimer = setTimeout(() => trySaveKey(t.value), 280);
1011
+ } else if (t.matches("[data-onb-voice-key]")) {
1012
+ clearTimeout(t.__onbTimer);
1013
+ t.__onbTimer = setTimeout(() => trySaveVoiceKey(t.value), 280);
744
1014
  }
745
1015
  });
746
1016
 
747
- // Enter on name → next
748
1017
  overlay.addEventListener("keydown", (e) => {
749
1018
  if (e.key !== "Enter") return;
750
1019
  if (e.target.matches("[data-onb-name]")) {
@@ -756,18 +1025,23 @@
756
1025
 
757
1026
  function show() {
758
1027
  if (document.getElementById("onb-overlay")) return;
759
- // Claim the dashboard sub-state · the dashboard page runs a
760
- // restore tick (~2.5s of 250ms retries) that re-opens whatever
761
- // agent profile the user last viewed once refreshAgents mounts the
762
- // sidebar rows. Onboarding always lands the user on a fresh room
763
- // (or the composer), so claim "rooms" + clear the saved agent id
764
- // up-front. Without this, finishing onboarding fast enough races
765
- // the tick and the user occasionally lands on a stale agent
766
- // profile instead of the room they just convened.
1028
+ // Claim the dashboard sub-state so the restore tick doesn't race
1029
+ // refreshAgents and land the user on a stale agent profile.
1030
+ // (See feedback_substate_restore_race.)
767
1031
  try {
768
1032
  localStorage.setItem("boardroom.sidebar.tab", "rooms");
769
1033
  localStorage.setItem("boardroom.sidebar.agents", "new");
770
1034
  } catch (e) {}
1035
+ // Cycle the step-3 voice-room preview theme each open so replay
1036
+ // feels alive. Persists across sessions through localStorage.
1037
+ try {
1038
+ const raw = localStorage.getItem("boardroom.onb.voiceThemeIdx");
1039
+ const idx = raw == null ? 0 : (parseInt(raw, 10) || 0);
1040
+ const n = VOICE_PREVIEW_THEMES.length;
1041
+ const cur = ((idx % n) + n) % n;
1042
+ voicePreviewTheme = VOICE_PREVIEW_THEMES[cur] || "regent";
1043
+ localStorage.setItem("boardroom.onb.voiceThemeIdx", String((cur + 1) % n));
1044
+ } catch (e) { voicePreviewTheme = "regent"; }
771
1045
  const wrap = document.createElement("div");
772
1046
  wrap.innerHTML = modalHTML().trim();
773
1047
  document.body.appendChild(wrap.firstChild);
@@ -777,8 +1051,6 @@
777
1051
  renderStep();
778
1052
  wire();
779
1053
 
780
- // First-run: for the user-settings.js bootstrap, suppress ⚙ click
781
- // while overlay is open by capture-phase guard.
782
1054
  document.addEventListener("click", guardSettingsTrigger, true);
783
1055
  }
784
1056
 
@@ -805,5 +1077,24 @@
805
1077
  init();
806
1078
  }
807
1079
 
1080
+ /** Replay entry point · used by user-settings "replay onboarding"
1081
+ * row for users who already onboarded once and want to revisit
1082
+ * the story. Resets per-run state (currentStep, provider cache)
1083
+ * and the once-only composer-hint flag so the full flow plays
1084
+ * again. Does NOT clear the user's saved name / theme / keys —
1085
+ * those are surfaced by loadInitial() and rendered as-is, so the
1086
+ * user sees their existing config rather than a blank slate. */
1087
+ async function replay() {
1088
+ try { localStorage.removeItem(FIRST_HINT_KEY); } catch (e) {}
1089
+ currentStep = 0;
1090
+ // Reload prefs + keys so step 2 reflects the user's CURRENT
1091
+ // provider state (they may have added or removed keys since the
1092
+ // first run).
1093
+ try { await loadInitial(); } catch (e) {}
1094
+ if (document.getElementById("onb-overlay")) return;
1095
+ show();
1096
+ }
1097
+
808
1098
  window.boardroomShowOnboarding = show; // exposed for testing
1099
+ window.boardroomReplayOnboarding = replay;
809
1100
  })();