diff --git a/apps/web/e2e/bot-organization.spec.ts b/apps/web/e2e/bot-organization.spec.ts
index c32a4cd..d9176bf 100644
--- a/apps/web/e2e/bot-organization.spec.ts
+++ b/apps/web/e2e/bot-organization.spec.ts
@@ -307,6 +307,10 @@ test("group chats share every context-menu action", async ({ page }, testInfo) =
await group.click({ button: "right" });
await captureScreenshot(page, testInfo, "group-context-menu-mobile");
await page.keyboard.press("Escape");
+ if ((await page.getByRole("menu").count()) > 0) {
+ await page.keyboard.press("Escape");
+ }
+ await expect(page.getByRole("menu")).toHaveCount(0);
await page.setViewportSize({ width: 1280, height: 800 });
await group.click({ button: "right" });
diff --git a/apps/web/e2e/first-run-onboarding-ux.spec.ts b/apps/web/e2e/first-run-onboarding-ux.spec.ts
new file mode 100644
index 0000000..b0e2306
--- /dev/null
+++ b/apps/web/e2e/first-run-onboarding-ux.spec.ts
@@ -0,0 +1,87 @@
+import { expect, test } from "@playwright/test";
+import { completeOnboarding, rpc, signup } from "./helpers";
+
+test("onboarding names the first Oo and shows it in chat", async ({ page }) => {
+ const stamp = Date.now();
+ await signup(page, `name-oo-${stamp}@rakazo.test`, "password12", "Namer");
+ await page.waitForURL(/\/onboarding/, { timeout: 20_000 });
+ await page.getByRole("button", { name: /^(Continue|Weiter)$/i }).click();
+ await expect(page.getByTestId("onboarding-name-step")).toBeVisible();
+ await page.getByTestId("onboarding-oo-name-input").fill("Luna");
+ await page
+ .getByTestId("onboarding-name-step")
+ .getByRole("button", { name: /^(Continue|Weiter)$/i })
+ .click();
+ await page.getByRole("button", { name: /^(Skip|Überspringen ·)/i }).click();
+ await page.getByRole("button", { name: /^(Skip|Überspringen)$/i }).click();
+ await expect(page).toHaveURL(/\/app\/g\/[^/]+/, { timeout: 30_000 });
+ await expect(
+ page.getByTestId("bot-settings-trigger").getByText("Luna", { exact: true }),
+ ).toBeVisible();
+ expect((await rpc<{ name: string }>(page, "bots/ensureMain", {})).name).toBe("Luna");
+});
+
+test("German UI keeps Deutsch on language skip label", async ({ page }) => {
+ await page.addInitScript(() => {
+ Object.defineProperty(navigator, "language", { value: "de-DE", configurable: true });
+ localStorage.setItem("rakazo.uiLocale", "de");
+ });
+ const stamp = Date.now();
+ await signup(page, `de-skip-${stamp}@rakazo.test`, "password12", "Deutsch");
+ await page.getByRole("button", { name: /^(Continue|Weiter)$/i }).click();
+ await page.getByRole("button", { name: /^(Skip|Überspringen)$/i }).click();
+ await expect(
+ page.getByRole("heading", { name: /Wähle deine Sprache|Choose your language/i }),
+ ).toBeVisible();
+ await expect(page.getByRole("button", { name: /Überspringen · Deutsch/i })).toBeVisible();
+});
+
+test("German voice step lists German voices after choosing Deutsch", async ({ page }) => {
+ await page.addInitScript(() => {
+ Object.defineProperty(navigator, "language", { value: "de-DE", configurable: true });
+ localStorage.setItem("rakazo.uiLocale", "de");
+ });
+ const stamp = Date.now();
+ await signup(page, `de-voice-${stamp}@rakazo.test`, "password12", "Stimme");
+ await page.getByRole("button", { name: /^(Continue|Weiter)$/i }).click();
+ await page.getByRole("button", { name: /^(Skip|Überspringen)$/i }).click();
+ await page.getByRole("button", { name: "Deutsch", exact: true }).click();
+ await expect(page.getByText(/Loading voices|Stimmen werden geladen/i)).toHaveCount(0, {
+ timeout: 30_000,
+ });
+ await expect(page.getByTestId("onboarding-voice-df_eva")).toBeVisible({ timeout: 30_000 });
+ await expect(page.getByTestId("onboarding-voice-dm_bernd")).toBeVisible();
+ await expect(page.getByTestId("onboarding-voice-af_alloy")).toHaveCount(0);
+ await expect(page.getByTestId("onboarding-voice-df_eva")).toHaveAttribute("aria-pressed", "true");
+});
+
+test("main conversation does not show a group subtitle for one Oo", async ({ page }) => {
+ await page.addInitScript(() => {
+ Object.defineProperty(navigator, "language", { value: "de-DE", configurable: true });
+ localStorage.setItem("rakazo.uiLocale", "de");
+ });
+ const stamp = Date.now();
+ await signup(page, `main-1-1-${stamp}@rakazo.test`, "password12", "Solo");
+ await completeOnboarding(page, undefined, { mainConversation: true });
+ await expect(page.getByTestId("bot-settings-trigger")).not.toContainText("Gruppenunterhaltung");
+ await expect(page.getByTestId("oo-group-stage")).toContainText(/1:1/i);
+});
+
+test.describe("mobile shell", () => {
+ test.use({
+ hasTouch: true,
+ isMobile: true,
+ viewport: { width: 390, height: 844 },
+ });
+
+ test("Escape closes the mobile navigation drawer", async ({ page }) => {
+ const stamp = Date.now();
+ await signup(page, `mobile-esc-${stamp}@rakazo.test`, "password12", "Escape");
+ await completeOnboarding(page);
+ await page.getByRole("button", { name: "Open navigation" }).click();
+ await expect(page.getByRole("button", { name: "Close navigation" })).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(page.getByRole("button", { name: "Close navigation" })).toHaveCount(0);
+ await expect(page.getByTestId("open-navigation")).toBeFocused();
+ });
+});
diff --git a/apps/web/e2e/helpers.ts b/apps/web/e2e/helpers.ts
index 04661ec..b3e4036 100644
--- a/apps/web/e2e/helpers.ts
+++ b/apps/web/e2e/helpers.ts
@@ -87,6 +87,16 @@ async function visible(locator: Locator, timeout: number) {
return locator.isVisible({ timeout }).catch(() => false);
}
+const onboardingContinueButton = /^(Continue|Weiter)$/i;
+const onboardingSkipButton = /^(Skip|Überspringen)$/i;
+const onboardingLanguageSkipButton = /^(Skip|Überspringen) ·/i;
+
+/** After the look picker, skip the optional Oo naming step (keeps the default name). */
+export async function skipOnboardingNameStep(page: Page) {
+ await expect(page.getByTestId("onboarding-name-step")).toBeVisible();
+ await page.getByRole("button", { name: onboardingSkipButton }).click();
+}
+
/**
* Walks the first-run wizard, which lands in the main conversation with the main bot's first
* onboarding question pending (the composer works regardless). Unless `mainConversation` is
@@ -95,23 +105,29 @@ async function visible(locator: Locator, timeout: number) {
export async function completeOnboarding(
page: Page,
testInfo?: TestInfo,
- options: { mainConversation?: boolean } = {},
+ options: { mainConversation?: boolean; ooName?: string } = {},
) {
await page.waitForURL(/\/(onboarding|app)/, { timeout: 20_000 });
if (page.url().includes("/onboarding")) {
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
"data-onboarding-step",
- /^(emoji|language|voice|integrations|scan|bot)$/,
+ /^(emoji|name|language|voice|integrations|scan|bot)$/,
{ timeout: 30_000 },
);
}
- const design = page.getByRole("heading", { name: /Design your Oo/i });
- const language = page.getByRole("heading", { name: /Choose your language/i });
- const voice = page.getByRole("heading", { name: /Pick a voice/i });
- const integrations = page.getByRole("heading", { name: "Server integrations", exact: true });
- const chief = page.getByText("Chief").first();
+ const design = page.getByRole("heading", { name: /Design your Oo|Gestalte dein Oo/i });
+ const naming = page.getByTestId("onboarding-name-step");
+ const language = page.getByRole("heading", {
+ name: /Choose your language|Wähle deine Sprache/i,
+ });
+ const voice = page.getByRole("heading", { name: /Pick a voice|Wähle eine Stimme/i });
+ const integrations = page.getByRole("heading", {
+ name: /Server integrations|Server-Integrationen/i,
+ });
+ const expectedOoName = options.ooName ?? "Chief";
+ const chief = page.getByText(expectedOoName).first();
// Scripted CI has needsModel false, so a fresh owner starts on integrations.
// A model-gated run still opens the look picker first.
@@ -119,23 +135,30 @@ export async function completeOnboarding(
await expect(entry.first()).toBeVisible({ timeout: 20_000 });
if (await design.isVisible()) {
- await page.getByRole("button", { name: "Continue", exact: true }).click();
+ await page.getByRole("button", { name: onboardingContinueButton }).click();
+ await expect(naming).toBeVisible();
+ if (options.ooName) {
+ await page.getByTestId("onboarding-oo-name-input").fill(options.ooName);
+ await naming.getByRole("button", { name: onboardingContinueButton }).click();
+ } else {
+ await page.getByRole("button", { name: onboardingSkipButton }).click();
+ }
await expect(language).toBeVisible();
- await page.getByRole("button", { name: /Skip/i }).click();
+ await page.getByRole("button", { name: onboardingLanguageSkipButton }).click();
await expect(voice).toBeVisible();
- await page.getByRole("button", { name: "Skip", exact: true }).click();
+ await page.getByRole("button", { name: onboardingSkipButton }).click();
}
if (await visible(integrations, 8_000)) {
if (testInfo) await captureScreenshot(page, testInfo, "02-connect-apps");
- await page.getByRole("button", { name: "Skip", exact: true }).click();
+ await page.getByRole("button", { name: onboardingSkipButton }).click();
}
// The scan step, when shown, either asks first (skip it) or advances by itself. Onboarding
// then lands in the main conversation.
const skipScan = page
.locator('[data-onboarding-step="scan"]')
- .getByRole("button", { name: "Skip", exact: true });
+ .getByRole("button", { name: onboardingSkipButton });
await expect(async () => {
if (await skipScan.isVisible()) await skipScan.click();
expect(page.url()).toMatch(/\/app\/g\/[^/]+$/);
@@ -171,15 +194,17 @@ export async function signup(
) {
await expect(async () => {
await page.goto("/sign-up", { waitUntil: "domcontentloaded" });
- await expect(page.getByRole("heading", { name: "Create your evOo" })).toBeVisible({
+ await expect(
+ page.getByRole("heading", { name: /Create your evOo|Dein evOo erstellen/i }),
+ ).toBeVisible({
timeout: 5_000,
});
}).toPass({ timeout: 45_000 });
if (testInfo) await captureScreenshot(page, testInfo, "01-sign-up");
- await page.getByPlaceholder("Your name").fill(name);
- await page.getByPlaceholder("Your email address").fill(email);
- await page.getByPlaceholder("Password").fill(password);
- await page.getByRole("button", { name: "Create account" }).click();
+ await page.getByPlaceholder(/Your name|Dein Name/i).fill(name);
+ await page.getByPlaceholder(/Your email address|Deine E-Mail-Adresse/i).fill(email);
+ await page.getByPlaceholder(/^Password$|^Passwort$/i).fill(password);
+ await page.getByRole("button", { name: /Create account|Konto erstellen/i }).click();
}
export async function captureScreenshot(page: Page, testInfo: TestInfo, name: string) {
@@ -274,7 +299,7 @@ export async function waitForChatComposer(page: Page) {
await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", {
timeout: 30_000,
});
- await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
+ await expect(page.getByRole("combobox", { name: /^Message$|^Nachricht$/i })).toBeVisible();
}
/** Create a named bot via RPC for test setup (skips the + picker). */
diff --git a/apps/web/e2e/heute-brief.spec.ts b/apps/web/e2e/heute-brief.spec.ts
index 293dcc9..4caac78 100644
--- a/apps/web/e2e/heute-brief.spec.ts
+++ b/apps/web/e2e/heute-brief.spec.ts
@@ -18,7 +18,10 @@ test("Heute shows native brief instead of Mission Control iframe", async ({ page
await completeOnboarding(page);
await openHeute(page);
- await expect(page).toHaveURL(/\/app\/[^/]+$/);
+ await expect(page).toHaveURL(/\/app\/heute$/);
+ await page.reload();
+ await expect(page.getByTestId("heute-brief")).toBeVisible();
+ await expect(page).toHaveURL(/\/app\/heute$/);
const brief = page.getByTestId("heute-brief");
await expect(brief).toBeVisible();
@@ -81,12 +84,21 @@ test("Heute shows the coach's brief, habits and goals, checks habits off and set
await expect(page.getByTestId(`heute-habit-streak-${habit.id}`)).toHaveText("3 days");
await captureScreenshot(page, testInfo, "heute-coach");
- await page.reload();
- await openHeute(page);
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await expect(page).toHaveURL(/\/app\/heute$/);
+ await expect(page.getByTestId("heute-brief")).toBeVisible();
await expect(page.getByTestId(`heute-habit-${habit.id}`).getByRole("checkbox")).toBeChecked();
// Check-in times live behind one compact control.
- await page.getByTestId("heute-rhythm").click();
+ const rhythm = page.getByTestId("heute-rhythm");
+ await expect(page.getByTestId("heute-coach-brief")).toBeVisible();
+ await rhythm.scrollIntoViewIfNeeded();
+ await expect(rhythm).toBeVisible({ timeout: 30_000 });
+ const rhythmLoaded = page.waitForResponse(
+ (response) => response.url().includes("/rpc/coach/rhythm/get") && response.ok(),
+ );
+ await rhythm.click();
+ await rhythmLoaded;
const panel = page.getByTestId("heute-rhythm-panel");
await expect(panel).toBeVisible();
const morning = panel.getByTestId("heute-rhythm-morning");
diff --git a/apps/web/e2e/onboarding-conversation.spec.ts b/apps/web/e2e/onboarding-conversation.spec.ts
index 7809caa..c1d254b 100644
--- a/apps/web/e2e/onboarding-conversation.spec.ts
+++ b/apps/web/e2e/onboarding-conversation.spec.ts
@@ -11,7 +11,7 @@ test("focus choice suggests apps and preserves a completed connection", async ({
const stamp = Date.now();
await signup(page, `onboarding-${stamp}@rakazo.test`, "password12", "Robin");
// The main bot greets the user in the main conversation, where onboarding lands.
- await completeOnboarding(page, undefined, { mainConversation: true });
+ await completeOnboarding(page, undefined, { mainConversation: true, ooName: "Robin" });
await expect(
page.getByText("Hey Robin. Fresh start on my side, so I’ll keep this short."),
@@ -31,12 +31,11 @@ test("focus choice suggests apps and preserves a completed connection", async ({
await captureScreenshot(page, testInfo, "choice-card-onboarding");
await page.getByTestId("focus-choice-day").click();
- // The focus step suggests apps but must not rename the bot: the name the
- // user chose during creation ("Chief") is preserved.
+ // The focus step must not rename the bot: onboarding keeps the chosen name.
await expect(
- page.getByTestId("bot-settings-trigger").getByText("Chief", { exact: true }),
+ page.getByTestId("bot-settings-trigger").getByText("Robin", { exact: true }),
).toBeVisible();
- expect((await rpc<{ name: string }>(page, "bots/ensureMain", {})).name).toBe("Chief");
+ expect((await rpc<{ name: string }>(page, "bots/ensureMain", {})).name).toBe("Robin");
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
// The app cards speak for themselves; onboarding adds no text around them.
await expect(page.getByText(/^Got it/)).toHaveCount(0);
@@ -103,7 +102,7 @@ test("focus choice suggests apps and preserves a completed connection", async ({
test("choice refresh failures leave options available for retry", async ({ page }) => {
await signup(page, `choice-refresh-${Date.now()}@rakazo.test`, "password12", "Choice Retry");
- await completeOnboarding(page, undefined, { mainConversation: true });
+ await completeOnboarding(page, undefined, { mainConversation: true, ooName: "Robin" });
const choice = page.getByTestId("focus-choice-day");
await expect(choice).toBeEnabled();
// Keep the existing choice rendered while its save succeeds and navigation refresh fails.
diff --git a/apps/web/e2e/onboarding-emoji-first.spec.ts b/apps/web/e2e/onboarding-emoji-first.spec.ts
index 51e1bf8..9572b68 100644
--- a/apps/web/e2e/onboarding-emoji-first.spec.ts
+++ b/apps/web/e2e/onboarding-emoji-first.spec.ts
@@ -1,5 +1,5 @@
import { expect, test } from "@playwright/test";
-import { captureScreenshot, signup } from "./helpers";
+import { captureScreenshot, signup, skipOnboardingNameStep } from "./helpers";
test.beforeEach(async ({ page }) => {
await page.route("**/rpc/integrationSetup/get", (route) =>
@@ -49,11 +49,12 @@ test("emoji-first onboarding: shape + color selection with live preview", async
if (testInfo) await captureScreenshot(page, testInfo, "02-shape-color-preview");
- // Click Continue → goes to the language step
+ // Click Continue → naming, then language.
await page
.getByRole("button", { name: /Continue/i })
.first()
.click();
+ await skipOnboardingNameStep(page);
// Step 2: Language selection
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
@@ -96,6 +97,7 @@ test("onboarding: skip language defaults to English", async ({ page }) => {
.getByRole("button", { name: /Continue/i })
.first()
.click();
+ await skipOnboardingNameStep(page);
// Language step — click Skip, then skip voice.
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
diff --git a/apps/web/e2e/onboarding-oo-swipe.spec.ts b/apps/web/e2e/onboarding-oo-swipe.spec.ts
index 0789ace..4b5f343 100644
--- a/apps/web/e2e/onboarding-oo-swipe.spec.ts
+++ b/apps/web/e2e/onboarding-oo-swipe.spec.ts
@@ -1,6 +1,6 @@
import type { Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
-import { captureScreenshot, signup } from "./helpers";
+import { captureScreenshot, signup, skipOnboardingNameStep } from "./helpers";
test.beforeEach(async ({ page }) => {
await page.route("**/rpc/integrationSetup/get", (route) =>
@@ -77,5 +77,6 @@ test("mobile onboarding: category pick and swipe look previews", async ({ page }
.getByRole("button", { name: /Continue/i })
.first()
.click();
+ await skipOnboardingNameStep(page);
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
});
diff --git a/apps/web/e2e/user-profile.spec.ts b/apps/web/e2e/user-profile.spec.ts
index 8d5840e..21cf71a 100644
--- a/apps/web/e2e/user-profile.spec.ts
+++ b/apps/web/e2e/user-profile.spec.ts
@@ -4,7 +4,14 @@
* with its origin, confirms a pending one, and forgets one or all of them.
*/
import { expect, test } from "@playwright/test";
-import { captureScreenshot, openUserSettings, rpc, signup, waitForChatComposer } from "./helpers";
+import {
+ captureScreenshot,
+ openUserSettings,
+ rpc,
+ signup,
+ skipOnboardingNameStep,
+ waitForChatComposer,
+} from "./helpers";
test.use({ timezoneId: "Europe/Vienna" });
@@ -34,6 +41,7 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async
timeout: 30_000,
});
await page.getByRole("button", { name: "Continue", exact: true }).click();
+ await skipOnboardingNameStep(page);
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
await page.getByRole("button", { name: "English", exact: true }).click();
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 7d13621..3b1e012 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -120,6 +120,10 @@ function SessionApp() {
}
/>
: } />
+ : }
+ />
: }
diff --git a/apps/web/src/components/onboarding/OnboardingNameStep.tsx b/apps/web/src/components/onboarding/OnboardingNameStep.tsx
new file mode 100644
index 0000000..c7d9c1e
--- /dev/null
+++ b/apps/web/src/components/onboarding/OnboardingNameStep.tsx
@@ -0,0 +1,98 @@
+import { Trans, useLingui } from "@lingui/react/macro";
+import type { EvooPose } from "@rakazo/ui-web";
+import { BotAvatar, Button } from "@rakazo/ui-web";
+import { useId, useState } from "react";
+import type { OnboardingEmojiConfig } from "./OnboardingOoDesigner";
+
+export const DEFAULT_FIRST_OO_NAME = "Chief";
+
+type OnboardingNameStepProps = {
+ config: OnboardingEmojiConfig;
+ name: string;
+ onNameChange: (name: string) => void;
+ onBack: () => void;
+ onContinue: () => void;
+};
+
+export function OnboardingNameStep({
+ config,
+ name,
+ onNameChange,
+ onBack,
+ onContinue,
+}: OnboardingNameStepProps) {
+ const { t } = useLingui();
+ const inputId = useId();
+ const [draft, setDraft] = useState(name);
+
+ function commitAndContinue() {
+ const trimmed = draft.trim() || DEFAULT_FIRST_OO_NAME;
+ onNameChange(trimmed);
+ onContinue();
+ }
+
+ return (
+
+
+ Name your Oo
+
+
+ Something short and yours — or keep the default and rename later.
+
+
+
+
+
+
+
setDraft(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") commitAndContinue();
+ }}
+ className="w-full rounded-2xl border-2 border-border bg-card px-4 py-3 text-lg font-semibold tracking-tight text-foreground shadow-sm outline-none transition-colors placeholder:text-muted-foreground/60 focus:border-primary"
+ />
+
+ {t`Press Enter or Continue — skip keeps ${DEFAULT_FIRST_OO_NAME}.`}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/onboarding/OnboardingVoiceStep.tsx b/apps/web/src/components/onboarding/OnboardingVoiceStep.tsx
index 91cd569..37d7704 100644
--- a/apps/web/src/components/onboarding/OnboardingVoiceStep.tsx
+++ b/apps/web/src/components/onboarding/OnboardingVoiceStep.tsx
@@ -1,15 +1,20 @@
import { Trans } from "@lingui/react/macro";
import type { VoiceInfo } from "@rakazo/contracts";
-import { defaultVoiceForLocale, voicesForLocale } from "@rakazo/core";
import { Button } from "@rakazo/ui-web";
import { useEffect, useRef, useState } from "react";
+import {
+ defaultOnboardingVoiceId,
+ offlineOnboardingVoices,
+ onboardingVoicesFromList,
+} from "../../lib/onboarding-voices";
import { rpc } from "../../lib/rpc";
-import { readUiLocale } from "../../lib/ui-locale";
+import type { UiLocale } from "../../lib/ui-locale";
/** Must match packages/adapters KokoroVoiceProvider placeholder. */
export const KOKORO_LOCAL_TOKEN = "local-kokoro";
type OnboardingVoiceStepProps = {
+ uiLocale: UiLocale;
selectedVoiceId: string;
onSelectedVoiceIdChange: (voiceId: string) => void;
onBack: () => void;
@@ -18,6 +23,7 @@ type OnboardingVoiceStepProps = {
};
export function OnboardingVoiceStep({
+ uiLocale,
selectedVoiceId,
onSelectedVoiceIdChange,
onBack,
@@ -36,13 +42,17 @@ export function OnboardingVoiceStep({
await rpc.voice.connect({ provider: "kokoro", apiKey: KOKORO_LOCAL_TOKEN });
const listed = await rpc.voice.voices({ provider: "kokoro" });
if (cancelled) return;
- const locale = readUiLocale();
- const ordered = voicesForLocale(listed, locale).slice(0, 6);
+ const ordered = onboardingVoicesFromList(listed, uiLocale);
setVoiceOptions(ordered);
- const defaultVoice = defaultVoiceForLocale(listed, locale);
- if (defaultVoice) onSelectedVoiceIdChange(defaultVoice.id);
+ const nextId = defaultOnboardingVoiceId(uiLocale, ordered);
+ if (nextId) onSelectedVoiceIdChange(nextId);
} catch {
- if (!cancelled) setVoiceOptions([]);
+ if (!cancelled) {
+ const fallback = offlineOnboardingVoices(uiLocale);
+ setVoiceOptions(fallback);
+ const nextId = defaultOnboardingVoiceId(uiLocale, fallback);
+ if (nextId) onSelectedVoiceIdChange(nextId);
+ }
} finally {
if (!cancelled) setVoiceLoading(false);
}
@@ -54,7 +64,7 @@ export function OnboardingVoiceStep({
return () => {
cancelled = true;
};
- }, [onSelectedVoiceIdChange]);
+ }, [onSelectedVoiceIdChange, uiLocale]);
async function skipVoice() {
await previewTask.current;
@@ -82,6 +92,7 @@ export function OnboardingVoiceStep({