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.
- package/dist/cli.js +2623 -333
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/public/adjourn-overlay.css +6 -6
- package/public/agent-build-bgm.js +292 -0
- package/public/agent-overlay.css +14 -14
- package/public/agent-profile.css +408 -87
- package/public/agent-profile.js +254 -0
- package/public/app.js +2486 -384
- package/public/home.html +26 -26
- package/public/i18n.js +1890 -21
- package/public/icons/logo2.png +0 -0
- package/public/icons/private-board-vi.html +1716 -0
- package/public/index.html +2954 -1018
- package/public/magazine.html +12 -12
- package/public/new-agent.css +29 -29
- package/public/newspaper.html +20 -20
- package/public/onboarding.css +350 -272
- package/public/onboarding.js +614 -323
- package/public/quote-cta.css +4 -4
- package/public/report.html +2008 -1673
- package/public/room-settings.css +192 -24
- package/public/room-settings.js +5 -0
- package/public/share-cover-svg-creator.js +736 -0
- package/public/themes.css +0 -34
- package/public/typing-sfx.js +176 -3
- package/public/user-settings.css +50 -27
- package/public/user-settings.js +43 -14
- package/public/voice-onboarding.css +425 -0
- package/public/voice-onboarding.js +144 -0
- package/public/voice-replay.css +31 -38
- package/public/voice-replay.js +12 -11
package/public/onboarding.js
CHANGED
|
@@ -1,58 +1,43 @@
|
|
|
1
1
|
/* ═══════════════════════════════════════════
|
|
2
|
-
ONBOARDING · first-run
|
|
2
|
+
ONBOARDING · first-run storyline (v2)
|
|
3
3
|
═══════════════════════════════════════════
|
|
4
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
{ slug: "
|
|
25
|
-
|
|
26
|
-
{ slug: "
|
|
27
|
-
|
|
28
|
-
{ slug: "
|
|
29
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
|
@@ -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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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>
|
|
301
|
-
<span class="right"
|
|
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
|
|
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"
|
|
343
|
-
<div class="onb-title"
|
|
344
|
-
<div class="onb-deck"
|
|
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="
|
|
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
|
|
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"
|
|
361
|
-
<div class="onb-title"
|
|
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-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
|
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
|
|
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-
|
|
467
|
+
class="onb-key-direct${isActive ? " active" : ""}${isConfigured ? " configured" : ""}"
|
|
390
468
|
data-onb-provider="${escape(p.slug)}">
|
|
391
|
-
<span class="onb-key-
|
|
392
|
-
|
|
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"
|
|
405
|
-
<div class="onb-title"
|
|
406
|
-
<div class="onb-deck"
|
|
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-
|
|
410
|
-
|
|
411
|
-
<div class="onb-
|
|
412
|
-
|
|
413
|
-
<
|
|
414
|
-
<
|
|
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
|
-
|
|
417
|
-
<div class="onb-
|
|
418
|
-
|
|
419
|
-
<
|
|
420
|
-
|
|
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"}
|
|
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"
|
|
431
|
-
<div class="onb-title">${
|
|
432
|
-
<div class="onb-deck"
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
<
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
<
|
|
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
|
-
|
|
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-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
<
|
|
468
|
-
<div class="onb-
|
|
469
|
-
<div class="onb-
|
|
470
|
-
|
|
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="
|
|
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 ·
|
|
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,
|
|
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
|
-
/**
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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 ·
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
589
|
-
//
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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 === "
|
|
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
|
|
760
|
-
//
|
|
761
|
-
//
|
|
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
|
})();
|