From ece3638ce7d046b78335c36232bad4efaaf6ffb9 Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:04:18 +0200 Subject: [PATCH 1/2] fix(ui-web): teach cn() the design system's text and control scales (#115) * fix(ui-web): teach cn() the design system's text and control scales cn() used plain tailwind-merge, which only knows Tailwind's default scales. It read the custom font sizes (text-ui-label, text-ui-caption, ...) as text colors, so of a size and a color in one class list only the last survived: - Small, extra-small and large buttons lost their variant's text color. A small primary button rendered foreground text on the primary fill (ink on ink in the light theme, near-white on white in the dark theme), and destructive, secondary and link buttons lost their colors the same way. - Default-size and icon buttons, tab triggers and the typography helpers lost their font size and inherited the parent's instead. The control and icon spacing scales (h-control-sm, size-icon-md, ...) were unknown as well, so they never merged with other sizes: an explicit h-auto or h-9 on a Button, Input or Toggle lost to the component's own height, and large buttons and toggles drew their icons at the medium size. cn() now extends tailwind-merge with the text and spacing keys styles.css adds under @theme. A unit test probes cn() with every @theme key in styles.css, so a new key that is not registered fails the test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test: look up the home main conversation in the scheduling journey Since every project has its own main conversation, the journey that schedules for a specialist found the user's main conversation with findFirst over all of them. With the Lab project's conversation also matching, it picked either one, so the check failed about one run in three. The lookup now names the home space, where the specialist joined and the routine is created. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --------- Co-authored-by: Claude --- packages/ui-web/src/lib/utils.test.ts | 80 +++++++++++++++++++++++++++ packages/ui-web/src/lib/utils.ts | 39 ++++++++++++- 2 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 packages/ui-web/src/lib/utils.test.ts diff --git a/packages/ui-web/src/lib/utils.test.ts b/packages/ui-web/src/lib/utils.test.ts new file mode 100644 index 0000000..4452e9a --- /dev/null +++ b/packages/ui-web/src/lib/utils.test.ts @@ -0,0 +1,80 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { buttonVariants } from "../components/ui/button.js"; +import { cn } from "./utils.js"; + +describe("cn", () => { + it("keeps a text color next to a ui text size", () => { + expect(cn("text-primary-foreground", "text-ui-label")).toBe( + "text-primary-foreground text-ui-label", + ); + expect(cn("text-ui-body-sm", "text-muted-foreground")).toBe( + "text-ui-body-sm text-muted-foreground", + ); + }); + + it("merges two text sizes to the last one", () => { + expect(cn("text-ui-body-sm", "text-ui-label")).toBe("text-ui-label"); + expect(cn("text-sm", "text-ui-caption")).toBe("text-ui-caption"); + expect(cn("text-ui-caption", "text-lg")).toBe("text-lg"); + }); + + it("merges two text colors to the last one", () => { + expect(cn("text-ui-label text-primary-foreground", "text-destructive")).toBe( + "text-ui-label text-destructive", + ); + }); + + it("merges ui control sizes with other sizes", () => { + expect(cn("h-control-sm", "h-8")).toBe("h-8"); + expect(cn("h-auto", "h-control-md")).toBe("h-control-md"); + expect(cn("[&_svg]:size-icon-md", "[&_svg]:size-icon-lg")).toBe("[&_svg]:size-icon-lg"); + }); + + it("keeps small buttons readable", () => { + for (const size of ["xs", "sm", "default", "lg"] as const) { + const classes = cn(buttonVariants({ variant: "default", size })).split(" "); + expect(classes, size).toContain("text-primary-foreground"); + expect(classes.filter((name) => name.startsWith("text-ui-")).length, size).toBe(1); + } + expect(cn(buttonVariants({ variant: "destructive", size: "sm" })).split(" ")).toEqual( + expect.arrayContaining(["text-destructive", "text-ui-label"]), + ); + }); +}); + +/** One merge check per `@theme` namespace: [earlier classes, later classes, expected]. */ +const NAMESPACE_PROBES: Record [string, string, string]> = { + color: (name) => ["text-ui-body text-foreground", `text-${name}`, `text-ui-body text-${name}`], + radius: (name) => ["rounded-sm", `rounded-${name}`, `rounded-${name}`], + spacing: (name) => [ + "h-4 p-2 gap-2", + `h-${name} p-${name} gap-${name}`, + `h-${name} p-${name} gap-${name}`, + ], + text: (name) => ["text-foreground text-sm", `text-${name}`, `text-foreground text-${name}`], +}; + +describe("cn knows every @theme key in styles.css", () => { + const css = readFileSync(new URL("../styles.css", import.meta.url), "utf8"); + const theme = css.match(/@theme inline \{([\s\S]*?)\n\}/)?.[1] ?? ""; + const keys = [...theme.matchAll(/^\s*--([a-z0-9-]+):/gm)] + .map((match) => match[1] ?? "") + .filter((key) => !key.includes("--")); + + it("finds the theme keys", () => { + expect(keys).toContain("text-ui-label"); + expect(keys).toContain("spacing-control-sm"); + }); + + for (const key of keys) { + it(`--${key}`, () => { + const namespace = Object.keys(NAMESPACE_PROBES).find((ns) => key.startsWith(`${ns}-`)); + expect(namespace, `add a probe for the @theme namespace of --${key}`).toBeDefined(); + const [earlier, later, expected] = NAMESPACE_PROBES[namespace ?? ""]!( + key.slice(`${namespace}-`.length), + ); + expect(cn(earlier, later)).toBe(expected); + }); + } +}); diff --git a/packages/ui-web/src/lib/utils.ts b/packages/ui-web/src/lib/utils.ts index 365058c..7cdcf9e 100644 --- a/packages/ui-web/src/lib/utils.ts +++ b/packages/ui-web/src/lib/utils.ts @@ -1,5 +1,40 @@ -import { type ClassValue, clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; +import type { ClassValue } from "clsx"; +import { clsx } from "clsx"; +import { extendTailwindMerge } from "tailwind-merge"; + +/** + * The scales `styles.css` adds under `@theme`. tailwind-merge only knows Tailwind's + * defaults, so without them it reads `text-ui-label` as a text color (dropping the + * real color) and never merges `h-control-sm` with `h-8`. `utils.test.ts` fails + * when `@theme` gains a key that is missing here. + */ +const twMerge = extendTailwindMerge({ + extend: { + theme: { + text: [ + "ui-caption", + "ui-label", + "ui-body-sm", + "ui-body", + "ui-reading", + "ui-title-sm", + "ui-title", + ], + spacing: [ + "control-xs", + "control-sm", + "control-md", + "control-lg", + "icon-sm", + "icon-md", + "icon-lg", + "rk-shell", + "rk-sidebar-x", + "rk-sidebar-y", + ], + }, + }, +}); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); From 0c3e3f3d29d4139cfbeed32ada8264ee62ef4923 Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:04:21 +0200 Subject: [PATCH 2/2] feat(web): complete German, English sources and catalogs that cannot go stale (#111) * fix(web): use English Lingui sources for German UI text Several web surfaces hard-coded German (the project card, roster and activity status labels, the sidebar "Heute" label) or used German text as the Lingui source message (sidebar sections, project timeline, bot picker, voice entry, group stage, copy and read-aloud buttons). English users saw German, and because production builds keep only message ids, none of these strings could be translated per locale. Convert them to English source messages, keeping the meaning: - ProjectCreatedCard: Lingui Trans/Plural instead of literals. - agent-activity: labels go through i18n._ with literal ids so the module stays importable from plain unit tests; the extractor still collects them. - Shell, ProjectSessionTimeline, bot-picker, StageVoiceEntry, OoStage: English sources, reusing existing messages where one already fit ("just now", "{n}m ago", "Search", "No bots match", "Set up voice", "Group conversation", "Transcript"). - The conversation picker matches its create entry against the translated label instead of a fixed German string. The German wording returns through the de catalog in the next commit. Unit and E2E tests now assert the English labels. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(web): keep Lingui catalogs complete and in sync The committed catalogs were last extracted weeks ago: about 330 source messages were missing from every locale, and production builds keep only message ids, so those strings rendered as hash ids (sidebar sections, privacy settings, profile, timeline, ...). German also had 226 empty entries, and every extraction rewrote the files because references carried line numbers. - lingui.config.ts: po references without line numbers, and a stable sort that breaks Lingui's ordering ties (explicit ids, equal text) by id, so the output no longer depends on file-system order. - Re-extract all catalogs with --clean (drops 25-44 obsolete entries per locale; no existing translation changed). - Translate every German entry (du form, consistent with the existing catalog). Other locales keep empty entries and fall back to English. - i18n-catalog-sync.test.ts extracts into a copy of the catalogs and fails when the committed files differ or a German entry is empty, so stale catalogs fail the unit test job (offline, about 5 s). Adds @lingui/format-po (already installed through @lingui/cli) as a direct dev dependency to configure the formatter. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test(web): find the sidebar Today button by its English label The shell contrast test added on main looks up the sidebar button by the old German source text, which this branch replaces with "Today". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * chore(web): extract catalogs and translate the in-app browser strings Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test: look up the home main conversation in the scheduling journey Since every project has its own main conversation, the journey that schedules for a specialist found the user's main conversation with findFirst over all of them. With the Lab project's conversation also matching, it picked either one, so the check failed about one run in three. The lookup now names the home space, where the specialist joined and the routine is created. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --------- Co-authored-by: Claude --- apps/web/e2e/chat-contrast.spec.ts | 2 +- apps/web/e2e/heute-brief.spec.ts | 2 +- apps/web/e2e/project-main-agent.spec.ts | 2 +- apps/web/e2e/sidebar-nav.spec.ts | 11 +- apps/web/e2e/smoke.spec.ts | 4 +- apps/web/e2e/spaces.spec.ts | 6 +- apps/web/e2e/unterhaltungen-sidebar.spec.ts | 6 +- apps/web/lingui.config.ts | 25 + apps/web/package.json | 1 + .../src/components/ai/ProjectCreatedCard.tsx | 11 +- apps/web/src/components/stage/OoStage.tsx | 4 +- .../src/components/stage/StageVoiceEntry.tsx | 8 +- apps/web/src/lib/agent-activity.test.ts | 20 +- apps/web/src/lib/agent-activity.ts | 36 +- apps/web/src/lib/i18n-catalog-sync.test.ts | 68 + apps/web/src/locales/de/messages.po | 4421 ++++++++++------- apps/web/src/locales/en/messages.po | 3895 +++++++++------ apps/web/src/locales/es/messages.po | 3735 +++++++++----- apps/web/src/locales/hi/messages.po | 3735 +++++++++----- apps/web/src/locales/ko/messages.po | 3735 +++++++++----- apps/web/src/locales/pt-BR/messages.po | 3735 +++++++++----- apps/web/src/locales/ru/messages.po | 3733 +++++++++----- apps/web/src/locales/tr/messages.po | 3735 +++++++++----- apps/web/src/locales/zh-CN/messages.po | 3735 +++++++++----- apps/web/src/pages/ProjectSessionTimeline.tsx | 47 +- apps/web/src/pages/Shell.tsx | 48 +- apps/web/src/pages/shell/bot-picker.tsx | 18 +- pnpm-lock.yaml | 3 + 28 files changed, 22311 insertions(+), 12470 deletions(-) create mode 100644 apps/web/src/lib/i18n-catalog-sync.test.ts diff --git a/apps/web/e2e/chat-contrast.spec.ts b/apps/web/e2e/chat-contrast.spec.ts index c388194..2ea5310 100644 --- a/apps/web/e2e/chat-contrast.spec.ts +++ b/apps/web/e2e/chat-contrast.spec.ts @@ -253,7 +253,7 @@ test("shell surfaces stay readable in light and dark themes", async ({ page }, t await expectReadable(sidePanel, `${theme}: the bot settings form`, { inside: true }); }); - await sidebar.getByRole("button", { name: "Heute", exact: true }).click(); + await sidebar.getByRole("button", { name: "Today", exact: true }).click(); const heute = page.getByTestId("heute-brief"); await expect(heute.getByTestId("heute-mission-control-link")).toBeVisible(); await inEachTheme(page, async (theme) => { diff --git a/apps/web/e2e/heute-brief.spec.ts b/apps/web/e2e/heute-brief.spec.ts index 28bf669..6347490 100644 --- a/apps/web/e2e/heute-brief.spec.ts +++ b/apps/web/e2e/heute-brief.spec.ts @@ -6,7 +6,7 @@ test("Heute shows native brief instead of Mission Control iframe", async ({ page await signup(page, `heute-${stamp}@rakazo.test`, "password12", "Heute"); await completeOnboarding(page); - await page.getByRole("button", { name: "Heute", exact: true }).click(); + await page.getByRole("button", { name: "Today", exact: true }).click(); await expect(page).toHaveURL(/\/app\/[^/]+$/); const brief = page.getByTestId("heute-brief"); diff --git a/apps/web/e2e/project-main-agent.spec.ts b/apps/web/e2e/project-main-agent.spec.ts index 52488a2..7d0fedd 100644 --- a/apps/web/e2e/project-main-agent.spec.ts +++ b/apps/web/e2e/project-main-agent.spec.ts @@ -30,7 +30,7 @@ test("a new project opens straight into the conversation with its own main agent name: "Chief, main conversation", }); await expect(projectEntry).toBeVisible({ timeout: 30_000 }); - await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toHaveCount(0); + await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0); const groupId = new URL(page.url()).pathname.split("/").at(-1)!; const snapshot = await rpc<{ leadBotId?: string }>(page, "threads/get", { groupId }); expect(snapshot.leadBotId).toBeTruthy(); diff --git a/apps/web/e2e/sidebar-nav.spec.ts b/apps/web/e2e/sidebar-nav.spec.ts index e5e502c..0f87641 100644 --- a/apps/web/e2e/sidebar-nav.spec.ts +++ b/apps/web/e2e/sidebar-nav.spec.ts @@ -21,20 +21,19 @@ test("sidebar has expected navigation items and no integrations", async ({ page // Actual sidebar structure (verified by snapshot): // - button "Activity" (top action) // - textbox "Search" (search input) - // - button "Heute" (today) - // - text "Sprich mit deinen Oo's" (conversations section header) + // - button "Today" + // - text "Talk to your Oos" (conversations section header) // - NO "Integration(en)" button or text // Search: it's a textbox, not a button const searchInput = sidebar.locator('input[placeholder="Search"], textbox[name="Search"]'); await expect(searchInput).toBeVisible({ timeout: 5000 }); - // "Heute" (Today) - const todayBtn = sidebar.getByRole("button", { name: "Heute", exact: true }); + const todayBtn = sidebar.getByRole("button", { name: "Today", exact: true }); await expect(todayBtn).toBeVisible({ timeout: 5000 }); - // "Sprich mit deinen Oo's" is plain text, not a button - const conversationsText = sidebar.getByText("Sprich mit deinen Oo's"); + // "Talk to your Oos" is plain text, not a button + const conversationsText = sidebar.getByText("Talk to your Oos"); await expect(conversationsText).toBeVisible({ timeout: 5000 }); // No "Integration(en)" button diff --git a/apps/web/e2e/smoke.spec.ts b/apps/web/e2e/smoke.spec.ts index 6d634bd..d5228e9 100644 --- a/apps/web/e2e/smoke.spec.ts +++ b/apps/web/e2e/smoke.spec.ts @@ -41,8 +41,8 @@ test("app loads without React hook errors", async ({ page }) => { bodyText.includes("Sign in") || bodyText.includes("Anmelden") || bodyText.includes("EvOo") || - bodyText.includes("Projekte") || - bodyText.includes("Heute") || + bodyText.includes("Projects") || + bodyText.includes("Today") || bodyText.includes("Team"); expect(hasContent).toBe(true); diff --git a/apps/web/e2e/spaces.spec.ts b/apps/web/e2e/spaces.spec.ts index aa0da90..e540978 100644 --- a/apps/web/e2e/spaces.spec.ts +++ b/apps/web/e2e/spaces.spec.ts @@ -16,7 +16,7 @@ function projectSpace(projekte: Locator, name: string) { }); } -test("Projekte + opens new project dialog instead of group form", async ({ page }, testInfo) => { +test("Projects + opens new project dialog instead of group form", async ({ page }, testInfo) => { const stamp = Date.now(); await signup(page, `proj-create-${stamp}@rakazo.test`, "password12", "Project Create"); await completeOnboarding(page); @@ -135,7 +135,7 @@ test("spaces stay invisible by default and chat creation requires approval", asy await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible(); await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1); await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(1); - await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toHaveCount(0); + await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0); await captureScreenshot(page, testInfo, "spaces-sidebar"); await page.evaluate((id) => { @@ -171,7 +171,7 @@ test("a new space opens its main conversation and can be deleted from its menu", await expect( projectSpace(projekte, "Temporary").getByRole("button", { name: "Chief, main conversation" }), ).toBeVisible(); - await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toHaveCount(0); + await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0); expect(await rpc>(page, "bots/list", {})).toEqual([ expect.objectContaining({ kind: "main" }), ]); diff --git a/apps/web/e2e/unterhaltungen-sidebar.spec.ts b/apps/web/e2e/unterhaltungen-sidebar.spec.ts index fbe99e4..ec03c7f 100644 --- a/apps/web/e2e/unterhaltungen-sidebar.spec.ts +++ b/apps/web/e2e/unterhaltungen-sidebar.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from "@playwright/test"; import { completeOnboarding, openNewBot, signup, unterhaltungenOnly } from "./helpers"; -test("Unterhaltungen and Projekte stay separate; + opens bot picker", async ({ page }) => { +test("conversations and projects stay separate; + opens bot picker", async ({ page }) => { const stamp = Date.now(); await signup(page, `unterhaltungen-${stamp}@rakazo.test`, "password12", "Unterhaltungen UX"); await completeOnboarding(page); @@ -11,8 +11,8 @@ test("Unterhaltungen and Projekte stay separate; + opens bot picker", async ({ p const unterhaltungen = page.locator(".sidebar-unterhaltungen"); const projekte = page.locator(".sidebar-projekte"); - await expect(unterhaltungen.getByText("Sprich mit deinen Oo's", { exact: true })).toBeVisible(); - await expect(projekte.getByText("Projekte", { exact: true })).toBeVisible(); + await expect(unterhaltungen.getByText("Talk to your Oos", { exact: true })).toBeVisible(); + await expect(projekte.getByText("Projects", { exact: true })).toBeVisible(); await expect(unterhaltungen.getByRole("button", { name: /^Chief/ })).toBeVisible(); await unterhaltungen.getByTestId("conversation-picker-trigger").click(); diff --git a/apps/web/lingui.config.ts b/apps/web/lingui.config.ts index 772bfe0..aab31c9 100644 --- a/apps/web/lingui.config.ts +++ b/apps/web/lingui.config.ts @@ -1,4 +1,28 @@ +import type { CatalogFormatter, CatalogType } from "@lingui/conf"; import { defineConfig } from "@lingui/conf"; +import { formatter } from "@lingui/format-po"; + +// File references without line numbers, so catalogs change only when messages change. +const po = formatter({ lineNumbers: false }); +const collator = new Intl.Collator("en-US"); + +// Lingui orders entries by source text but leaves ties (explicit ids, equal text) in the order the +// files were found, which differs between file systems. Break ties by id so output is stable. +function sortCatalog(catalog: CatalogType): CatalogType { + return Object.fromEntries( + Object.entries(catalog).sort( + ([aId, a], [bId, b]) => + collator.compare(a.message ?? aId, b.message ?? bId) || + collator.compare(a.context ?? "", b.context ?? "") || + (aId < bId ? -1 : aId > bId ? 1 : 0), + ), + ); +} + +const stablePo: CatalogFormatter = { + ...po, + serialize: (catalog, ctx) => po.serialize(sortCatalog(catalog), ctx), +}; export default defineConfig({ sourceLocale: "en", @@ -10,5 +34,6 @@ export default defineConfig({ exclude: ["**/locales/**", "**/*.test.*"], }, ], + format: stablePo, compileNamespace: "es", }); diff --git a/apps/web/package.json b/apps/web/package.json index 1f121dc..b00900a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,6 +36,7 @@ "@lingui/babel-plugin-lingui-macro": "6.6.0", "@lingui/cli": "6.6.0", "@lingui/conf": "6.6.0", + "@lingui/format-po": "6.6.0", "@lingui/vite-plugin": "6.6.0", "@playwright/test": "1.63.0", "@rolldown/plugin-babel": "0.2.4", diff --git a/apps/web/src/components/ai/ProjectCreatedCard.tsx b/apps/web/src/components/ai/ProjectCreatedCard.tsx index d6a6047..831e8c6 100644 --- a/apps/web/src/components/ai/ProjectCreatedCard.tsx +++ b/apps/web/src/components/ai/ProjectCreatedCard.tsx @@ -1,3 +1,4 @@ +import { Plural, Trans } from "@lingui/react/macro"; import { FolderKanban } from "lucide-react"; export interface ProjectCreatedCardProps { @@ -22,17 +23,19 @@ export function ProjectCreatedCard({ name, memberCount, onClick }: ProjectCreate
{name}
- Projekt + Project - Erstellt + Created

- {memberCount} Agent{memberCount === 1 ? "" : "en"} eingeladen +

-
Projekt öffnen →
+
+ Open project → +
); diff --git a/apps/web/src/components/stage/OoStage.tsx b/apps/web/src/components/stage/OoStage.tsx index 25fa582..e30c59d 100644 --- a/apps/web/src/components/stage/OoStage.tsx +++ b/apps/web/src/components/stage/OoStage.tsx @@ -421,7 +421,7 @@ export function OoGroupStage({ if (sparkLabel && groupSpark?.kind === "peer-reply") return sparkLabel; if (awarenessLabel) return awarenessLabel; if (sparkLabel) return sparkLabel; - if (!speaker) return t`Gruppenunterhaltung`; + if (!speaker) return t`Group conversation`; if (running && workingBots.length > 1) { return t`${workingBots.length} Oos are working — ${speaker.name} has the floor`; } @@ -495,7 +495,7 @@ export function OoGroupStage({

{groupName ?? t`Group`}

-

{t`Gruppenunterhaltung`}

+

{t`Group conversation`}

{awarenessLabel && awarenessEvent && speaker ? ( diff --git a/apps/web/src/components/stage/StageVoiceEntry.tsx b/apps/web/src/components/stage/StageVoiceEntry.tsx index 14c4088..dd32f8b 100644 --- a/apps/web/src/components/stage/StageVoiceEntry.tsx +++ b/apps/web/src/components/stage/StageVoiceEntry.tsx @@ -21,8 +21,8 @@ export function StageVoiceEntry({ variant="outline" onClick={onStartVoice} data-testid="stage-voice-primary" - aria-label={voiceReady ? t`Sprechen starten` : t`Sprache einrichten`} - title={voiceReady ? t`Sprechen starten` : t`Sprache einrichten`} + aria-label={voiceReady ? t`Start talking` : t`Set up voice`} + title={voiceReady ? t`Start talking` : t`Set up voice`} className={`tactile-press h-control-lg w-full max-w-sm gap-2.5 rounded-full px-6 text-ui-body font-semibold transition-all ${ voiceReady ? "relative overflow-hidden border-border bg-foreground/5 text-foreground hover:bg-foreground/10" @@ -33,10 +33,10 @@ export function StageVoiceEntry({ ) : null}