Skip to content

Commit 79cb1f8

Browse files
Programcodeclaude
andauthored
Localize tab titles, validation and server errors; add ?lang= links (T12: IN-01, SE-01, IN-14, IN-03, IN-04, IN-11) (#222)
* Localize tab titles, form validation and server errors; add ?lang= links (T12) IN-01, SE-01, IN-14, IN-03, IN-04, IN-11. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Import mapAIError through the AI client boundary in the T12 test Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Add the localization verify recipe; type the page-head test components Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Save a ?lang= link's language on its own and keep a missing share link's title (review round 1) A link opened before the account's settings load no longer saves this device's older snapshot or flips back after hydration. A page can name its tab over its route's label, so a language switch keeps the not-found title. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Let a switcher choice or a sign-out cancel a pending ?lang= link (review round 2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Save only the language when the switcher changes it (review round 3, Low) The switcher now shows on pages that open before the account's settings load; writing this device's whole snapshot could overwrite newer settings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Add a credential-free localization browser spec; fix the recipe's login title check e2e/localization.spec.ts proves T12 in the browser with every off-origin request stubbed: titles per route and language, the empty-Attack error, the switcher on /login and 404, ?lang= and /hu, and a translated refusal, at 1280 and 375 px. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent f1cb90c commit 79cb1f8

53 files changed

Lines changed: 1266 additions & 185 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.claude/skills/verify-debatelab/features/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ Each feature file starts with an H1 title and one paragraph describing the user-
5353
- Account: output language reaching the model from a new device, flowchart node delete (at once, with an `Undo` toast; no confirm dialog), save and load, and the speech timer (`R` resets only while paused) (`recipes/account.spec.ts`).
5454
- [Usage limits](./usage-limits.md): Opus refused when its weight does not fit today while Haiku still runs, and the weekly AI budget dialog (`recipes/usage-limits.spec.ts`).
5555
- Billing: expired account blocked, promo code restores access (`recipes/billing.spec.ts`); each billing state (trial, extended access, active Pro, Pro set to cancel, payment failed, subscription ended) shown the same way by Settings, the pricing dialog (its one plan button and Current badge) and a refused AI request, in English and Hungarian, light and dark, at 360 and 375 px (mobile project) and 768 and 1280 px (desktop project); the usage line on every tool page matching Settings and updating after a run; the trial banner; and `/app/billing` landing on the Settings subscription card (`recipes/billing-states.spec.ts`). The pricing dialog's Manage path stubs `create-portal` with its own no-subscription answer, since the portal needs Stripe; signed, forged, and replayed Stripe webhooks, including a failed renewal that keeps Pro (`e2e/full-stack/billing.spec.ts`).
56+
- [Localization](./localization.md): tab titles per route and language, translated form validation and server refusals, the language switcher on sign-in and 404, and `?lang=` with the `/es` and `/hu` home pages (`recipes/localization.spec.ts`).
5657
- Admin announcements and the non-admin block (`e2e/full-stack/admin.spec.ts`), public share links (`e2e/full-stack/public.spec.ts`), and Workspaces with live collaboration and invites (`e2e/workspace-collaboration.spec.ts`).
5758

58-
Not yet mapped in recipes: theme and UI language, the public landing and SEO pages, the free no-login tools under `/tools` and `/embed/debate-timer` (no backend; `e2e/free-tools.spec.ts` covers them), and share-link creation (the app has no client caller for the `share` function). The agentic suite covers UI language switching (`agentic/workspace.e2e.ts`) and renders the public pages in every language (`agentic/pages.e2e.ts`).
59+
Not yet mapped in recipes: theme, the public landing and SEO pages beyond their titles, the free no-login tools under `/tools` and `/embed/debate-timer` (no backend; `e2e/free-tools.spec.ts` covers them), and share-link creation (the app has no client caller for the `share` function). The agentic suite covers UI language switching (`agentic/workspace.e2e.ts`) and renders the public pages in every language (`agentic/pages.e2e.ts`).
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Localization
2+
3+
The interface runs in English, Spanish and Hungarian. Every page names itself in the tab title in the interface language ("Generálás · DebateLab" in the app, "Felhasználási feltételek | DebateLab by Pi-Stack" on public pages), form validation and server refusals read in that language, the language switcher sits in every header, and `?lang=es|hu|en` links and the `/es` and `/hu` home pages open the app in that language.
4+
5+
## Sub-features
6+
7+
- loc-titles: `document.title` on app and public routes, per language, and a switch retitling the page without a reload. App routes read `<page> · DebateLab` (`usePrivateRouteHead`); public pages use the `pageHead` catalog section (`usePageHead`), whose English text is also what the prerender writes.
8+
- loc-validation: an empty Attack submit shows `validation.caseTextRequired` in the interface language, and nothing shows before the first submit. Generate, Brainstorm and Factoids use the same keys.
9+
- loc-switcher: the switcher on `/login`, the 404 page, and the other public pages (`PublicPageShell`), besides the landing page and the app header.
10+
- loc-links: `/?lang=hu` switches and saves the language, sets `<html lang>`, and drops the parameter; `/es` and `/hu` land on `/` in that language (a client route here; `vercel.json` redirects them on the host).
11+
- loc-server-error: a refused promo code shows `serverErrors.promoInvalid` in the interface language while redeem-promo still answers in English beside its `code`.
12+
13+
## How to get to it (user POV)
14+
15+
- The globe button in any header, then a language under "Interface language".
16+
- A link with `?lang=hu`, or the address `/hu` or `/es`.
17+
- Settings, `Upgrade to Pro`, `Have a promo code?`, then an unknown code and `Apply`, for the translated refusal.
18+
19+
## Driving it with verify.sh + Playwright
20+
21+
```bash
22+
$H drive recipes/localization --project desktop-chromium
23+
$H drive recipes/localization --project mobile-chromium # 375 px
24+
```
25+
26+
`01-document-titles.json` in the loc-titles evidence lists the title of every route in each language.
27+
28+
## Gotchas
29+
30+
- The switcher's own name is translated (`langSwitcher.label`); match it in all three languages.
31+
- The recipe reads the catalogs from `src/locales/`, so its expectations follow the strings. Match on keys, never on copied text.
32+
- The promo field shows only for an account that would start a Checkout, so the recipe marks its Pro demo user expired first.
33+
- `/es` and `/hu` on the host are a `vercel.json` redirect; the local stack proves only the client route.
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { readFileSync } from "node:fs";
2+
import { randomUUID } from "node:crypto";
3+
import type { Page } from "@playwright/test";
4+
import { admin, expect, test } from "./fixtures";
5+
6+
// Feature map: features/localization.md (T12: IN-01, SE-01, IN-03, IN-04, IN-11, IN-14).
7+
8+
const catalog = (language: string) => JSON.parse(readFileSync(`src/locales/${language}.json`, "utf8"));
9+
const en = catalog("en");
10+
const es = catalog("es");
11+
const hu = catalog("hu");
12+
const BRAND = "DebateLab by Pi-Stack";
13+
const LANGUAGE_NAMES = { en: "English", es: "Español", hu: "Magyar" } as const;
14+
/** The switcher's own name in whichever language is showing. */
15+
const SWITCHER_NAME = new RegExp(`^(${[en, es, hu].map((strings) => strings.langSwitcher.label).join("|")})$`);
16+
17+
const APP_ROUTES = ["/app", "/app/generate", "/app/attack", "/app/history", "/app/settings"];
18+
const PUBLIC_ROUTES = ["/terms", "/privacy", "/tools", "/no-such-page"];
19+
20+
/** Phones at 375 px, the width the QA report used; desktop keeps the project's 1280. */
21+
test.beforeEach(async ({ page }, testInfo) => {
22+
if (testInfo.project.name === "mobile-chromium") await page.setViewportSize({ width: 375, height: page.viewportSize()!.height });
23+
});
24+
25+
async function chooseLanguage(page: Page, language: keyof typeof LANGUAGE_NAMES) {
26+
await page.getByRole("button", { name: SWITCHER_NAME }).first().click();
27+
await page.getByRole("radiogroup").first().getByRole("radio", { name: LANGUAGE_NAMES[language] }).click();
28+
await expect(page.locator("html")).toHaveAttribute("lang", language);
29+
}
30+
31+
/** The tab title once the page has set its own (the shell's is just "DebateLab"). */
32+
async function pageTitle(page: Page, route: string) {
33+
await page.goto(route);
34+
await expect.poll(() => page.title()).not.toBe("DebateLab");
35+
return page.title();
36+
}
37+
38+
test("loc-titles: every route's tab title is its own and follows the interface language", async ({ signedIn: page, evidence }) => {
39+
const titles: Record<string, Record<string, string>> = {};
40+
for (const language of ["en", "hu", "es"] as const) {
41+
await page.goto(`/app?lang=${language}`);
42+
await expect(page.locator("html")).toHaveAttribute("lang", language);
43+
titles[language] = {};
44+
for (const route of [...APP_ROUTES, ...PUBLIC_ROUTES]) titles[language][route] = await pageTitle(page, route);
45+
await evidence.step(`Recorded document.title on ${APP_ROUTES.length + PUBLIC_ROUTES.length} routes in ${language}`);
46+
}
47+
await evidence.json("01-document-titles", titles);
48+
49+
for (const route of APP_ROUTES) {
50+
for (const language of ["en", "hu", "es"]) expect(titles[language][route]).toMatch(/ · DebateLab$/);
51+
}
52+
for (const route of PUBLIC_ROUTES) {
53+
for (const language of ["en", "hu", "es"]) expect(titles[language][route]).toMatch(new RegExp(`\\| ${BRAND}$`));
54+
}
55+
expect(titles.en["/app/generate"]).toBe(`${en.nav.generate} · DebateLab`);
56+
expect(titles.hu["/app/generate"]).toBe(`${hu.nav.generate} · DebateLab`);
57+
expect(titles.hu["/no-such-page"]).toBe(hu.pageHead.notFound.title.replace("{{brand}}", BRAND));
58+
// Each language names every page in its own words (the brand aside).
59+
for (const route of [...APP_ROUTES, ...PUBLIC_ROUTES]) {
60+
expect(titles.hu[route], route).not.toBe(titles.en[route]);
61+
}
62+
63+
// A switch retitles the page on screen, with no reload.
64+
await page.goto("/app/attack");
65+
await chooseLanguage(page, "en");
66+
await expect(page).toHaveTitle(`${en.nav.attack} · DebateLab`);
67+
await chooseLanguage(page, "hu");
68+
await expect(page).toHaveTitle(`${hu.nav.attack} · DebateLab`);
69+
await evidence.shot("02-attack-title-hu");
70+
});
71+
72+
test("loc-validation: an empty Attack shows its required-field error in the interface language, only after submit", async ({ signedIn: page, evidence }) => {
73+
await page.goto("/app/attack?lang=hu");
74+
const caseText = page.getByRole("textbox", { name: new RegExp(hu.attackForm.argument) });
75+
await expect(caseText).toBeVisible();
76+
await expect(page.getByText(hu.validation.caseTextRequired)).toHaveCount(0);
77+
await evidence.shot("01-attack-hu-before-submit");
78+
await evidence.aria("01-attack-hu-before-submit", page.getByRole("main"));
79+
80+
await page.getByRole("button", { name: hu.attackForm.submit }).click();
81+
await expect(page.getByText(hu.validation.caseTextRequired)).toBeVisible();
82+
await expect(page.getByText(en.validation.caseTextRequired)).toHaveCount(0);
83+
await evidence.step("Submitted an empty Attack in Hungarian");
84+
await evidence.shot("02-attack-hu-required");
85+
86+
await chooseLanguage(page, "es");
87+
await expect(page.getByText(es.validation.caseTextRequired)).toBeVisible();
88+
await evidence.shot("03-attack-es-required");
89+
});
90+
91+
test("loc-switcher: sign-in and not-found pages carry the language switcher", async ({ page, evidence }) => {
92+
await page.goto("/login");
93+
await expect(page.getByRole("heading", { level: 1, name: en.login.title })).toBeVisible();
94+
await evidence.shot("01-login-en");
95+
await chooseLanguage(page, "hu");
96+
await expect(page.getByRole("heading", { level: 1, name: hu.login.title })).toBeVisible();
97+
// "Bejelentkezés a DebateLab-ba" already names the app, so it gets no suffix.
98+
await expect(page).toHaveTitle(hu.login.title);
99+
await evidence.shot("02-login-hu");
100+
101+
await page.goto("/no-such-page");
102+
await expect(page.getByRole("heading", { level: 1, name: hu.notFound.title })).toBeVisible();
103+
await expect(page).toHaveTitle(hu.pageHead.notFound.title.replace("{{brand}}", BRAND));
104+
await evidence.shot("03-not-found-hu");
105+
await chooseLanguage(page, "es");
106+
await expect(page).toHaveTitle(es.pageHead.notFound.title.replace("{{brand}}", BRAND));
107+
await expect(page.getByText(es.notFound.message)).toBeVisible();
108+
await evidence.shot("04-not-found-es");
109+
});
110+
111+
test("loc-links: ?lang= and the /es and /hu home pages open the landing page in that language", async ({ page, evidence }) => {
112+
await page.goto("/?lang=hu");
113+
await expect(page.locator("html")).toHaveAttribute("lang", "hu");
114+
await expect(page).toHaveTitle(hu.pageHead.home.title.replace("{{brand}}", BRAND));
115+
// The choice is kept and the address drops the parameter.
116+
await expect.poll(() => new URL(page.url()).search).toBe("");
117+
await evidence.shot("01-landing-lang-hu");
118+
119+
await page.goto("/es");
120+
await expect.poll(() => new URL(page.url()).pathname).toBe("/");
121+
await expect(page.locator("html")).toHaveAttribute("lang", "es");
122+
await expect(page).toHaveTitle(es.pageHead.home.title.replace("{{brand}}", BRAND));
123+
await evidence.shot("02-landing-es");
124+
125+
await page.goto("/hu");
126+
await expect.poll(() => new URL(page.url()).pathname).toBe("/");
127+
await expect(page.locator("html")).toHaveAttribute("lang", "hu");
128+
await page.reload();
129+
await expect(page.locator("html")).toHaveAttribute("lang", "hu");
130+
await evidence.shot("03-landing-hu-after-reload");
131+
});
132+
133+
test("loc-server-error: a refused promo code reads in the interface language", async ({ signedIn: page, user, evidence }) => {
134+
const userId = await user.userId();
135+
// The promo field shows for an account that would start a Checkout.
136+
await admin.from("user_settings")
137+
.update({ subscription_tier: "expired", trial_ends_at: null, grace_period_ends_at: null, daily_limit_override: null })
138+
.eq("user_id", userId);
139+
await page.goto("/app/settings?lang=hu");
140+
await page.getByRole("button", { name: hu.billing.upgradeToPro }).first().click();
141+
await page.getByRole("button", { name: hu.pricingModal.promoCode }).click();
142+
await page.getByPlaceholder(hu.pricingModal.promoPlaceholder).fill(`NOPE${randomUUID().slice(0, 6).toUpperCase()}`);
143+
const answer = page.waitForResponse((response) => response.url().includes("/functions/v1/redeem-promo"));
144+
await page.getByRole("button", { name: hu.pricingModal.apply, exact: true }).click();
145+
const body = await (await answer).json();
146+
await evidence.json("01-redeem-promo-answer", body);
147+
// The server still sends English beside the code; the screen shows the translation.
148+
expect(body).toMatchObject({ success: false, code: "promo_invalid", message: en.serverErrors.promoInvalid });
149+
await expect(page.getByText(hu.serverErrors.promoInvalid).first()).toBeVisible();
150+
await expect(page.getByText(en.serverErrors.promoInvalid)).toHaveCount(0);
151+
await evidence.shot("02-promo-refused-hu");
152+
});

0 commit comments

Comments
 (0)