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] 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}