diff --git a/apps/web/e2e/empty-project-starten.spec.ts b/apps/web/e2e/empty-project-starten.spec.ts index dcf80dc..17d7a16 100644 --- a/apps/web/e2e/empty-project-starten.spec.ts +++ b/apps/web/e2e/empty-project-starten.spec.ts @@ -14,7 +14,7 @@ async function projectSidebarStart(projekte: Locator, name: string) { return projekte.getByTestId(`project-start-${spaceId}`); } -test("empty project shows Starten instead of a fallback bot", async ({ page }) => { +test("empty project shows Start instead of a fallback bot", async ({ page }) => { const stamp = Date.now(); await signup(page, `empty-project-${stamp}@rakazo.test`, "password12", "Empty Project"); await completeOnboarding(page); @@ -29,14 +29,14 @@ test("empty project shows Starten instead of a fallback bot", async ({ page }) = await page.waitForURL(/\/app(?:\/)?$/); await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0); - await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible(); + await expect(projekte.getByRole("button", { name: "Start", exact: true })).toBeVisible(); await expect(page.getByTestId("project-start-main")).toBeVisible(); await page.getByTestId("project-start-main").click(); await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "create"); }); -test("Starten in an onboarded project opens the group create flow", async ({ page }) => { +test("Start in an onboarded project opens the group create flow", async ({ page }) => { const stamp = Date.now(); await signup(page, `project-start-${stamp}@rakazo.test`, "password12", "Project Start"); await completeOnboarding(page); @@ -70,7 +70,7 @@ test("Starten in an onboarded project opens the group create flow", async ({ pag ).toBeVisible(); }); -test("archiving the only bot keeps Starten visible in the project sidebar", async ({ page }) => { +test("archiving the only bot keeps Start visible in the project sidebar", async ({ page }) => { const stamp = Date.now(); await signup(page, `project-archive-${stamp}@rakazo.test`, "password12", "Project Archive"); await completeOnboarding(page); @@ -93,6 +93,6 @@ test("archiving the only bot keeps Starten visible in the project sidebar", asyn await page.goto("/app"); await page.waitForURL(/\/app(?:\/)?$/); - await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible(); + await expect(projekte.getByRole("button", { name: "Start", exact: true })).toBeVisible(); await expect(page.getByTestId("project-start-main")).toBeVisible(); }); diff --git a/apps/web/e2e/heute-brief.spec.ts b/apps/web/e2e/heute-brief.spec.ts index c473c98..3cdc59f 100644 --- a/apps/web/e2e/heute-brief.spec.ts +++ b/apps/web/e2e/heute-brief.spec.ts @@ -3,9 +3,9 @@ import { expect, test } from "@playwright/test"; import type { CoachRhythm, CoachToday, Goal, Habit, Routine } from "@rakazo/contracts"; import { captureScreenshot, completeOnboarding, rpc, signup } from "./helpers"; -/** The nav button reads "Heute" or "Heute, N need you". */ +/** The nav button reads "Today" or "Today, N need you". */ function openHeute(page: Page) { - return page.getByRole("button", { name: /^Heute(,|$)/ }).click(); + return page.getByRole("button", { name: /^Today(,|$)/ }).click(); } function shiftDate(date: string, days: number) { 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 d3b1dc6..5b29d7f 100644 --- a/apps/web/e2e/spaces.spec.ts +++ b/apps/web/e2e/spaces.spec.ts @@ -15,7 +15,7 @@ function projectSpace(projekte: Locator, name: string) { }); } -test("Projekte + opens new project dialog instead of group form", async ({ page }) => { +test("Projects + opens new project dialog instead of group form", async ({ page }) => { const stamp = Date.now(); await signup(page, `proj-create-${stamp}@rakazo.test`, "password12", "Project Create"); await completeOnboarding(page); @@ -42,7 +42,7 @@ test("Projekte + opens new project dialog instead of group form", async ({ page 0, ); await expect(page.getByTestId("project-start-main")).toBeVisible(); - await expect(page.getByTestId("project-start-main")).toHaveText(/Starten/); + await expect(page.getByTestId("project-start-main")).toHaveText(/Start/); }); test("spaces stay invisible by default and chat creation requires approval", async ({ @@ -104,7 +104,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(0); - await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible(); + await expect(projekte.getByRole("button", { name: "Start", exact: true })).toBeVisible(); await captureScreenshot(page, testInfo, "spaces-sidebar"); await page.evaluate((id) => { @@ -135,7 +135,7 @@ test("a new space opens empty and can be deleted from its menu", async ({ page } await expect(projekte.getByText("Temporary", { exact: true })).toBeVisible(); await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1); await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0); - await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible(); + await expect(projekte.getByRole("button", { name: "Start", exact: true })).toBeVisible(); expect(await rpc(page, "bots/list", {})).toEqual([]); await captureScreenshot(page, testInfo, "new-space-empty"); 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 1fa1f26..a4ae59e 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}