From 4201a352c8266580e618dba3998422b7cff3e3af Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 27 Oct 2026 08:46:10 +0000 Subject: [PATCH 01/12] Prerendered pages load the built owl, not /src The prerender step runs the pages through Vite's dev SSR loader, which resolved `import point from '@/assets/owl/owl-point.svg'` to /src/assets/owl/owl-point.svg. That path 404s on the deployed site, so /, /hu, /es and /404 showed no owl until hydration. Asset imports now resolve to the hashed file from the build manifest, and the build fails if a page still names a /src/ URL. Co-Authored-By: Claude Opus 5.5 --- scripts/prerender.mjs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs index 51718f3d..5fe0b726 100644 --- a/scripts/prerender.mjs +++ b/scripts/prerender.mjs @@ -115,17 +115,42 @@ const shell = readFileSync(join(DIST, 'index.html'), 'utf8'); const manifest = JSON.parse(readFileSync(join(DIST, '.vite/manifest.json'), 'utf8')); writeFileSync(join(DIST, 'app-shell.html'), shell); +/** + * The dev server would resolve `import owl from '@/assets/owl/owl-point.svg'` + * to /src/assets/owl/owl-point.svg, which dist/ does not have. Every asset the + * build emitted is in the manifest, so an import of one resolves to the built, + * hashed file instead. + */ +function builtAssetUrls() { + const root = `${process.cwd()}/`; + return { + name: 'prerender-built-asset-urls', + enforce: 'pre', + load(id) { + const path = id.split('?')[0]; + if (!path.startsWith(root)) return undefined; + const entry = manifest[path.slice(root.length)]; + if (!entry || entry.isEntry || entry.isDynamicEntry || /\.[cm]?[jt]sx?$/.test(path)) return undefined; + return `export default ${JSON.stringify(`/${entry.file}`)};`; + }, + }; +} + // Vite's SSR loader runs the page modules in Node with the app's own config and aliases. const server = await createServer({ mode: 'production', appType: 'custom', logLevel: 'warn', + plugins: [builtAssetUrls()], server: { middlewareMode: true, hmr: false, ws: false }, }); try { const { prerenderPages } = await server.ssrLoadModule('/src/prerender/entry.tsx'); const pages = await prerenderPages({ landingModified: landingModified() }); for (const page of pages) { + // A source path in a page is a file the site does not serve. + const sourceUrl = page.body.match(/["'(]\/src\/[^"')]*/); + if (sourceUrl) throw new Error(`prerender: ${page.route} references ${sourceUrl[0].slice(1)}, which is not in dist/`); const out = join(DIST, page.file); mkdirSync(dirname(out), { recursive: true }); writeFileSync(out, pageHtml(shell, page, pageAssets(manifest, page.module, shell))); From be18a4c1f59599e8e7024a06f1d5db9742ba6091 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 27 Oct 2026 08:52:36 +0000 Subject: [PATCH 02/12] Sentence case and shorter copy across the app Headings, toasts, dialog titles, form labels and section names move to sentence case (polish study P1-1, P2-8). Proper nouns, tool names used as names, speech and role names, and the legal and SEO strings keep their capitals. Buttons and labels keep their words; only their case changes. Where the study gave new copy for a heading or a message, it is used in English, Spanish and Hungarian: the tool pages' empty panels (P1-4), the one-time intro (P1-2), the 404 heading (P1-3), the limit dialogs, the tour's last step (P2-7), landing headings (P2-8) and the provider error tail "Try again in a minute." (P3-1). Ellipses use the single character (P2-6). Running text says "library"; the page title says "Source library". The flowchart's starter node labels keep "Your Thesis": saved charts are matched to their translation key by that exact text. home.title and home.subtitle had no callers and are gone (P3-3). Unit tests, e2e specs, verify-debatelab recipes and feature notes follow the new strings. The production canary and staging specs match the Ask placeholder without its ellipsis, so they pass before and after deploy. Co-Authored-By: Claude Opus 5.5 --- .../verify-debatelab/features/account-data.md | 4 +- .../verify-debatelab/features/ai-tools.md | 8 +- .../features/case-generation.md | 16 +- .../features/current-debate.md | 4 +- .../features/library-sources.md | 8 +- .../features/live-generation.md | 4 +- .../verify-debatelab/features/practice.md | 4 +- .../verify-debatelab/features/usage-limits.md | 6 +- .../recipes/account-data.spec.ts | 2 +- .../verify-debatelab/recipes/account.spec.ts | 8 +- .../verify-debatelab/recipes/ai-tools.spec.ts | 16 +- .../recipes/billing-states.spec.ts | 18 +- .../verify-debatelab/recipes/billing.spec.ts | 6 +- .../recipes/case-generation.spec.ts | 18 +- .../recipes/current-debate.spec.ts | 16 +- .../verify-debatelab/recipes/library.spec.ts | 6 +- .../recipes/live-generation.spec.ts | 12 +- .../recipes/model-defaults.spec.ts | 2 +- .../recipes/usage-limits.spec.ts | 10 +- docs/operations.md | 4 +- e2e/admin-announcements.spec.ts | 2 +- e2e/animation-polish.spec.ts | 22 +- e2e/authed-pages.spec.ts | 4 +- e2e/first-run.spec.ts | 6 +- e2e/full-stack/account-data.spec.ts | 14 +- e2e/full-stack/account-deletion.spec.ts | 2 +- e2e/full-stack/ai-tools.spec.ts | 10 +- e2e/full-stack/auth.spec.ts | 2 +- e2e/full-stack/billing.spec.ts | 6 +- e2e/full-stack/case-generation.spec.ts | 18 +- e2e/full-stack/current-debate.spec.ts | 8 +- e2e/full-stack/debate-context.spec.ts | 4 +- e2e/full-stack/first-run.spec.ts | 6 +- e2e/home-navigation.spec.ts | 2 +- e2e/live/models.spec.ts | 6 +- e2e/live/research.spec.ts | 6 +- e2e/loading-performance.spec.ts | 4 +- e2e/model-matrix/generate.spec.ts | 2 +- e2e/navigation-performance.spec.ts | 2 +- e2e/production/canary.spec.ts | 4 +- e2e/staging-switch.spec.ts | 4 +- e2e/staging/authed-flows.spec.ts | 6 +- .../features/debate/DebateContextControl.tsx | 6 +- .../features/tools/result-actions.tsx | 2 +- src/lib/ai/error-display.ts | 6 +- src/lib/ai/sources.test.ts | 2 +- src/lib/debateowl-intro.ts | 2 +- src/locales/en.json | 509 +++++++++--------- src/locales/es.json | 87 ++- src/locales/hu.json | 83 ++- src/pages/BrainstormPage.tsx | 2 +- src/pages/FactoidsPage.tsx | 2 +- src/pages/HistoryPage.tsx | 2 +- src/pages/LibraryPage.tsx | 2 +- src/test/a11y/names-and-headings.test.tsx | 14 +- .../components/EnvironmentSwitcher.test.tsx | 8 +- .../components/LimitReachedModal.test.tsx | 6 +- .../components/SubscriptionStatus.test.tsx | 4 +- src/test/generate/generate-form-role.test.tsx | 2 +- src/test/i18n-untranslated.test.ts | 3 +- src/test/onboarding/debateowl-intro.test.tsx | 4 +- src/test/pages/SharedPage.test.tsx | 4 +- 62 files changed, 529 insertions(+), 533 deletions(-) diff --git a/.claude/skills/verify-debatelab/features/account-data.md b/.claude/skills/verify-debatelab/features/account-data.md index 846bd68e..47656e47 100644 --- a/.claude/skills/verify-debatelab/features/account-data.md +++ b/.claude/skills/verify-debatelab/features/account-data.md @@ -6,11 +6,11 @@ From Settings, a signed-in user can download everything saved to the account as - `acct-export`: `Download my data` saves `debatelab-data-YYYY-MM-DD.json` with `format: "debatelab-account-export"`, the caller's `account` (`id`, `email`), and `data` (debates, sources, and saved results). Another account's rows never appear, even when the request names that account's id. - `acct-delete`: `Delete account` opens `Delete your account?`. Its confirm button stays disabled until the field labelled `Type your email to confirm` holds the account email. After deletion, the toast reads `Account deleted` and the app returns to `/`. The auth user and every row mentioning its id, in the `public` and `private` schemas, are gone. Nobody else's rows change, and the old session gets `401`. -- `acct-erase`: `Delete all my data` with the same email confirmation shows `Data Deleted` and empties the account's sources and debates. A bystander's rows stay. This is covered by `e2e/full-stack/account-data.spec.ts`, not by a recipe. +- `acct-erase`: `Delete all my data` with the same email confirmation shows `Data deleted` and empties the account's sources and debates. A bystander's rows stay. This is covered by `e2e/full-stack/account-data.spec.ts`, not by a recipe. ## How to get to it (user POV) -- Settings: the gear link named `Settings` in the header, or `/app/settings`. `Download my data` is under `Your data` in the Account card. `Delete all my data` and `Delete account` are in the Danger Zone card. +- Settings: the gear link named `Settings` in the header, or `/app/settings`. `Download my data` is under `Your data` in the Account card. `Delete all my data` and `Delete account` are in the Delete data card. ## Driving it with verify.sh + Playwright diff --git a/.claude/skills/verify-debatelab/features/ai-tools.md b/.claude/skills/verify-debatelab/features/ai-tools.md index 155e4db3..1b15b22a 100644 --- a/.claude/skills/verify-debatelab/features/ai-tools.md +++ b/.claude/skills/verify-debatelab/features/ai-tools.md @@ -22,10 +22,10 @@ Preconditions: - Doctor passes. The user is signed in. -- **Attack.** On `/app/attack`, fill the field labelled `Your Argument *` and click `Find weaknesses`. The text `FAKE-PROVIDER weakness` appears. `providerCall("attack").user` contains the argument. -- **Brainstorm.** On `/app/brainstorm`, fill the field labelled `Motion / Topic *` and click `Start brainstorming`. With no Current Debate, the dialog `Start a debate with this motion?` opens first: `chooseForMotion(page, "create")` (or `"once"`). The text `FAKE-PROVIDER idea 1` appears. `providerCall("brainstorm").user` contains `Motion: `, and the `brainstorms` row is filed in the new debate. With a Current Debate, the field is prefilled and no dialog opens unless the motion differs. -- **Factoids.** On `/app/factoids`, fill the field labelled `Statement *` and click `Find Factoids`. The text `FAKE-PROVIDER search suggestion` appears. -- **Ask, streamed.** On `/app/ask`, fill the placeholder `Ask your debate coach...` and click the button named `Send message`. The text `FAKE-PROVIDER streamed answer: ...` appears. `fake.requests()` features contain `ask-stream` and not `ask`. `ai_requests` gets `{feature: "ask", status: "success"}`. +- **Attack.** On `/app/attack`, fill the field labelled `Your argument *` and click `Find weaknesses`. The text `FAKE-PROVIDER weakness` appears. `providerCall("attack").user` contains the argument. +- **Brainstorm.** On `/app/brainstorm`, fill the field labelled `Motion / topic *` and click `Start brainstorming`. With no Current Debate, the dialog `Start a debate with this motion?` opens first: `chooseForMotion(page, "create")` (or `"once"`). The text `FAKE-PROVIDER idea 1` appears. `providerCall("brainstorm").user` contains `Motion: `, and the `brainstorms` row is filed in the new debate. With a Current Debate, the field is prefilled and no dialog opens unless the motion differs. +- **Factoids.** On `/app/factoids`, fill the field labelled `Statement *` and click `Find factoids`. The text `FAKE-PROVIDER search suggestion` appears. +- **Ask, streamed.** On `/app/ask`, fill the placeholder `Ask your debate coach…` and click the button named `Send message`. The text `FAKE-PROVIDER streamed answer: ...` appears. `fake.requests()` features contain `ask-stream` and not `ask`. `ai_requests` gets `{feature: "ask", status: "success"}`. - **Ask, cut off.** Call `await fake.truncate("ask-stream")` and send. The stream ends with `finish_reason: "length"`, its `final` event's output carries `replyTruncated: true`, and the partial answer stays with `This answer hit its length limit and stops early...` under `Uncertainties:`. No `Response was interrupted` toast. Fake mode only. - **Ask, cited.** Open the `Options` button above the message box, click `#citation-mode`, close it with Escape, then send. The text `FAKE-PROVIDER answer` appears, and `providerCall("ask")` fired. diff --git a/.claude/skills/verify-debatelab/features/case-generation.md b/.claude/skills/verify-debatelab/features/case-generation.md index d0faa56c..e6cbd030 100644 --- a/.claude/skills/verify-debatelab/features/case-generation.md +++ b/.claude/skills/verify-debatelab/features/case-generation.md @@ -6,8 +6,8 @@ A signed-in user enters a motion on Generate and receives a structured debate ca - `gen-create` generates a case from the Current Debate's motion. The `ai` Edge Function calls the provider and records `ai_requests` with `feature: generate, status: success`. The case is filed in the debate automatically (`cases.debate_id`, `side: pro`); there is no Save button in a debate. - `gen-grounded` includes active Library sources in the system prompt (see [library-sources](./library-sources.md)). -- `gen-error` shows `Generation Failed` on a provider error, and the button becomes enabled again for a retry. -- `gen-quota` shows the dialog `Daily Access Paused` when the account has no quota, without calling the provider. +- `gen-error` shows `Generation failed` on a provider error, and the button becomes enabled again for a retry. +- `gen-quota` shows the dialog `Daily AI uses used up` when the account has no quota, without calling the provider. - `gen-save` saves a one-off case (the motion dialog answered with `Not now, use it once`) under a title into `cases` with `debate_id: null`. It appears in History under `One-off and unsorted work`, including from a fresh browser context. - `gen-regenerate` regenerates one section. Only that section shows the overlay `Regenerating
…`; the rest of the case and the result toolbar stay on screen, and every regenerate button and the form's submit button are disabled until it finishes. - `gen-export` downloads a `.pdf` (starting with `%PDF`) and a `.docx` (starting with `PK`). @@ -24,24 +24,24 @@ Preconditions: - Doctor passes. The recipe `recipes/case-generation.spec.ts` covers every sub-feature below: `bin/verify.sh drive recipes/case-generation`. -- **Start the debate.** `await startDebate(page, motion)`. On `/app/generate`, the textbox named `Motion / Topic *` already holds the motion. +- **Start the debate.** `await startDebate(page, motion)`. On `/app/generate`, the textbox named `Motion / topic *` already holds the motion. - **Generate.** Click the button named `Generate debate case`. The text `FAKE-PROVIDER case thesis` and `FAKE-PROVIDER contention 3` appear, along with a toast `Generate complete`. Without a Current Debate, fill the motion yourself and answer the dialog `Start a debate with this motion?` with `chooseForMotion`. - **Provider side effect.** `providerCall("generate")`'s `user` contains `Motion: `. `ai_requests` for the user contains `{feature: "generate", status: "success"}`. - **Filed in the debate.** `cases` for the user is one row whose `debates(motion)` is the motion. On a fresh browser context, `/app/history` shows it as a heading in the `Cases` tab panel, and `Load` reopens it. - **Save (one-off).** Generate with no debate and choose `chooseForMotion(page, "once")`. Click the button named `Save` (use `exact`), fill the dialog field labelled `Case title`, and click the dialog's `Save` button. `cases` for the user is `[{title, debate_id: null}]`. On a fresh browser context, `/app/history` shows `One-off and unsorted work` with a list item holding the title. -- **Error.** Call `await fake.fail("generate", 500)`, start the debate, and generate. An `alert` holding `Generation Failed` stays above the results with a `Retry` button; clicking `Retry` succeeds and removes it. +- **Error.** Call `await fake.fail("generate", 500)`, start the debate, and generate. An `alert` holding `Generation failed` stays above the results with a `Retry` button; clicking `Retry` succeeds and removes it. - **Cut off.** Call `await fake.truncate("generate")`, start the debate, and generate. The first reply stops halfway with `finish_reason: "length"`; the Edge Function retries once with a larger cap, and the case appears with no error. `fake.requests()` has two `generate` entries, the second with a larger `maxTokens`. Fake mode only. - **Cancel.** While a generation runs, the results column shows `Writing your case…` and a `Cancel` button. Cancel stops the request and shows no error. -- **Quota.** Update `user_settings` to set `daily_limit_override: 0` through `admin`, start the debate, then generate. The dialog shows `Daily Access Paused`. `fake.requests()` has no `generate` entry. -- **Regenerate a section.** After generating, click the button named `Regenerate Arguments` (the section titles are `Case Structure`, `Arguments`, `Predicted Opponent Case`, `Prepared Counters`). Hold the request with `page.route("**/functions/v1/ai", ...)` to check the in-flight state: a `status` with `Regenerating Arguments` is visible and `Regenerate Case Structure` is disabled. The toast `Section regenerated` follows. A full generation shows no toast: the result appears in place (and scrolls into view on phones). +- **Quota.** Update `user_settings` to set `daily_limit_override: 0` through `admin`, start the debate, then generate. The dialog shows `Daily AI uses used up`. `fake.requests()` has no `generate` entry. +- **Regenerate a section.** After generating, click the button named `Regenerate Arguments` (the section titles are `Case structure`, `Arguments`, `Predicted opponent case`, `Prepared counters`). Hold the request with `page.route("**/functions/v1/ai", ...)` to check the in-flight state: a `status` with `Regenerating Arguments` is visible and `Regenerate Case structure` is disabled. The toast `Section regenerated` follows. A full generation shows no toast: the result appears in place (and scrolls into view on phones). - **Flowchart.** After generating in the debate, open `/app/flowchart` and click the button named `Build from current case` (Hungarian: `Létrehozás a jelenlegi esetből`). It appears only when the case belongs to the Current Debate, or to one-off work while no debate is chosen. The contention claims are visible, and an edge is a button named `From Thesis to Argument 1`. - **Export.** Wait for `page.waitForEvent("download")` while clicking the button named `PDF` or `DOCX` (use `exact`). Check the suggested filename's extension and the file's magic bytes. ## Gotchas -- While any generation is running, the form's submit button reads `Generating case...`, not `Generate debate case`. Find it by `[data-tour="generate-button"]` then. +- While any generation is running, the form's submit button reads `Generating case…`, not `Generate debate case`. Find it by `[data-tour="generate-button"]` then. -- The Generate page shows the heading `Ready to Generate` before the first run. Wait for it after navigating, because lazy route chunks load slowly on a cold Vite. +- The Generate page shows the heading `Your case will appear here` before the first run. Wait for it after navigating, because lazy route chunks load slowly on a cold Vite. - `Save` appears both on the result toolbar and in the dialog. Scope the second click to `getByRole("dialog")`. The toolbar `Save` exists only for one-off results. - A case filed in a debate is titled with the motion, which the header control also shows (truncated and hidden). Look for it inside the History panel, not with a page-wide `getByText`. - The client files a result after it renders. Wait for the result on screen (and the row, if the next step reloads) before a `page.goto`, or the reload can drop it. diff --git a/.claude/skills/verify-debatelab/features/current-debate.md b/.claude/skills/verify-debatelab/features/current-debate.md index 286b11e5..f7de8ba8 100644 --- a/.claude/skills/verify-debatelab/features/current-debate.md +++ b/.claude/skills/verify-debatelab/features/current-debate.md @@ -32,12 +32,12 @@ Preconditions: - **Use once.** With a debate, submit a different motion. The dialog `This is a different motion` opens. `chooseForMotion(page, "once")`. End state: the provider call's `system` has no debate marker, and the header still names the old debate. - **Dashboard.** `/app/debate`: `current-debate-motion` has the motion, `debate-side-pro` reads `In favor`, `debate-side-con` reads `Against`, and `debate-sources` and `debate-recent` list only this debate's work. - **Switch.** Click `debateControl(page)`, then the dialog's `Switch debate`, then `Switch to `. End state: the Library shows only that debate's sources. In History, the `Brainstorms` and `Cases` tabs show only its results. -- **Tour.** Seed `debate-trainer-onboarding` with `hasCompletedOnboarding: false` once per session (see the recipe), sign in, and click the tooltip's `Start debate`. Fill `Motion`, click `Start debate`, click `[data-test-id="button-primary"]` twice, then `Get started!`. End state: one `debates` row is current, and Generate opens prefilled with no dialog. +- **Tour.** Seed `debate-trainer-onboarding` with `hasCompletedOnboarding: false` once per session (see the recipe), sign in, and click the tooltip's `Start debate`. Fill `Motion`, click `Start debate`, click `[data-test-id="button-primary"]` twice, then `Get started`. End state: one `debates` row is current, and Generate opens prefilled with no dialog. ## Gotchas - The motion dialogs open after the tool's submit button and before its AI request. Create the `aiResponse()` promise before the click, answer the dialog, then await it. Otherwise the wait times out with the dialog still open. -- `chooseForMotion` scopes itself to the two motion dialogs by name, because the next step may open its own dialog, such as the paywall `Free Trial Ended`. +- `chooseForMotion` scopes itself to the two motion dialogs by name, because the next step may open its own dialog, such as the paywall `Your free trial has ended`. - The Library's `Add source` button is disabled until a Current Debate exists. Start one first. - `getByRole("button", { name: "Start" })` also matches the header's `Start debate`. Use `exact: true` for other Start buttons, such as the speech timer's. - In live mode, research can save web sources to the Library. Check the rows a recipe added by title rather than counting all of them. diff --git a/.claude/skills/verify-debatelab/features/library-sources.md b/.claude/skills/verify-debatelab/features/library-sources.md index c19210be..425514f7 100644 --- a/.claude/skills/verify-debatelab/features/library-sources.md +++ b/.claude/skills/verify-debatelab/features/library-sources.md @@ -4,11 +4,11 @@ Users save reference texts to their Library. The Library belongs to the Current ## Sub-features -- `lib-add` adds a source with a title and content. It shows at once while it saves, then a toast `Saved to the Library`; its toggle is on, and the banner `Grounding Active` shows. +- `lib-add` adds a source with a title and content. It shows at once while it saves, then a toast `Saved <title> to the Library`; its toggle is on, and the banner `Grounding active` shows. - `lib-toggle` pauses a source through its switch, which keeps it out of prompts. - `lib-offline` offline, the switch does not move and a toast says `You are offline. Reconnect and try again.` A write that fails online puts the switch back with a toast. - `lib-edit-while-switching` the source editor's untouched fields follow the stored source while it is open (a switch that rolls back shows as rolled back), and `Save` sends only the fields edited in the sheet. Text typed while a save runs stays an edit and goes out with the next `Save`. -- `lib-dedupe` keeps one row per web page per debate. URLs match after normalizing (host without `www.`, no scheme, fragment, tracking parameters or trailing slash). Importing a page the debate has warns `This page is already in this debate's Library.` before any fetch; research refreshes the existing row instead of adding one. +- `lib-dedupe` keeps one row per web page per debate. URLs match after normalizing (host without `www.`, no scheme, fragment, tracking parameters or trailing slash). Importing a page the debate has warns `This page is already in this debate's library.` before any fetch; research refreshes the existing row instead of adding one. - `lib-layout` on a phone the row's controls take their own line, so a long title gets the row's width and two lines. - `lib-ground` includes only active sources' text in the `system` of later AI calls (Generate, Brainstorm, and others). - `lib-delete` deletes a source. It stays gone after a reload and in the `sources` table. @@ -27,7 +27,7 @@ Preconditions: - Doctor passes. The user is signed in (`signedIn` fixture). A Current Debate exists: `await startDebate(page, motion)`. Without one, `Add source` stays disabled. -- **Add.** Click the button named `Add source`. Fill the textbox named `Title` (exact), fill the textbox named `Content` (exact) with a unique marker, and click `Save to Library`. Leaving either empty shows an inline error under the field and moves focus to it. The switch named `Toggle <title>` is checked. `sources` has a row with `is_active: true` and the debate's `debate_id`. +- **Add.** Click the button named `Add source`. Fill the textbox named `Title` (exact), fill the textbox named `Content` (exact) with a unique marker, and click `Save to library`. Leaving either empty shows an inline error under the field and moves focus to it. The switch named `Toggle <title>` is checked. `sources` has a row with `is_active: true` and the debate's `debate_id`. - **Toggle.** Click the switch named `Toggle <title>`. It becomes not checked. - **Offline toggle.** `page.context().setOffline(true)`, click the switch: the offline toast shows, the switch stays checked, and the row's `is_active` is unchanged. Still offline, open the source, change its `Content` and click `Save`: the toast `Could not save this source.` shows and the typed text stays in the sheet. Set offline back to false and click `Save` again: `Source updated` shows and the row has the new content with `is_active` unchanged. - **Duplicate import.** Insert a URL source through `admin`, then import the same page with `?utm_source=x#top`: the duplicate warning shows and no `import-source` request is made. @@ -36,7 +36,7 @@ Preconditions: - **Delete.** Click the button named `Delete <title>` (use `exact`). It deletes at once, with no confirmation, and a toast `Deleted <title>` offers `Undo`, which saves the source again. The switch count drops to 0, and stays 0 after `page.reload()`. `sources` no longer has the title. - **Counts.** Insert 7 URL sources through `admin`, 5 with `is_active: true`. `/app` test id `home-current-debate` contains `7 sources (5 active)`; on `/app/ask` test id `debate-context-control` contains `Using 5 sources`. - **Import.** In the add panel, fill `Import from URL` and click `Import URL`. The `Import preview` panel appears, then `Save imported source` stores a row whose `origin` is the URL. -- **Erase.** On Settings, click `Delete all my data`. In the dialog, fill `Type your email to confirm` with `user.email` and click the dialog's `Delete all my data`. The text `Data Deleted` appears, and `sources` for the user is `[]`. +- **Erase.** On Settings, click `Delete all my data`. In the dialog, fill `Type your email to confirm` with `user.email` and click the dialog's `Delete all my data`. The text `Data deleted` appears, and `sources` for the user is `[]`. ## Gotchas diff --git a/.claude/skills/verify-debatelab/features/live-generation.md b/.claude/skills/verify-debatelab/features/live-generation.md index 110fca62..2defe01e 100644 --- a/.claude/skills/verify-debatelab/features/live-generation.md +++ b/.claude/skills/verify-debatelab/features/live-generation.md @@ -4,7 +4,7 @@ Every AI answer appears while the model writes it. Generate, Attack, Brainstorm, ## Sub-features -- `live-generate`: Generate's draft shows `Case Structure` and later sections before the result, then the full result and its toolbar replace it. +- `live-generate`: Generate's draft shows `Case structure` and later sections before the result, then the full result and its toolbar replace it. - `live-cancel`: `Cancel` on the draft stops the request; no draft, result, or error stays on the page. - `live-tools`: Attack, Brainstorm, Factoids, and cited Ask each ask for live generation and receive an event stream. - `live-practice`: Crossfire turns and debriefs and Sparring speeches and scorecards ask for live generation (checked in `recipes/practice.spec.ts` with `expectStreamed`). @@ -20,7 +20,7 @@ Preconditions: - Doctor passes. The user is signed in. `startDebate(page, motion)` avoids the motion dialog. -- **Draft.** Click `Generate debate case`. `getByTestId("live-draft")` appears, holding a region named `Case Structure`, one `live-caret`, and a `Cancel` button. It disappears when the result renders. +- **Draft.** Click `Generate debate case`. `getByTestId("live-draft")` appears, holding a region named `Case structure`, one `live-caret`, and a `Cancel` button. It disappears when the result renders. - **Request.** The page's `ai` request body has `stream: true`. The response is `text/event-stream`: `phase` events (`research`, `writing`, `checking`), many `delta` events with the model's raw text, an optional `reset` when a repair retry starts the answer again, and exactly one final `result` event `{status, body}` holding what a plain request would return. Read it with `readAIResponse(response)`. - **Provider.** `expectStreamed(["generate"])`: every provider call for the feature had `stream: true`. Research sub-calls (`research-decision`, `research-queries`) are never streamed. - **Errors.** Checks before the feature (auth, input, quota, model access) still answer with plain JSON and their real status. A failure after streaming starts arrives as a `result` with the error status, which the client turns into the same error a plain request gets. diff --git a/.claude/skills/verify-debatelab/features/practice.md b/.claude/skills/verify-debatelab/features/practice.md index ed565170..68ca1112 100644 --- a/.claude/skills/verify-debatelab/features/practice.md +++ b/.claude/skills/verify-debatelab/features/practice.md @@ -30,10 +30,10 @@ Preconditions: - **Speeches.** For each user turn, fill the textbox named `Draft for <turn>` (`Affirmative constructive`, `First affirmative rebuttal`, `Second affirmative rebuttal`) and click `Submit speech`. Each turn opens with a stopped clock (test id `sparring-ready`, button `Start speaking`); filling the draft starts it. After each of the first two, the last `FAKE-PROVIDER opponent speech` is visible. - **Verdict.** The heading `Here is what happened in the room.` and the text `FAKE-PROVIDER verdict: the user had the edge.` appear. `providerCall("sparring").user` contains `SPARRING ACTION:`. `sparring_sessions` statuses for the user equal `["complete"]`, and `select("side,debates(motion)")` gives `{side: "pro", debates: {motion}}`. In Hungarian the radar chart axis (`.recharts-polar-angle-axis-tick text`) reads `Logika`, `Bizonyíték`, `Előadás`, `Stratégia`. - **Hold the quota check.** `page.route("**/functions/v1/ai")` and wait before `route.fallback()` when the body has `quotaCheck: true`; register it after the page loads, since setup asks once on load. -- **Start Crossfire.** On `/app/crossfire`, fill test id `crossfire-resolution` and `crossfire-argument`. Click `mode-question-ai` (or `mode-ai-questions-user`), then `start-crossfire`, then `chooseForMotion(page, "create")`. +- **Start crossfire.** On `/app/crossfire`, fill test id `crossfire-resolution` and `crossfire-argument`. Click `mode-question-ai` (or `mode-ai-questions-user`), then `start-crossfire`, then `chooseForMotion(page, "create")`. - **Crossfire turn.** Fill `crossfire-input` and click `submit-turn`. The text `FAKE-PROVIDER crossfire answer` and `Direct answer` appear. `providerCall("crossfire")` and `providerCall("jev")` both fired. - **Debrief.** Click `end-crossfire`. Test id `crossfire-debrief` is visible. `crossfires` for the user equals `[{title: <resolution>, debates: {motion: <resolution>}}]`. -- **Out of requests.** Through `admin`, set `user_settings.daily_limit_override` to N, upsert today's `daily_usage_counters` row (`quota_key: "ai"`, `request_count: N`; what the `ai` function checks), and insert N `ai_requests` rows. The usage line and Crossfire and Sparring read only the counter (`ai_usage_today` and the quota check). Start Crossfire in `mode-ai-questions-user`: a `dialog` containing `N of N` opens and `start-crossfire` is back. On `/app/sparring`, `sparring-request-shortfall` says the same `You've used N of N included AI uses today.` +- **Out of requests.** Through `admin`, set `user_settings.daily_limit_override` to N, upsert today's `daily_usage_counters` row (`quota_key: "ai"`, `request_count: N`; what the `ai` function checks), and insert N `ai_requests` rows. The usage line and Crossfire and Sparring read only the counter (`ai_usage_today` and the quota check). Start crossfire in `mode-ai-questions-user`: a `dialog` containing `N of N` opens and `start-crossfire` is back. On `/app/sparring`, `sparring-request-shortfall` says the same `You've used N of N included AI uses today.` ## Gotchas diff --git a/.claude/skills/verify-debatelab/features/usage-limits.md b/.claude/skills/verify-debatelab/features/usage-limits.md index 3448546d..bba4aac2 100644 --- a/.claude/skills/verify-debatelab/features/usage-limits.md +++ b/.claude/skills/verify-debatelab/features/usage-limits.md @@ -6,10 +6,10 @@ Crossfire and Sparring are charged per round, not per call: the round's first ca ## Sub-features -- `usage-weighted`: with 4 units left, Opus is refused (`Daily Access Paused`, "This model counts as 5 uses, and you have 4 left today.") without a provider call or a counter change; switching to Claude Haiku 5.5 runs and uses 1 unit. -- `usage-weekly`: with `ai_requests.cap_cost_usd` for this week at or above the budget, the next request opens `Weekly AI Allowance Used` with an `OK` button and no upgrade offer, makes no provider call, and leaves the daily counter alone. +- `usage-weighted`: with 4 units left, Opus is refused (`Daily AI uses used up`, "This model counts as 5 uses, and you have 4 left today.") without a provider call or a counter change; switching to Claude Haiku 5.5 runs and uses 1 unit. +- `usage-weekly`: with `ai_requests.cap_cost_usd` for this week at or above the budget, the next request opens `Weekly AI allowance used` with an `OK` button and no upgrade offer, makes no provider call, and leaves the daily counter alone. -- `usage-weekly-sparring`: with the budget reached, the Sparring setup's `session-cost` shows the translated weekly message from the catalog (not the server's English reason) before Start, and Start opens `Weekly AI Allowance Used` without opening a round or calling the provider. +- `usage-weekly-sparring`: with the budget reached, the Sparring setup's `session-cost` shows the translated weekly message from the catalog (not the server's English reason) before Start, and Start opens `Weekly AI allowance used` without opening a round or calling the provider. - `usage-session-sparring` (QB-01): with 1 request left of 5, a Lincoln-Douglas round on Claude Haiku 5.5 runs both opponent replies and the judge; the counter moves 4 to 5 once and `ai_quota_sessions` shows `calls_used: 3`. - `usage-session-crossfire` (QB-05, MD-03): an AI-led Crossfire round with two answers and the debrief makes four calls and one charge; the setup hint is gone once the round runs. - `usage-refund-reload` (QB-04, MD-13): reloading while Brainstorm streams logs the run `cancelled`/`aborted` and gives its units back. Fake mode only. diff --git a/.claude/skills/verify-debatelab/recipes/account-data.spec.ts b/.claude/skills/verify-debatelab/recipes/account-data.spec.ts index 0de92a80..f95e36ea 100644 --- a/.claude/skills/verify-debatelab/recipes/account-data.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/account-data.spec.ts @@ -14,7 +14,7 @@ async function addSource(page: Page, title: string, content: string) { await page.getByRole("button", { name: "Add source" }).click(); await page.getByRole("textbox", { name: "Title", exact: true }).fill(title); await page.getByRole("textbox", { name: "Content", exact: true }).fill(content); - await page.getByRole("button", { name: "Save to Library" }).click(); + await page.getByRole("button", { name: "Save to library" }).click(); await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked(); } diff --git a/.claude/skills/verify-debatelab/recipes/account.spec.ts b/.claude/skills/verify-debatelab/recipes/account.spec.ts index ef5400af..10a92cf9 100644 --- a/.claude/skills/verify-debatelab/recipes/account.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/account.spec.ts @@ -7,7 +7,7 @@ import { admin, AI_TIMEOUT, aiResponse, expect, expectProviderCalls, signIn, tes test("acct-language: the output language follows the account to a new device and reaches the model", async ({ signedIn: page, user, browser, evidence }) => { await page.goto("/app/settings"); - await page.getByRole("combobox", { name: "AI Output Language" }).click(); + await page.getByRole("combobox", { name: "AI output language" }).click(); await page.getByRole("option", { name: "Magyar" }).click(); await expect.poll(async () => { const { data } = await admin.from("user_settings").select("output_language").eq("user_id", await user.userId()).single(); @@ -21,7 +21,7 @@ test("acct-language: the output language follows the account to a new device and await other.addInitScript(() => localStorage.setItem("debate-trainer-onboarding", JSON.stringify({ state: { hasCompletedOnboarding: true, isTourRunning: false }, version: 0 }))); await signIn(other, user); await other.goto("/app/attack"); - await other.getByLabel("Your Argument *").fill("Free transit cuts congestion because riders leave their cars at home."); + await other.getByLabel("Your argument *").fill("Free transit cuts congestion because riders leave their cars at home."); const response = aiResponse(other, "attack"); await other.getByRole("button", { name: "Find weaknesses" }).click(); const body = await response; @@ -53,8 +53,8 @@ test("flow-edit: a flowchart node is deleted, the chart is saved, and it loads b await page.getByRole("button", { name: "Save", exact: true }).click(); const dialog = page.getByRole("dialog"); await dialog.getByRole("textbox").fill(title); - await dialog.getByRole("button", { name: "Save Flowchart" }).click(); - await expect(page.getByText("Flowchart saved successfully").first()).toBeVisible(); + await dialog.getByRole("button", { name: "Save flowchart" }).click(); + await expect(page.getByText("Flowchart saved").first()).toBeVisible(); const { data } = await admin.from("flowcharts").select("title,nodes").eq("user_id", await user.userId()); expect(data?.map((row) => row.title)).toEqual([title]); expect(JSON.stringify(data?.[0].nodes)).not.toContain("Argument 1"); diff --git a/.claude/skills/verify-debatelab/recipes/ai-tools.spec.ts b/.claude/skills/verify-debatelab/recipes/ai-tools.spec.ts index 42f4693c..4786099e 100644 --- a/.claude/skills/verify-debatelab/recipes/ai-tools.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/ai-tools.spec.ts @@ -5,7 +5,7 @@ import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, expect, expectProviderC test("attack-run: Attack analyses a pasted argument", async ({ signedIn: page, evidence }) => { const argument = "Free transit cuts congestion because riders leave their cars at home, so cities should abolish fares."; await page.goto("/app/attack"); - await page.getByLabel("Your Argument *").fill(argument); + await page.getByLabel("Your argument *").fill(argument); await evidence.step("Attack: pasted an argument and clicked Find weaknesses"); const response = aiResponse(page, "attack"); await page.getByRole("button", { name: "Find weaknesses" }).click(); @@ -22,7 +22,7 @@ test("attack-run: Attack analyses a pasted argument", async ({ signedIn: page, e test("brainstorm-run: Brainstorm returns ranked angles", async ({ signedIn: page, user, evidence }) => { const motion = "Universal basic income should replace targeted welfare"; await page.goto("/app/brainstorm"); - await page.getByLabel("Motion / Topic *").fill(motion); + await page.getByLabel("Motion / topic *").fill(motion); const response = aiResponse(page, "brainstorm"); await page.getByRole("button", { name: "Start brainstorming" }).click(); // A new account has no Current Debate, so Brainstorm first offers one for this motion. @@ -47,7 +47,7 @@ test("factoids-run: Factoids researches a statement and keeps only verified cita await page.goto("/app/factoids"); await page.getByLabel("Statement *").fill("Fare-free public transport increases ridership"); const response = aiResponse(page, "factoids"); - await page.getByRole("button", { name: "Find Factoids" }).click(); + await page.getByRole("button", { name: "Find factoids" }).click(); const body = await response; await evidence.json("01-factoids-response", body); const factoids = body.output.factoids as Array<{ shortClaim: string; citation: { url?: string; verified: string } }>; @@ -69,7 +69,7 @@ test("factoids-run: Factoids researches a statement and keeps only verified cita test("ask-stream: Ask streams a coaching answer and records usage", async ({ signedIn: page, user, evidence }) => { await page.goto("/app/ask"); - await page.getByPlaceholder("Ask your debate coach...").fill("In two sentences, how should I weigh access against cost?"); + await page.getByPlaceholder("Ask your debate coach…").fill("In two sentences, how should I weigh access against cost?"); const stream = page.waitForResponse((response) => response.url().endsWith("/functions/v1/ai-stream"), { timeout: AI_TIMEOUT }); await page.getByRole("button", { name: "Send message" }).click(); const response = await stream; @@ -127,7 +127,7 @@ test("ask-bubble: the student's message meets WCAG AA in light and dark, and the await followSystemTheme(page); const question = "How should I weigh **access** against cost?"; await page.goto("/app/ask"); - await page.getByPlaceholder("Ask your debate coach...").fill(question); + await page.getByPlaceholder("Ask your debate coach…").fill(question); const stream = page.waitForResponse((response) => response.url().endsWith("/functions/v1/ai-stream"), { timeout: AI_TIMEOUT }); await page.getByRole("button", { name: "Send message" }).click(); await stream; @@ -166,7 +166,7 @@ test("ask-cutoff: a streamed answer cut off at its length cap says so under the test.skip(LIVE, "only the fake provider can force a cut-off reply"); await fake.truncate("ask-stream"); await page.goto("/app/ask"); - await page.getByPlaceholder("Ask your debate coach...").fill("How should I weigh access against cost?"); + await page.getByPlaceholder("Ask your debate coach…").fill("How should I weigh access against cost?"); const stream = page.waitForResponse((response) => response.url().endsWith("/functions/v1/ai-stream"), { timeout: AI_TIMEOUT }); await page.getByRole("button", { name: "Send message" }).click(); const events = await (await stream).text(); @@ -186,7 +186,7 @@ test("ask-cited: Ask in citation mode uses the structured endpoint", async ({ si await page.getByRole("button", { name: "Options" }).click(); await page.locator("#citation-mode").click(); await page.keyboard.press("Escape"); - await page.getByPlaceholder("Ask your debate coach...").fill("Which evidence supports fare-free transit?"); + await page.getByPlaceholder("Ask your debate coach…").fill("Which evidence supports fare-free transit?"); const response = aiResponse(page, "ask"); await page.getByRole("button", { name: "Send message" }).click(); const body = await response; @@ -202,7 +202,7 @@ test("ask-web: Ask with web search shows the sources it used", async ({ signedIn await page.getByRole("button", { name: "Options" }).click(); await page.locator("#web-search-mode").click(); await page.keyboard.press("Escape"); - await page.getByPlaceholder("Ask your debate coach...").fill("What did recent studies find about fare-free public transport ridership?"); + await page.getByPlaceholder("Ask your debate coach…").fill("What did recent studies find about fare-free public transport ridership?"); const response = aiResponse(page, "ask"); await page.getByRole("button", { name: "Send message" }).click(); const body = await response; diff --git a/.claude/skills/verify-debatelab/recipes/billing-states.spec.ts b/.claude/skills/verify-debatelab/recipes/billing-states.spec.ts index 02cd236b..7ec6ca81 100644 --- a/.claude/skills/verify-debatelab/recipes/billing-states.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/billing-states.spec.ts @@ -30,14 +30,14 @@ type State = { const UPGRADE: Text = { en: "Upgrade to Pro", hu: "Váltás Pro-ra" }; const MANAGE: Text = { en: "Manage subscription", hu: "Előfizetés kezelése" }; -const PAUSED: Text = { en: "Daily Access Paused", hu: "Napi hozzáférés szünetel" }; +const PAUSED: Text = { en: "Daily AI uses used up", hu: "Elfogyott a napi MI-keret" }; const OK: Text = { en: "OK", hu: "Rendben" }; const STATES: State[] = [ { name: "trial", settings: { subscription_tier: "trial", trial_ends_at: at(2.5) }, - badge: { en: "Free Trial", hu: "Ingyenes próba" }, + badge: { en: "Free trial", hu: "Ingyenes próba" }, text: { en: /AI access is active/, hu: /Az MI-hozzáférés aktív/ }, button: UPGRADE, plan: "plan-trial", @@ -112,7 +112,7 @@ const STATES: State[] = [ button: { en: "Resubscribe", hu: "Újra előfizetek" }, plan: null, dailyLimit: null, - refusal: { title: { en: "Pro Subscription Ended", hu: "A Pro-előfizetés véget ért" }, button: { en: "Resubscribe", hu: "Újra előfizetek" } }, + refusal: { title: { en: "Your Pro subscription has ended", hu: "Lejárt a Pro-előfizetésed" }, button: { en: "Resubscribe", hu: "Újra előfizetek" } }, }, ]; @@ -151,7 +151,7 @@ async function chargeAIUnits(userId: string, units: number) { async function brainstorm(page: Page, language: "en" | "hu" = "en") { await page.goto("/app/brainstorm"); - await page.getByLabel(language === "en" ? "Motion / Topic *" : "Indítvány / téma *").fill("Public transport should be free"); + await page.getByLabel(language === "en" ? "Motion / topic *" : "Indítvány / téma *").fill("Public transport should be free"); await page.getByRole("button", { name: language === "en" ? "Start brainstorming" : "Ötletelés indítása" }).click(); if (language === "en") return chooseForMotion(page, "once"); const dialog = page.getByRole("dialog", { name: /^(Indítasz egy vitát ezzel a tétellel\?|Ez egy másik tétel)$/ }); @@ -161,7 +161,7 @@ async function brainstorm(page: Page, language: "en" | "hu" = "en") { async function useHungarian(page: Page) { await page.goto("/app/settings"); - await page.getByLabel("Interface Language").click(); + await page.getByLabel("Interface language").click(); await page.getByRole("option", { name: "Magyar" }).click(); await expect(page.getByText("Előfizetés", { exact: true })).toBeVisible(); } @@ -261,7 +261,7 @@ test("bill-states-es: the Spanish subscription card wraps instead of squeezing", const userId = await user.userId(); await followSystemTheme(page); await page.goto("/app/settings"); - await page.getByLabel("Interface Language").click(); + await page.getByLabel("Interface language").click(); await page.getByRole("option", { name: "Español" }).click(); await expect(page.getByText("Suscripción", { exact: true })).toBeVisible(); const ES: Record<string, { badge: string; button: string }> = { @@ -316,7 +316,7 @@ test("bill-trial-banner: the trial banner counts down, opens the plans and stays await expect(banner).toHaveCount(0); // Once the plan has loaded again (the Settings badge), the banner stays away. await page.goto("/app/settings"); - await expect(page.getByTestId("subscription-card").getByText("Free Trial", { exact: true })).toBeVisible(); + await expect(page.getByTestId("subscription-card").getByText("Free trial", { exact: true })).toBeVisible(); await expect(page.getByTestId("trial-banner")).toHaveCount(0); await evidence.step("Trial banner shows days left, opens the pricing dialog, and stays hidden after dismissal"); }); @@ -346,12 +346,12 @@ test("bill-access: extended access is served; a past-due payer after the grace i }); await brainstorm(page); const dialog = page.getByRole("dialog"); - await expect(dialog.getByText("Payment Failed", { exact: true })).toBeVisible({ timeout: AI_TIMEOUT }); + await expect(dialog.getByText("Payment failed", { exact: true })).toBeVisible({ timeout: AI_TIMEOUT }); await expect(dialog.getByText(/free trial/i)).toHaveCount(0); await expect(dialog.getByRole("button", { name: /Upgrade to Pro/ })).toHaveCount(0); await evidence.shot("ai-payment-failed-refused"); await dialog.getByRole("button", { name: "Update payment method" }).click(); await expect(page).toHaveURL(/\/app\/settings$/); await expect(page.getByText("Payment failed", { exact: true })).toBeVisible(); - await evidence.step("Extended access is served; a past-due payer after the grace gets Payment Failed and goes to Settings"); + await evidence.step("Extended access is served; a past-due payer after the grace gets Payment failed and goes to Settings"); }); diff --git a/.claude/skills/verify-debatelab/recipes/billing.spec.ts b/.claude/skills/verify-debatelab/recipes/billing.spec.ts index 99d8df5b..d8e7a792 100644 --- a/.claude/skills/verify-debatelab/recipes/billing.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/billing.spec.ts @@ -15,14 +15,14 @@ test("bill-promo: an expired account is blocked, redeems a promo code, and gets if (error) throw error; const brainstorm = async () => { await page.goto("/app/brainstorm"); - await page.getByLabel("Motion / Topic *").fill("Public transport should be free"); + await page.getByLabel("Motion / topic *").fill("Public transport should be free"); await page.getByRole("button", { name: "Start brainstorming" }).click(); // No Current Debate, so the motion check asks first; the paywall (or the run) follows it. await chooseForMotion(page, "once"); }; try { await brainstorm(); - await expect(page.getByRole("dialog").getByText("Free Trial Ended")).toBeVisible({ timeout: AI_TIMEOUT }); + await expect(page.getByRole("dialog").getByText("Your free trial has ended")).toBeVisible({ timeout: AI_TIMEOUT }); await evidence.shot("01-blocked"); expect((await providerCalls()).filter((call) => call.feature === "brainstorm")).toEqual([]); @@ -31,7 +31,7 @@ test("bill-promo: an expired account is blocked, redeems a promo code, and gets await page.getByRole("button", { name: "Have a promo code?" }).click(); await page.getByPlaceholder("Enter promo code").fill(code); await page.getByRole("button", { name: "Apply", exact: true }).click(); - await expect(page.getByText("Promo code applied!").first()).toBeVisible(); + await expect(page.getByText("Promo code applied").first()).toBeVisible(); await evidence.shot("02-promo-applied"); const { data } = await admin.from("user_settings").select("subscription_tier").eq("user_id", userId).single(); expect(data?.subscription_tier).toBe("pro"); diff --git a/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts b/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts index 0d9aa323..18158515 100644 --- a/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts @@ -13,8 +13,8 @@ type CaseOutput = { caseStructure: { thesis: string }; contentions: Array<{ clai async function openGenerate(page: import("@playwright/test").Page) { await startDebate(page, motion); await page.goto("/app/generate"); - await expect(page.getByRole("heading", { name: "Ready to Generate" })).toBeVisible(); - await expect(page.getByRole("textbox", { name: "Motion / Topic *" })).toHaveValue(motion); + await expect(page.getByRole("heading", { name: "Your case will appear here" })).toBeVisible(); + await expect(page.getByRole("textbox", { name: "Motion / topic *" })).toHaveValue(motion); } async function generate(page: import("@playwright/test").Page) { @@ -92,8 +92,8 @@ test("gen-create: a case generated in the Current Debate is filed there and reop test("gen-save: a one-off case is saved under a title and reopens from History on a fresh device", async ({ signedIn: page, user, evidence, browser }) => { await page.goto("/app/generate"); - await expect(page.getByRole("heading", { name: "Ready to Generate" })).toBeVisible(); - await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); + await expect(page.getByRole("heading", { name: "Your case will appear here" })).toBeVisible(); + await page.getByRole("textbox", { name: "Motion / topic *" }).fill(motion); const response = aiResponse(page, "generate"); await page.getByRole("button", { name: "Generate debate case" }).click(); // No Current Debate: decline the offer, so the case belongs to no debate until saved. @@ -202,7 +202,7 @@ test("gen-regenerate: regenerating one section keeps the rest of the case and it await expect(overlay).toBeVisible(); await expect(page.getByText(thesis).first()).toBeVisible(); await expect(page.getByRole("button", { name: "PDF", exact: true })).toBeVisible(); - await expect(page.getByRole("button", { name: "Regenerate Case Structure" })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Regenerate Case structure" })).toBeDisabled(); // The form button reads "Generating..." while busy, so find it by its tour anchor. await expect(page.locator('[data-tour="generate-button"]')).toBeDisabled(); await evidence.shot("02-regenerating-one-section"); @@ -214,7 +214,7 @@ test("gen-regenerate: regenerating one section keeps the rest of the case and it await expect(page.getByText("Section regenerated").first()).toBeVisible({ timeout: AI_TIMEOUT }); await expect(overlay).toHaveCount(0); await expect(page.getByText(thesis).first()).toBeVisible(); - await expect(page.getByRole("button", { name: "Regenerate Case Structure" })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Regenerate Case structure" })).toBeEnabled(); await evidence.shot("03-regenerated"); }); @@ -223,7 +223,7 @@ test("gen-error: a provider failure shows an error and the retry succeeds", asyn await openGenerate(page); await page.getByRole("button", { name: "Generate debate case" }).click(); // The failure stays on the page, with Retry, until the next run. - const failure = page.getByRole("alert").filter({ hasText: "Generation Failed" }); + const failure = page.getByRole("alert").filter({ hasText: "Generation failed" }); await expect(failure).toBeVisible({ timeout: AI_TIMEOUT }); await evidence.shot("01-failed"); await expect(page.getByRole("button", { name: "Generate debate case" })).toBeEnabled(); @@ -244,7 +244,7 @@ test("gen-cutoff: a case cut off at its length cap is retried once with a larger await page.getByRole("button", { name: "Generate debate case" }).click(); const body = await response; await expect(page.getByText((body.output as unknown as CaseOutput).caseStructure.thesis).first()).toBeVisible(); - await expect(page.getByRole("alert").filter({ hasText: "Generation Failed" })).toHaveCount(0); + await expect(page.getByRole("alert").filter({ hasText: "Generation failed" })).toHaveCount(0); const caps = (await fake.requests()).filter((request) => request.feature === "generate").map((request) => request.maxTokens ?? 0); await evidence.json("01-generate-max-tokens", caps); expect(caps).toHaveLength(2); @@ -256,7 +256,7 @@ test("gen-quota: an account without quota is told why and the provider is never await admin.from("user_settings").update({ daily_limit_override: 0 }).eq("user_id", await user.userId()); await openGenerate(page); await page.getByRole("button", { name: "Generate debate case" }).click(); - await expect(page.getByRole("dialog").getByText("Daily Access Paused")).toBeVisible({ timeout: AI_TIMEOUT }); + await expect(page.getByRole("dialog").getByText("Daily AI uses used up")).toBeVisible({ timeout: AI_TIMEOUT }); await evidence.shot("01-quota-dialog"); expect((await fake.requests()).filter((request) => request.feature === "generate")).toEqual([]); }); diff --git a/.claude/skills/verify-debatelab/recipes/current-debate.spec.ts b/.claude/skills/verify-debatelab/recipes/current-debate.spec.ts index 4d800217..064b3fc3 100644 --- a/.claude/skills/verify-debatelab/recipes/current-debate.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/current-debate.spec.ts @@ -16,7 +16,7 @@ async function addSource(page: Page, title: string, content: string) { await page.getByRole("button", { name: "Add source" }).click(); await page.getByRole("textbox", { name: "Title", exact: true }).fill(title); await page.getByRole("textbox", { name: "Content", exact: true }).fill(content); - await page.getByRole("button", { name: "Save to Library" }).click(); + await page.getByRole("button", { name: "Save to library" }).click(); await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked(); } @@ -56,7 +56,7 @@ test("debate-start, debate-scope, debate-once, debate-switch: two debates keep t await page.goto("/app/brainstorm"); await expect(page.getByTestId("current-debate-strip")).toContainText(`Working in: ${HOMEWORK}`); - await expect(page.getByLabel("Motion / Topic *")).toHaveValue(HOMEWORK); + await expect(page.getByLabel("Motion / topic *")).toHaveValue(HOMEWORK); let before = await callCount("brainstorm"); const homeworkRun = aiResponse(page, "brainstorm"); await page.getByRole("button", { name: "Start brainstorming" }).click(); @@ -80,7 +80,7 @@ test("debate-start, debate-scope, debate-once, debate-switch: two debates keep t await evidence.shot("03-library-in-zoo-debate"); await page.goto("/app/generate"); - await expect(page.getByRole("textbox", { name: "Motion / Topic *" })).toHaveValue(ZOOS); + await expect(page.getByRole("textbox", { name: "Motion / topic *" })).toHaveValue(ZOOS); before = await callCount("generate"); const zooRun = aiResponse(page, "generate"); await page.getByRole("button", { name: "Generate debate case" }).click(); @@ -94,7 +94,7 @@ test("debate-start, debate-scope, debate-once, debate-switch: two debates keep t // A different motion asks first; "use once" keeps it out of every debate. await page.goto("/app/brainstorm"); - await page.getByLabel("Motion / Topic *").fill("This House would lower the voting age"); + await page.getByLabel("Motion / topic *").fill("This House would lower the voting age"); before = await callCount("brainstorm"); const oneOffRun = aiResponse(page, "brainstorm"); await page.getByRole("button", { name: "Start brainstorming" }).click(); @@ -155,7 +155,7 @@ test("debate-start, debate-scope, debate-once, debate-switch: two debates keep t test("debate-offer: with no Current Debate, a tool offers to start one for the motion it was given", async ({ signedIn: page, user, evidence }) => { const motion = "This House would abolish zoos"; await page.goto("/app/generate"); - await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); + await page.getByRole("textbox", { name: "Motion / topic *" }).fill(motion); const run = aiResponse(page, "generate"); await page.getByRole("button", { name: "Generate debate case" }).click(); @@ -214,9 +214,9 @@ test("debate-tour: a brand-new account starts its first debate from the onboardi await next.click(); await expect(tooltip).toContainText("Generate"); await next.click(); - await page.getByRole("button", { name: "Get started!" }).click(); + await page.getByRole("button", { name: "Get started" }).click(); await expect(tooltip).toHaveCount(0); - await evidence.step("Tour: Library → Generate → Get started!"); + await evidence.step("Tour: Library → Generate → Get started"); const userId = await user.userId(); const { data: debates } = await admin.from("debates").select("id,motion").eq("user_id", userId); @@ -227,7 +227,7 @@ test("debate-tour: a brand-new account starts its first debate from the onboardi // The first tool already works inside that debate, with no motion check. await page.locator('main [data-tour="mode-generate"]').click(); await expect(page.getByTestId("current-debate-strip")).toContainText(`Working in: ${motion}`); - await expect(page.getByRole("textbox", { name: "Motion / Topic *" })).toHaveValue(motion); + await expect(page.getByRole("textbox", { name: "Motion / topic *" })).toHaveValue(motion); const run = aiResponse(page, "generate"); await page.getByRole("button", { name: "Generate debate case" }).click(); await run; diff --git a/.claude/skills/verify-debatelab/recipes/library.spec.ts b/.claude/skills/verify-debatelab/recipes/library.spec.ts index 6815367a..d07bcd8a 100644 --- a/.claude/skills/verify-debatelab/recipes/library.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/library.spec.ts @@ -9,7 +9,7 @@ async function addSource(page: Page, title: string, content: string) { await page.getByRole("button", { name: "Add source" }).click(); await page.getByRole("textbox", { name: "Title", exact: true }).fill(title); await page.getByRole("textbox", { name: "Content", exact: true }).fill(content); - await page.getByRole("button", { name: "Save to Library" }).click(); + await page.getByRole("button", { name: "Save to library" }).click(); await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked(); } @@ -30,7 +30,7 @@ test("lib-add, lib-toggle, lib-ground, lib-delete: only active sources ground AI // Brainstorm prefills the Current Debate's motion, so no motion check opens. await page.goto("/app/brainstorm"); - await expect(page.getByLabel("Motion / Topic *")).toHaveValue(motion); + await expect(page.getByLabel("Motion / topic *")).toHaveValue(motion); const response = aiResponse(page, "brainstorm"); await page.getByRole("button", { name: "Start brainstorming" }).click(); await response; @@ -185,7 +185,7 @@ test("lib-dedupe and lib-layout: a page saved once per debate, and long titles k await page.getByRole("button", { name: "Add source" }).click(); await page.getByRole("textbox", { name: "Import from URL" }).fill("http://example.org/estudios/deberes-escolares-primaria?utm_source=x#top"); await page.getByRole("button", { name: "Import URL" }).click(); - await expect(page.getByText("This page is already in this debate's Library.")).toBeVisible(); + await expect(page.getByText("This page is already in this debate's library.")).toBeVisible(); expect(imported).toBe(false); await evidence.shot("02-duplicate-warning"); const { data } = await admin.from("sources").select("id").eq("user_id", userId); diff --git a/.claude/skills/verify-debatelab/recipes/live-generation.spec.ts b/.claude/skills/verify-debatelab/recipes/live-generation.spec.ts index 6affc1e9..214059c9 100644 --- a/.claude/skills/verify-debatelab/recipes/live-generation.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/live-generation.spec.ts @@ -36,14 +36,14 @@ test("live-generate: a case fills in section by section while the model writes i await fake.streamDelay(80); await startDebate(page, motion); await page.goto("/app/generate"); - await expect(page.getByRole("textbox", { name: "Motion / Topic *" })).toHaveValue(motion); + await expect(page.getByRole("textbox", { name: "Motion / topic *" })).toHaveValue(motion); const request = aiRequest(page, "generate"); await page.getByRole("button", { name: "Generate debate case" }).click(); // Before the answer is complete, the draft shows what is written so far. const draft = page.getByTestId("live-draft"); await expect(draft).toBeVisible({ timeout: AI_TIMEOUT }); - await expect(draft.getByRole("region", { name: "Case Structure" })).toBeVisible(); + await expect(draft.getByRole("region", { name: "Case structure" })).toBeVisible(); await expect(draft.getByTestId("live-caret")).toHaveCount(1); await expect(draft.getByRole("button", { name: "Cancel" })).toBeVisible(); await evidence.shot("01-writing-live"); @@ -68,7 +68,7 @@ test("live-cancel: Cancel stops a live run and leaves no draft or result", async await expect(draft).toBeVisible({ timeout: AI_TIMEOUT }); await draft.getByRole("button", { name: "Cancel" }).click(); await expect(draft).toHaveCount(0); - await expect(page.getByRole("heading", { name: "Ready to Generate" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Your case will appear here" })).toBeVisible(); await expect(page.getByRole("alert")).toHaveCount(0); await evidence.shot("01-canceled"); }); @@ -77,7 +77,7 @@ test("live-tools: Attack, Brainstorm, Factoids, and cited Ask stream their answe await startDebate(page, motion); await page.goto("/app/attack"); - await page.getByLabel("Your Argument *").fill("Free transit raises ridership, so it always pays for itself."); + await page.getByLabel("Your argument *").fill("Free transit raises ridership, so it always pays for itself."); let request = aiRequest(page, "attack"); await page.getByRole("button", { name: "Find weaknesses" }).click(); await expectLiveStream(await request); @@ -92,7 +92,7 @@ test("live-tools: Attack, Brainstorm, Factoids, and cited Ask stream their answe await page.goto("/app/factoids"); await page.getByLabel("Statement *").fill("Free fares increase public transport ridership."); request = aiRequest(page, "factoids"); - await page.getByRole("button", { name: "Find Factoids" }).click(); + await page.getByRole("button", { name: "Find factoids" }).click(); await expectLiveStream(await request); await evidence.shot("03-factoids"); @@ -100,7 +100,7 @@ test("live-tools: Attack, Brainstorm, Factoids, and cited Ask stream their answe await page.getByRole("button", { name: "Options" }).click(); await page.locator("#citation-mode").click(); await page.keyboard.press("Escape"); - await page.getByPlaceholder("Ask your debate coach...").fill("How should I weigh access against cost?"); + await page.getByPlaceholder("Ask your debate coach…").fill("How should I weigh access against cost?"); request = aiRequest(page, "ask"); await page.getByRole("button", { name: "Send message" }).click(); const { body } = await expectLiveStream(await request); diff --git a/.claude/skills/verify-debatelab/recipes/model-defaults.spec.ts b/.claude/skills/verify-debatelab/recipes/model-defaults.spec.ts index 5f78a325..8b74ba79 100644 --- a/.claude/skills/verify-debatelab/recipes/model-defaults.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/model-defaults.spec.ts @@ -18,7 +18,7 @@ async function updateSettings(userId: string, values: Record<string, unknown>) { } async function generateOnce(page: Page) { - await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); + await page.getByRole("textbox", { name: "Motion / topic *" }).fill(motion); const response = aiResponse(page, "generate"); await page.getByRole("button", { name: "Generate debate case" }).click(); await chooseForMotion(page, "once"); diff --git a/.claude/skills/verify-debatelab/recipes/usage-limits.spec.ts b/.claude/skills/verify-debatelab/recipes/usage-limits.spec.ts index a2e9e8e8..b10c3be6 100644 --- a/.claude/skills/verify-debatelab/recipes/usage-limits.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/usage-limits.spec.ts @@ -7,7 +7,7 @@ const motion = "This House would make public transport free at the point of use" async function openGenerate(page: Page) { await startDebate(page, motion); await page.goto("/app/generate"); - await expect(page.getByRole("heading", { name: "Ready to Generate" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Your case will appear here" })).toBeVisible(); } async function aiCounter(userId: string): Promise<number> { @@ -39,7 +39,7 @@ test("usage-weighted: Opus counts as 5 requests, a lighter model still runs, and await expect(selector).toContainText("Claude Opus 5.5"); await page.getByRole("button", { name: "Generate debate case" }).click(); const daily = page.getByRole("dialog"); - await expect(daily.getByText("Daily Access Paused")).toBeVisible({ timeout: AI_TIMEOUT }); + await expect(daily.getByText("Daily AI uses used up")).toBeVisible({ timeout: AI_TIMEOUT }); await expect(daily.getByText("This model counts as 5 uses, and you have 4 left today.", { exact: false })).toBeVisible(); await evidence.shot("01-opus-weighted-refusal"); expect(await generateCalls()).toBe(0); @@ -66,7 +66,7 @@ test("usage-weighted: Opus counts as 5 requests, a lighter model still runs, and if (spendError) throw spendError; await page.getByRole("button", { name: "Generate debate case" }).click(); const weekly = page.getByRole("dialog"); - await expect(weekly.getByText("Weekly AI Allowance Used")).toBeVisible({ timeout: AI_TIMEOUT }); + await expect(weekly.getByText("Weekly AI allowance used")).toBeVisible({ timeout: AI_TIMEOUT }); await expect(weekly.getByText("It refreshes on Monday at 00:00 UTC.", { exact: false })).toBeVisible(); await evidence.shot("03-weekly-cap"); expect(await generateCalls()).toBe(1); @@ -91,7 +91,7 @@ test("usage-weekly-sparring: with the week's allowance used, Sparring says so be await evidence.shot("04-sparring-weekly-before-start"); await page.getByRole("button", { name: "Start round" }).click(); await chooseForMotion(page, "create"); - await expect(page.getByRole("dialog").getByText("Weekly AI Allowance Used")).toBeVisible({ timeout: AI_TIMEOUT }); + await expect(page.getByRole("dialog").getByText("Weekly AI allowance used")).toBeVisible({ timeout: AI_TIMEOUT }); // No round opened, so no speech can be written into a round the server would refuse. await expect(page.getByRole("textbox", { name: /^Draft for / })).toHaveCount(0); expect((await providerCalls()).filter((call) => call.feature === "sparring")).toEqual([]); @@ -184,7 +184,7 @@ test("usage-refund-reload: reloading while Brainstorm streams costs nothing", as const userId = await user.userId(); await fake.streamDelay(1_500); await page.goto("/app/brainstorm"); - await page.getByLabel("Motion / Topic *").fill(motion); + await page.getByLabel("Motion / topic *").fill(motion); await page.getByRole("button", { name: "Start brainstorming" }).click(); await chooseForMotion(page, "create"); // The run is reserved and streaming: the counter shows it. diff --git a/docs/operations.md b/docs/operations.md index 430a1009..dbce274d 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -108,9 +108,9 @@ Before a production backend deploy, check that the commit's `staging-sync` statu [`src/lib/supabase/env.ts`](../src/lib/supabase/env.ts) reads the `debate-supabase-env` localStorage key once at startup. The value `staging` selects staging, and anything else selects production. Each project keeps its own auth session under `debate-auth-<env>`. -Admins see a **Developer** card in Settings that signs out, sets the key, and reloads. The app's persisted stores are shared by both projects, so the switch also clears this project's stored session, its Google token, and all user-specific local data, including saved results and form drafts. It waits at most 4 seconds for the sign-out request, so an unresponsive project cannot block it. Other open tabs clear their own session storage, including their Google token, and reload as soon as the key changes, so no tab keeps talking to the old project while sharing stores with the new one. The strip publishes its height as `--staging-banner-height`, and the landing navigation and app header sit below it. While staging is active, every page shows a staging strip with **Switch to Production**, and the Settings card is shown to everyone. A new staging session starts signed out, and an admin on production may not be an admin on staging, so the way back cannot depend on that role. The switch is a convenience, not an access control. Staging RLS and its separate user accounts are the boundary. +Admins see a **Developer** card in Settings that signs out, sets the key, and reloads. The app's persisted stores are shared by both projects, so the switch also clears this project's stored session, its Google token, and all user-specific local data, including saved results and form drafts. It waits at most 4 seconds for the sign-out request, so an unresponsive project cannot block it. Other open tabs clear their own session storage, including their Google token, and reload as soon as the key changes, so no tab keeps talking to the old project while sharing stores with the new one. The strip publishes its height as `--staging-banner-height`, and the landing navigation and app header sit below it. While staging is active, every page shows a staging strip with **Switch to production**, and the Settings card is shown to everyone. A new staging session starts signed out, and an admin on production may not be an admin on staging, so the way back cannot depend on that role. The switch is a convenience, not an access control. Staging RLS and its separate user accounts are the boundary. -A build can pin one project with the `VITE_SUPABASE_ENV` build variable (`production` or `staging`; any other non-empty value fails the build). A pinned build ignores the localStorage key, hides the Settings card and the strip's **Switch to Production** button, and does not reload when another tab switches. The strip still shows on a pinned staging build. The staging Vercel project uses this so a requested preview is pinned to staging; see [Opt-in previews](#opt-in-previews). +A build can pin one project with the `VITE_SUPABASE_ENV` build variable (`production` or `staging`; any other non-empty value fails the build). A pinned build ignores the localStorage key, hides the Settings card and the strip's **Switch to production** button, and does not reload when another tab switches. The strip still shows on a pinned staging build. The staging Vercel project uses this so a requested preview is pinned to staging; see [Opt-in previews](#opt-in-previews). The switch only works while the `connect-src` directive in [`vercel.json`](../vercel.json) allows the staging project origin. It lists exactly the two project origins from [`src/lib/supabase/projects.ts`](../src/lib/supabase/projects.ts), and `src/test/vercel-deployment-config.test.ts` fails if they drift apart. The same file is deployed to production and to `debatelab-staging`, and an unpinned production preview can still select staging, so the staging origin stays in the production site's CSP. diff --git a/e2e/admin-announcements.spec.ts b/e2e/admin-announcements.spec.ts index 52557b32..63a1f3a3 100644 --- a/e2e/admin-announcements.spec.ts +++ b/e2e/admin-announcements.spec.ts @@ -111,7 +111,7 @@ test('keeps announcements stable across routes and refreshes on a full reload', await expect(page.getByRole('heading', { name: 'Scheduled maintenance' })).toHaveCount(0); await openAppNav(page, 'Library'); - await expect(page.getByRole('heading', { name: 'Source Library', exact: true })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Source library', exact: true })).toBeVisible(); await openAppNav(page, 'Tools'); await expect(page.getByRole('main').getByRole('heading', { name: 'What are you working on?', exact: true })).toBeVisible(); expect(listRequestCount()).toBe(1); diff --git a/e2e/animation-polish.spec.ts b/e2e/animation-polish.spec.ts index b2c6c290..96d08162 100644 --- a/e2e/animation-polish.spec.ts +++ b/e2e/animation-polish.spec.ts @@ -36,7 +36,7 @@ async function prepareMotionFixture(page: Page, reducedMotion: 'no-preference' | async function openGenerateOptions(page: Page) { await page.goto('/app/generate', { waitUntil: 'domcontentloaded' }); - await expect(page.getByText('Ready to Generate')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await expect(page.getByText('Your case will appear here')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); const options = page.locator('#generate-more-options'); expect(await options.evaluate((element) => (element as HTMLElement).inert)).toBe(true); await page.getByRole('button', { name: 'More options' }).click(); @@ -51,7 +51,7 @@ async function openGenerateOptions(page: Page) { } async function generateMockCase(page: Page) { - await page.getByLabel('Motion / Topic').fill('This House would make public transport free.'); + await page.getByLabel('Motion / topic').fill('This House would make public transport free.'); await page.getByRole('button', { name: 'Generate debate case' }).click(); await page.mouse.move(0, 0); await expect(page.getByText('Writing your case…')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); @@ -118,36 +118,36 @@ test('audits applicable empty, loading, result, and option states across feature await prepareMotionFixture(page, 'no-preference'); await page.goto('/app/attack', { waitUntil: 'domcontentloaded' }); - await expect(page.getByText('No Mercy Mode')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await expect(page.getByText('Your critique will appear here')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); const attackOptions = page.locator('#attack-options'); await page.getByRole('button', { name: 'More options' }).click(); await expect(attackOptions).toHaveAttribute('data-open', 'true'); await page.getByRole('button', { name: 'More options' }).click(); await expect(attackOptions).toHaveAttribute('data-open', 'false'); expect(await attackOptions.evaluate((element) => (element as HTMLElement).inert)).toBe(true); - await page.getByLabel('Your Argument').fill('Universal transit improves access, reduces congestion, and strengthens civic participation.'); + await page.getByLabel('Your argument').fill('Universal transit improves access, reduces congestion, and strengthens civic participation.'); await page.getByRole('button', { name: 'Find weaknesses' }).click(); await expect(page.getByText('Reviewing your case…')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); - await expect(page.getByRole('heading', { name: 'Overall Assessment' })).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await expect(page.getByRole('heading', { name: 'Overall assessment' })).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await page.screenshot({ path: testInfo.outputPath('attack-result.png'), fullPage: true }); await openFeatureRoute(page, 'brainstorm'); - await expect(page.getByText('Unlock Creative Angles')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); - await page.getByLabel('Motion / Topic').fill('This House would make public transport free.'); + await expect(page.getByText('Your angles will appear here')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await page.getByLabel('Motion / topic').fill('This House would make public transport free.'); await page.getByRole('button', { name: 'Start brainstorming' }).click(); await expect(page.getByText('Finding angles…')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); - await expect(page.getByRole('heading', { name: 'Top 5 Argument Angles' })).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await expect(page.getByRole('heading', { name: 'Top 5 argument angles' })).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await openFeatureRoute(page, 'factoids'); - await expect(page.getByText('Evidence That Wins')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await expect(page.getByText('Your evidence will appear here')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await page.getByLabel('Statement').fill('Free public transport reduces urban congestion.'); - await page.getByRole('button', { name: 'Find Factoids' }).click(); + await page.getByRole('button', { name: 'Find factoids' }).click(); await expect(page.getByText('Searching for evidence…')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await expect(page.getByRole('heading', { name: 'Factoids' })).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await openFeatureRoute(page, 'ask'); await expect(page.getByText('Start a conversation')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); - await page.getByPlaceholder('Ask your debate coach...').fill('How should I weigh access against cost?'); + await page.getByPlaceholder('Ask your debate coach…').fill('How should I weigh access against cost?'); await page.getByRole('button', { name: 'Send message' }).click(); await expect(page.getByText('Working…')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await expect(page.getByText(/Great question about/)).toBeVisible({ timeout: STATE_TIMEOUT_MS }); diff --git a/e2e/authed-pages.spec.ts b/e2e/authed-pages.spec.ts index 5d6771c2..a8d8700c 100644 --- a/e2e/authed-pages.spec.ts +++ b/e2e/authed-pages.spec.ts @@ -131,11 +131,11 @@ test('visible Ask advanced controls use the non-streaming contract', async ({ pa await page.goto('/app/ask', { waitUntil: 'domcontentloaded' }); await page.getByRole('button', { name: 'Clarify' }).click(); await page.getByRole('button', { name: 'Options' }).click(); - for (const name of ['Citations', 'Web search', 'Pros/Cons', 'Arg Map']) { + for (const name of ['Citations', 'Web search', 'Pros/cons', 'Arg map']) { await page.getByRole('switch', { name }).click(); } await page.keyboard.press('Escape'); - await page.getByPlaceholder('Ask your debate coach...').fill('Which assumptions should I define?'); + await page.getByPlaceholder('Ask your debate coach…').fill('Which assumptions should I define?'); await page.getByRole('button', { name: 'Send message' }).click(); await expect(page.getByText('Clarification contract response')).toBeVisible(); diff --git a/e2e/first-run.spec.ts b/e2e/first-run.spec.ts index 27e76543..f4b4e625 100644 --- a/e2e/first-run.spec.ts +++ b/e2e/first-run.spec.ts @@ -15,8 +15,8 @@ const copy = { library: 'Library', generate: 'Generate', ready: "You're Ready!", - finish: 'Get started!', - generateMotion: 'Motion / Topic *', + finish: 'Get started', + generateMotion: 'Motion / topic *', generateSubmit: 'Generate debate case', offerTitle: 'Start a debate with this motion?', workingIn: 'Working in:', @@ -31,7 +31,7 @@ const copy = { library: 'Forráskönyvtár', generate: 'Generálás', ready: 'Készen állsz!', - finish: 'Kezdjük!', + finish: 'Kezdjük', generateMotion: 'Indítvány / téma *', generateSubmit: 'Vitaeset generálása', offerTitle: 'Indítasz egy vitát ezzel a tétellel?', diff --git a/e2e/full-stack/account-data.spec.ts b/e2e/full-stack/account-data.spec.ts index b252684c..751b1a55 100644 --- a/e2e/full-stack/account-data.spec.ts +++ b/e2e/full-stack/account-data.spec.ts @@ -7,7 +7,7 @@ async function addSource(page: Page, title: string, content: string) { await page.getByRole("button", { name: "Add source" }).click(); await page.getByRole("textbox", { name: "Title", exact: true }).fill(title); await page.getByRole("textbox", { name: "Content", exact: true }).fill(content); - await page.getByRole("button", { name: "Save to Library" }).click(); + await page.getByRole("button", { name: "Save to library" }).click(); await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked(); } @@ -20,7 +20,7 @@ test("only active Library sources ground AI requests, and deletions persist", as await expect(page.getByRole("switch", { name: "Toggle Paused study" })).not.toBeChecked(); await page.goto("/app/brainstorm"); - await page.getByLabel("Motion / Topic *").fill("Public transport should be free"); + await page.getByLabel("Motion / topic *").fill("Public transport should be free"); await page.getByRole("button", { name: "Start brainstorming" }).click(); await expectResult(page, `${MARK} idea 1`); const call = await providerCall("brainstorm"); @@ -61,7 +61,7 @@ test("Delete all my data erases the account's saved work and nobody else's", asy const dialog = page.getByRole("dialog"); await dialog.getByLabel("Type your email to confirm").fill(user.email); await dialog.getByRole("button", { name: "Delete all my data" }).click(); - await expect(page.getByText("Data Deleted").first()).toBeVisible(); + await expect(page.getByText("Data deleted").first()).toBeVisible(); const { data: mine } = await admin.from("sources").select("id").eq("user_id", userId); expect(mine).toEqual([]); @@ -79,7 +79,7 @@ test("Delete all my data erases the account's saved work and nobody else's", asy test("the output language setting follows the account and reaches the model", async ({ signedIn: page, user, browser }) => { await page.goto("/app/settings"); - await page.getByRole("combobox", { name: "AI Output Language" }).click(); + await page.getByRole("combobox", { name: "AI output language" }).click(); await page.getByRole("option", { name: "Magyar" }).click(); await expect.poll(async () => { const { data } = await admin.from("user_settings").select("output_language").eq("user_id", await user.userId()).single(); @@ -93,7 +93,7 @@ test("the output language setting follows the account and reaches the model", as await other.addInitScript(() => localStorage.setItem("debate-trainer-onboarding", JSON.stringify({ state: { hasCompletedOnboarding: true, isTourRunning: false }, version: 0 }))); await signIn(other, user); await other.goto("/app/attack"); - await other.getByLabel("Your Argument *").fill("Free transit cuts congestion."); + await other.getByLabel("Your argument *").fill("Free transit cuts congestion."); await other.getByRole("button", { name: "Find weaknesses" }).click(); await expect(other.getByText(`${MARK} weakness`)).toBeVisible({ timeout: 30_000 }); expect((await providerCall("attack")).user).toContain("Language: Hungarian (hu)"); @@ -108,8 +108,8 @@ test("a saved flowchart is stored on the account", async ({ signedIn: page, user await page.getByRole("button", { name: "Save", exact: true }).click(); const dialog = page.getByRole("dialog"); await dialog.getByRole("textbox").fill(title); - await dialog.getByRole("button", { name: "Save Flowchart" }).click(); - await expect(page.getByText("Flowchart saved successfully")).toBeVisible(); + await dialog.getByRole("button", { name: "Save flowchart" }).click(); + await expect(page.getByText("Flowchart saved")).toBeVisible(); const { data } = await admin.from("flowcharts").select("title").eq("user_id", await user.userId()); expect(data?.map((row) => row.title)).toEqual([title]); diff --git a/e2e/full-stack/account-deletion.spec.ts b/e2e/full-stack/account-deletion.spec.ts index 9e83ed9b..1fbc0f1d 100644 --- a/e2e/full-stack/account-deletion.spec.ts +++ b/e2e/full-stack/account-deletion.spec.ts @@ -12,7 +12,7 @@ async function addSource(page: Page, title: string, content: string) { await page.getByRole("button", { name: "Add source" }).click(); await page.getByRole("textbox", { name: "Title", exact: true }).fill(title); await page.getByRole("textbox", { name: "Content", exact: true }).fill(content); - await page.getByRole("button", { name: "Save to Library" }).click(); + await page.getByRole("button", { name: "Save to library" }).click(); await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked(); } diff --git a/e2e/full-stack/ai-tools.spec.ts b/e2e/full-stack/ai-tools.spec.ts index 83202f32..ca3506b2 100644 --- a/e2e/full-stack/ai-tools.spec.ts +++ b/e2e/full-stack/ai-tools.spec.ts @@ -4,7 +4,7 @@ test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh test("Attack analyses a pasted case", async ({ signedIn: page }) => { await page.goto("/app/attack"); - await page.getByLabel("Your Argument *").fill("Free transit cuts congestion because riders leave their cars at home."); + await page.getByLabel("Your argument *").fill("Free transit cuts congestion because riders leave their cars at home."); await page.getByRole("button", { name: "Find weaknesses" }).click(); await expectResult(page, `${MARK} weakness`); @@ -13,7 +13,7 @@ test("Attack analyses a pasted case", async ({ signedIn: page }) => { test("Brainstorm returns ranked angles", async ({ signedIn: page }) => { await page.goto("/app/brainstorm"); - await page.getByLabel("Motion / Topic *").fill("Universal basic income should replace targeted welfare"); + await page.getByLabel("Motion / topic *").fill("Universal basic income should replace targeted welfare"); await page.getByRole("button", { name: "Start brainstorming" }).click(); await chooseForMotion(page, "create"); @@ -24,7 +24,7 @@ test("Brainstorm returns ranked angles", async ({ signedIn: page }) => { test("Factoids completes research without verified web sources", async ({ signedIn: page }) => { await page.goto("/app/factoids"); await page.getByLabel("Statement *").fill("Fare-free transit raises ridership"); - await page.getByRole("button", { name: "Find Factoids" }).click(); + await page.getByRole("button", { name: "Find factoids" }).click(); // The local stack has no search key, so the server must drop every unverified // factoid and still finish instead of inventing citations. @@ -34,7 +34,7 @@ test("Factoids completes research without verified web sources", async ({ signed test("Ask streams a coaching answer and records usage", async ({ signedIn: page, user }) => { await page.goto("/app/ask"); - await page.getByPlaceholder("Ask your debate coach...").fill("How do I weigh access against cost?"); + await page.getByPlaceholder("Ask your debate coach…").fill("How do I weigh access against cost?"); await page.getByRole("button", { name: "Send message" }).click(); await expect(page.getByText(`${MARK} streamed answer: weigh access against cost.`)).toBeVisible({ timeout: 30_000 }); @@ -57,7 +57,7 @@ test("Ask with citations uses the structured endpoint", async ({ signedIn: page await page.getByRole("button", { name: "Options" }).click(); await page.locator("#citation-mode").click(); await page.keyboard.press("Escape"); - await page.getByPlaceholder("Ask your debate coach...").fill("Which evidence supports fare-free transit?"); + await page.getByPlaceholder("Ask your debate coach…").fill("Which evidence supports fare-free transit?"); await page.getByRole("button", { name: "Send message" }).click(); await expect(page.getByText(`${MARK} answer`)).toBeVisible({ timeout: 30_000 }); diff --git a/e2e/full-stack/auth.spec.ts b/e2e/full-stack/auth.spec.ts index df4c409f..969559ec 100644 --- a/e2e/full-stack/auth.spec.ts +++ b/e2e/full-stack/auth.spec.ts @@ -38,7 +38,7 @@ test("an expired access token refreshes before an AI request", async ({ signedIn localStorage.setItem(key, JSON.stringify(session)); }); await page.goto("/app/generate"); - await page.getByRole("textbox", { name: "Motion / Topic *" }).fill("This House would make public transport free"); + await page.getByRole("textbox", { name: "Motion / topic *" }).fill("This House would make public transport free"); await page.getByRole("button", { name: "Generate debate case" }).click(); await chooseForMotion(page, "once"); await expectResult(page, `${MARK} case thesis`); diff --git a/e2e/full-stack/billing.spec.ts b/e2e/full-stack/billing.spec.ts index a01425bf..8f5ce440 100644 --- a/e2e/full-stack/billing.spec.ts +++ b/e2e/full-stack/billing.spec.ts @@ -22,7 +22,7 @@ async function tier(userId: string) { async function brainstorm(page: Page) { await page.goto("/app/brainstorm"); - await page.getByLabel("Motion / Topic *").fill("Public transport should be free"); + await page.getByLabel("Motion / topic *").fill("Public transport should be free"); await page.getByRole("button", { name: "Start brainstorming" }).click(); await chooseForMotion(page, "once"); } @@ -74,7 +74,7 @@ test("a promo code redeemed in Settings restores AI access", async ({ signedIn: try { await brainstorm(page); - await expect(page.getByRole("dialog").getByText("Free Trial Ended")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole("dialog").getByText("Your free trial has ended")).toBeVisible({ timeout: 30_000 }); expect((await fake.requests()).filter((request) => request.feature === "brainstorm")).toEqual([]); await page.goto("/app/settings"); @@ -83,7 +83,7 @@ test("a promo code redeemed in Settings restores AI access", async ({ signedIn: await page.getByRole("button", { name: "Have a promo code?" }).click(); await page.getByPlaceholder("Enter promo code").fill(code.toLowerCase()); await page.getByRole("button", { name: "Apply", exact: true }).click(); - await expect(page.getByText("Promo code applied!").first()).toBeVisible(); + await expect(page.getByText("Promo code applied").first()).toBeVisible(); await expect(page.getByRole("dialog")).toBeHidden(); await expect(page.getByText("Pro", { exact: true })).toBeVisible(); expect(await tier(userId)).toBe("pro"); diff --git a/e2e/full-stack/case-generation.spec.ts b/e2e/full-stack/case-generation.spec.ts index e6ded592..6b03bfc9 100644 --- a/e2e/full-stack/case-generation.spec.ts +++ b/e2e/full-stack/case-generation.spec.ts @@ -15,15 +15,15 @@ test("a signed-in user generates a case grounded in a saved Library source", asy // exceeded the Edge Function's 200 KB body limit before sources loaded server-side. await page.getByRole("textbox", { name: "Content", exact: true }) .fill(`${marker} Tallinn ridership rose 14 percent after fares were removed. ${"Detailed ridership evidence. ".repeat(9_000)}`); - await page.getByRole("button", { name: "Save to Library" }).click(); - await expect(page.getByText("Grounding Active")).toBeVisible(); + await page.getByRole("button", { name: "Save to library" }).click(); + await expect(page.getByText("Grounding active")).toBeVisible(); const userId = await user.userId(); const { data: sources } = await admin.from("sources").select("id,is_active").eq("user_id", userId); expect(sources).toEqual([expect.objectContaining({ is_active: true })]); await page.goto("/app/generate"); - await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); + await page.getByRole("textbox", { name: "Motion / topic *" }).fill(motion); await page.getByRole("button", { name: "Generate debate case" }).click(); // A hang (as in the auth relock regression) fails here instead of spinning forever. @@ -43,10 +43,10 @@ test("a provider failure shows an error and lets the user retry", async ({ signe await fake.fail("generate", 500); await startDebate(page, motion); await page.goto("/app/generate"); - await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); + await page.getByRole("textbox", { name: "Motion / topic *" }).fill(motion); await page.getByRole("button", { name: "Generate debate case" }).click(); - await expect(page.getByText("Generation Failed", { exact: true })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText("Generation failed", { exact: true })).toBeVisible({ timeout: 30_000 }); await expect(page.getByRole("button", { name: "Generate debate case" })).toBeEnabled(); await page.getByRole("button", { name: "Generate debate case" }).click(); @@ -57,16 +57,16 @@ test("an account without quota is told why generation is blocked", async ({ sign await admin.from("user_settings").update({ daily_limit_override: 0 }).eq("user_id", await user.userId()); await startDebate(page, motion); await page.goto("/app/generate"); - await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); + await page.getByRole("textbox", { name: "Motion / topic *" }).fill(motion); await page.getByRole("button", { name: "Generate debate case" }).click(); - await expect(page.getByRole("dialog").getByText("Daily Access Paused")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole("dialog").getByText("Daily AI uses used up")).toBeVisible({ timeout: 30_000 }); expect((await fake.requests()).filter((request) => request.feature === "generate")).toEqual([]); }); test("a saved one-off case persists to the account and reloads from History", async ({ signedIn: page, user }) => { await page.goto("/app/generate"); - await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); + await page.getByRole("textbox", { name: "Motion / topic *" }).fill(motion); await page.getByRole("button", { name: "Generate debate case" }).click(); await chooseForMotion(page, "once"); await expectResult(page, `${MARK} case thesis`); @@ -93,7 +93,7 @@ test("a saved one-off case persists to the account and reloads from History", as test("a generated case exports to PDF and DOCX files", async ({ signedIn: page }) => { await startDebate(page, motion); await page.goto("/app/generate"); - await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); + await page.getByRole("textbox", { name: "Motion / topic *" }).fill(motion); await page.getByRole("button", { name: "Generate debate case" }).click(); await expectResult(page, `${MARK} case thesis`); diff --git a/e2e/full-stack/current-debate.spec.ts b/e2e/full-stack/current-debate.spec.ts index db8c2956..eddefb31 100644 --- a/e2e/full-stack/current-debate.spec.ts +++ b/e2e/full-stack/current-debate.spec.ts @@ -20,7 +20,7 @@ async function addSource(page: Page, title: string, content: string) { await page.getByRole("button", { name: "Add source" }).click(); await page.getByRole("textbox", { name: "Title", exact: true }).fill(title); await page.getByRole("textbox", { name: "Content", exact: true }).fill(content); - await page.getByRole("button", { name: "Save to Library" }).click(); + await page.getByRole("button", { name: "Save to library" }).click(); await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked(); } @@ -52,7 +52,7 @@ test("two debates keep their motions, sources, and results apart", async ({ sign await page.goto("/app/brainstorm"); await expect(page.getByTestId("current-debate-strip")).toContainText(`Working in: ${HOMEWORK}`); - await expect(page.getByLabel("Motion / Topic *")).toHaveValue(HOMEWORK); + await expect(page.getByLabel("Motion / topic *")).toHaveValue(HOMEWORK); await page.getByRole("button", { name: "Start brainstorming" }).click(); await expectResult(page, `${MARK} idea 1`); const homeworkCall = await latestCall("brainstorm"); @@ -69,7 +69,7 @@ test("two debates keep their motions, sources, and results apart", async ({ sign await snap(page, "2-library-in-zoo-debate"); await page.goto("/app/generate"); - await expect(page.getByRole("textbox", { name: "Motion / Topic *" })).toHaveValue(ZOOS); + await expect(page.getByRole("textbox", { name: "Motion / topic *" })).toHaveValue(ZOOS); await page.getByRole("button", { name: "Generate debate case" }).click(); await expectResult(page, `${MARK} case thesis`); const zooCall = await latestCall("generate"); @@ -79,7 +79,7 @@ test("two debates keep their motions, sources, and results apart", async ({ sign // A different motion asks first; "use once" keeps it out of every debate. await page.goto("/app/brainstorm"); - await page.getByLabel("Motion / Topic *").fill("This House would lower the voting age"); + await page.getByLabel("Motion / topic *").fill("This House would lower the voting age"); await page.getByRole("button", { name: "Start brainstorming" }).click(); await expect(page.getByRole("dialog")).toContainText("This is a different motion"); await snap(page, "3-different-motion-choice"); diff --git a/e2e/full-stack/debate-context.spec.ts b/e2e/full-stack/debate-context.spec.ts index 83d463dc..629f5112 100644 --- a/e2e/full-stack/debate-context.spec.ts +++ b/e2e/full-stack/debate-context.spec.ts @@ -20,7 +20,7 @@ async function addSource(page: Page, title: string, content: string) { await page.getByRole("button", { name: "Add source" }).click(); await page.getByRole("textbox", { name: "Title", exact: true }).fill(title); await page.getByRole("textbox", { name: "Content", exact: true }).fill(content); - await page.getByRole("button", { name: "Save to Library" }).click(); + await page.getByRole("button", { name: "Save to library" }).click(); await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked(); } @@ -95,7 +95,7 @@ test("every request carries its debate's material, and Review changes exactly on await page.keyboard.press("Escape"); await expect(sheet).toBeHidden(); await page.goto("/app/brainstorm"); - await page.getByLabel("Motion / Topic *").fill("This House would lower the voting age"); + await page.getByLabel("Motion / topic *").fill("This House would lower the voting age"); const before = (await fake.requests()).filter((request) => request.feature === "brainstorm").length; await page.getByRole("button", { name: "Start brainstorming" }).click(); await page.getByRole("dialog").getByRole("button", { name: "Use once without saving to a debate" }).click(); diff --git a/e2e/full-stack/first-run.spec.ts b/e2e/full-stack/first-run.spec.ts index eabae851..b2376b76 100644 --- a/e2e/full-stack/first-run.spec.ts +++ b/e2e/full-stack/first-run.spec.ts @@ -16,9 +16,9 @@ const copy = { motionField: "Motion", library: "Library", generate: "Generate", - finish: "Get started!", + finish: "Get started", skip: "Skip tour", - generateMotion: "Motion / Topic *", + generateMotion: "Motion / topic *", generateSubmit: "Generate debate case", offerTitle: "Start a debate with this motion?", workingIn: "Working in:", @@ -35,7 +35,7 @@ const copy = { motionField: "Tétel", library: "Forráskönyvtár", generate: "Generálás", - finish: "Kezdjük!", + finish: "Kezdjük", skip: "Túra kihagyása", generateMotion: "Indítvány / téma *", generateSubmit: "Vitaeset generálása", diff --git a/e2e/home-navigation.spec.ts b/e2e/home-navigation.spec.ts index b6c6637d..999f0fb0 100644 --- a/e2e/home-navigation.spec.ts +++ b/e2e/home-navigation.spec.ts @@ -95,7 +95,7 @@ test('onboarding starts a fresh delay when a protected route reaches home', asyn })); }); await page.goto('/app/generate', { waitUntil: 'domcontentloaded' }); - await expect(page.getByRole('heading', { name: 'Ready to Generate' })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('heading', { name: 'Your case will appear here' })).toBeVisible({ timeout: 15_000 }); // eslint-disable-next-line no-restricted-properties -- observation window: let the first onboarding delay run partway before navigating. await page.waitForTimeout(650); diff --git a/e2e/live/models.spec.ts b/e2e/live/models.spec.ts index beb2c2ee..dbe2cad2 100644 --- a/e2e/live/models.spec.ts +++ b/e2e/live/models.spec.ts @@ -6,7 +6,7 @@ test.describe.configure({ timeout: LIVE_TIMEOUT + 60_000 }); test("Ask streams a live answer", async ({ signedIn: page }) => { await page.goto("/app/ask"); - await page.getByPlaceholder("Ask your debate coach...").fill("In two sentences, how should I weigh access against cost?"); + await page.getByPlaceholder("Ask your debate coach…").fill("In two sentences, how should I weigh access against cost?"); const stream = page.waitForResponse((response) => response.url().endsWith("/functions/v1/ai-stream"), { timeout: LIVE_TIMEOUT }); await page.getByRole("button", { name: "Send message" }).click(); @@ -24,13 +24,13 @@ test("Ask streams a live answer", async ({ signedIn: page }) => { test("Attack and Brainstorm return valid structured analyses", async ({ signedIn: page }) => { await page.goto("/app/attack"); - await page.getByLabel("Your Argument *").fill("Free transit cuts congestion because riders leave their cars at home, so cities should abolish fares."); + await page.getByLabel("Your argument *").fill("Free transit cuts congestion because riders leave their cars at home, so cities should abolish fares."); const attack = aiResponse(page, "attack"); await page.getByRole("button", { name: "Find weaknesses" }).click(); expect(((await attack).output.weaknesses as unknown[]).length).toBeGreaterThan(0); await page.goto("/app/brainstorm"); - await page.getByLabel("Motion / Topic *").fill("Universal basic income should replace targeted welfare"); + await page.getByLabel("Motion / topic *").fill("Universal basic income should replace targeted welfare"); const brainstorm = aiResponse(page, "brainstorm"); await page.getByRole("button", { name: "Start brainstorming" }).click(); await chooseForMotion(page, "once"); diff --git a/e2e/live/research.spec.ts b/e2e/live/research.spec.ts index dbf1551a..0347c536 100644 --- a/e2e/live/research.spec.ts +++ b/e2e/live/research.spec.ts @@ -8,7 +8,7 @@ const httpLink = /^https?:\/\//; test("case generation researches the web and cites what it found", async ({ signedIn: page, user }) => { await page.goto("/app/generate"); - await page.getByRole("textbox", { name: "Motion / Topic *" }).fill("This House would make public transport free at the point of use"); + await page.getByRole("textbox", { name: "Motion / topic *" }).fill("This House would make public transport free at the point of use"); const response = aiResponse(page, "generate"); await page.getByRole("button", { name: "Generate debate case" }).click(); await chooseForMotion(page, "once"); @@ -35,7 +35,7 @@ test("Factoids returns verified evidence with working citations", async ({ signe await page.goto("/app/factoids"); await page.getByLabel("Statement *").fill("Fare-free public transport increases ridership"); const response = aiResponse(page, "factoids"); - await page.getByRole("button", { name: "Find Factoids" }).click(); + await page.getByRole("button", { name: "Find factoids" }).click(); const body = await response; // The server keeps only factoids whose citation matches fetched research. @@ -55,7 +55,7 @@ test("Ask with web search shows the sources it used", async ({ signedIn: page }) await page.getByRole("button", { name: "Options" }).click(); await page.locator("#web-search-mode").click(); await page.keyboard.press("Escape"); - await page.getByPlaceholder("Ask your debate coach...").fill("What did recent studies find about fare-free public transport ridership?"); + await page.getByPlaceholder("Ask your debate coach…").fill("What did recent studies find about fare-free public transport ridership?"); const response = aiResponse(page, "ask"); await page.getByRole("button", { name: "Send message" }).click(); const body = await response; diff --git a/e2e/loading-performance.spec.ts b/e2e/loading-performance.spec.ts index 16b6613d..9364c13b 100644 --- a/e2e/loading-performance.spec.ts +++ b/e2e/loading-performance.spec.ts @@ -65,8 +65,8 @@ test('authenticated routes keep the shell and current page after refresh', async await stubSupabaseSession(page); for (const [path, heading] of [ ['/app', 'Prepare'], - ['/app/generate', 'Ready to Generate'], - ['/app/attack', 'No Mercy Mode'], + ['/app/generate', 'Your case will appear here'], + ['/app/attack', 'Your critique will appear here'], ] as const) { await page.goto(path); await page.reload(); diff --git a/e2e/model-matrix/generate.spec.ts b/e2e/model-matrix/generate.spec.ts index cb09c10a..17a2084c 100644 --- a/e2e/model-matrix/generate.spec.ts +++ b/e2e/model-matrix/generate.spec.ts @@ -13,7 +13,7 @@ for (const model of MODEL_CATALOG) { await page.goto("/app/generate"); await page.getByRole("combobox", { name: "Select AI model for generate" }).click(); await page.getByRole("option", { name: new RegExp(`^${model.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`) }).click(); - await page.getByRole("textbox", { name: "Motion / Topic *" }).fill("This House would make public transport free at the point of use"); + await page.getByRole("textbox", { name: "Motion / topic *" }).fill("This House would make public transport free at the point of use"); const response = aiResponse(page, "generate"); await page.getByRole("button", { name: "Generate debate case" }).click(); await chooseForMotion(page, "once"); diff --git a/e2e/navigation-performance.spec.ts b/e2e/navigation-performance.spec.ts index b3fb103e..dc195ce9 100644 --- a/e2e/navigation-performance.spec.ts +++ b/e2e/navigation-performance.spec.ts @@ -24,7 +24,7 @@ const privateRoutes = [ ['/app', { selector: 'main h1', text: 'What are you working on?' }], ['/app/generate', { selector: 'main h1', text: 'Generate' }], ['/app/attack', { selector: 'main h1', text: 'Attack' }], - ['/app/flowchart', { selector: 'main h1', text: 'Argument Flowchart' }], + ['/app/flowchart', { selector: 'main h1', text: 'Argument flowchart' }], ['/app/history', { selector: 'main h1', text: 'History' }], ['/app/admin', { selector: 'main h1', text: 'Access Denied' }], ] as const; diff --git a/e2e/production/canary.spec.ts b/e2e/production/canary.spec.ts index 5d7d739a..7169ae0e 100644 --- a/e2e/production/canary.spec.ts +++ b/e2e/production/canary.spec.ts @@ -92,7 +92,7 @@ test("the canary account gets a streamed answer from a real model", async ({ pag await signInAsCanary(page, { aiRequest: true }); await page.goto("/app/ask"); await pickBasicDefault(page, "ask"); - await page.getByPlaceholder("Ask your debate coach...").fill("Reply with one short sentence: what is a rebuttal?"); + await page.getByPlaceholder("Ask your debate coach").fill("Reply with one short sentence: what is a rebuttal?"); const stream = page.waitForResponse((response) => response.url().endsWith("/functions/v1/ai-stream"), { timeout: 90_000 }); await page.getByRole("button", { name: "Send message" }).click(); const response = await stream; @@ -114,7 +114,7 @@ test("the canary account generates a case", async ({ page }) => { await signInAsCanary(page, { aiRequest: true }); await page.goto("/app/generate"); await pickBasicDefault(page, "generate"); - await page.getByRole("textbox", { name: "Motion / Topic *" }).fill("This House would make public transport free"); + await page.getByRole("textbox", { name: "Motion / topic *" }).fill("This House would make public transport free"); const generated = page.waitForResponse((response) => response.url().endsWith("/functions/v1/ai") && response.request().method() === "POST", { timeout: 150_000 }); await page.getByRole("button", { name: "Generate debate case" }).click(); // Use the motion once so the canary account does not collect production debates. diff --git a/e2e/staging-switch.spec.ts b/e2e/staging-switch.spec.ts index 9f9d7ebe..1471350a 100644 --- a/e2e/staging-switch.spec.ts +++ b/e2e/staging-switch.spec.ts @@ -30,7 +30,7 @@ for (const path of ['/', '/login']) { const navBox = await page.locator('nav.fixed').boundingBox(); expect(navBox!.y).toBeGreaterThanOrEqual(stripBox!.y + stripBox!.height - 1); } - await strip.getByRole('button', { name: 'Switch to Production' }).click({ timeout: 5_000 }); + await strip.getByRole('button', { name: 'Switch to production' }).click({ timeout: 5_000 }); await expect(strip).toHaveCount(0); expect(await page.evaluate(() => localStorage.getItem('debate-supabase-env'))).toBeNull(); @@ -52,7 +52,7 @@ test('switching in one tab reloads the other tabs onto the same project', async const stripIn = (page: typeof other) => page.getByRole('status').filter({ hasText: 'Staging backend' }); await expect(stripIn(other)).toBeVisible(); - await stripIn(switching).getByRole('button', { name: 'Switch to Production' }).click(); + await stripIn(switching).getByRole('button', { name: 'Switch to production' }).click(); await expect(stripIn(switching)).toHaveCount(0); await expect(stripIn(other)).toHaveCount(0); diff --git a/e2e/staging/authed-flows.spec.ts b/e2e/staging/authed-flows.spec.ts index 2cac4901..6c1a7855 100644 --- a/e2e/staging/authed-flows.spec.ts +++ b/e2e/staging/authed-flows.spec.ts @@ -120,7 +120,7 @@ test('real staging session persists, starts a debate, writes and removes a sourc const sourceInsert = page.waitForResponse((response) => response.request().method() === 'POST' && response.url().includes('/rest/v1/sources') ); - await page.getByRole('button', { name: 'Save to Library' }).click(); + await page.getByRole('button', { name: 'Save to library' }).click(); expect((await sourceInsert).ok()).toBe(true); sourceCreated = true; await expect(page.getByText(sourceTitle, { exact: true })).toBeVisible(); @@ -151,9 +151,9 @@ test('real staging session persists, starts a debate, writes and removes a sourc await page.goto('/app/ask'); // Ask's modes live behind the Options popover since the design shell. await page.getByRole('button', { name: 'Options' }).click(); - await page.getByRole('switch', { name: 'Pros/Cons' }).click(); + await page.getByRole('switch', { name: 'Pros/cons' }).click(); await page.keyboard.press('Escape'); - await page.getByPlaceholder('Ask your debate coach...').fill('Reply with one short sentence confirming this staging request.'); + await page.getByPlaceholder('Ask your debate coach').fill('Reply with one short sentence confirming this staging request.'); const aiResponse = page.waitForResponse((response) => response.request().method() === 'POST' && response.url().includes('/functions/v1/ai'), { timeout: 90_000 }); diff --git a/src/components/features/debate/DebateContextControl.tsx b/src/components/features/debate/DebateContextControl.tsx index 00605ec9..3d8deec5 100644 --- a/src/components/features/debate/DebateContextControl.tsx +++ b/src/components/features/debate/DebateContextControl.tsx @@ -174,10 +174,10 @@ function DebateContextReview({ plan, motion, usage, pinsReady, resultsComplete } <p className="text-sm text-muted-foreground"> {plan.feature === 'sparring' ? t('debateContext.sparringNoSources', 'Live sparring rounds use only the transcript, the motion, and your debate settings.') - : t('debateContext.sourcesOff', 'Source Library is off for this request.')} + : t('debateContext.sourcesOff', 'Source library is off for this request.')} </p> ) : plan.sources.length === 0 ? ( - <p className="text-sm text-muted-foreground">{t('debateContext.noSources', 'This debate has no Library sources yet.')}</p> + <p className="text-sm text-muted-foreground">{t('debateContext.noSources', 'This debate has no library sources yet.')}</p> ) : ( <ul className="space-y-1"> {plan.sources.map((source) => ( @@ -231,7 +231,7 @@ function statusNote(item: { overLimit: boolean; omittedForSize: boolean; shorten function SourceRow({ source, onChange }: { source: ContextSourceItem; onChange: (include: boolean) => void }) { const { t } = useTranslation(); const id = `debate-context-source-${source.id}`; - const note = source.included ? statusNote(source, t) : (!source.active ? t('debateContext.inactive', 'Turned off in your Library.') : null); + const note = source.included ? statusNote(source, t) : (!source.active ? t('debateContext.inactive', 'Turned off in your library.') : null); return ( <li className="flex items-start gap-3 rounded-md px-1 py-1.5 pointer-coarse:min-h-11"> <Checkbox id={id} checked={source.included} onCheckedChange={(checked) => onChange(checked === true)} className="mt-0.5 pointer-coarse:after:-top-2" /> diff --git a/src/components/features/tools/result-actions.tsx b/src/components/features/tools/result-actions.tsx index 833a52b0..39606d5b 100644 --- a/src/components/features/tools/result-actions.tsx +++ b/src/components/features/tools/result-actions.tsx @@ -38,7 +38,7 @@ export function SaveResultAction({ saved, onSave, disabled = false }: { saved: b return ( <span className="inline-flex h-9 items-center gap-1.5 px-2 text-sm text-muted-foreground" data-testid="result-saved"> <Check className="h-4 w-4 text-primary" aria-hidden="true" /> - {t('resultActions.savedToHistory', 'Saved to History')} + {t('resultActions.savedToHistory', 'Saved to history')} </span> ); } diff --git a/src/lib/ai/error-display.ts b/src/lib/ai/error-display.ts index 993bcd46..3a494631 100644 --- a/src/lib/ai/error-display.ts +++ b/src/lib/ai/error-display.ts @@ -58,7 +58,7 @@ function describeAIError(error: unknown): string { } if (normalized.includes('rate limit') || normalized.includes('rate_limit') || normalized.includes('too many requests')) { - return i18n.t('aiErrors.rateLimit', { provider, defaultValue: '{{provider}} rate limit exceeded. Please try again later.' }); + return i18n.t('aiErrors.rateLimit', { provider, defaultValue: '{{provider}} rate limit exceeded. Try again in a minute.' }); } if ( @@ -67,14 +67,14 @@ function describeAIError(error: unknown): string { normalized.includes('service unavailable') || normalized.includes('503') ) { - return i18n.t('aiErrors.unavailable', { provider, defaultValue: '{{provider}} is temporarily unavailable. Please try again later.' }); + return i18n.t('aiErrors.unavailable', { provider, defaultValue: '{{provider}} is temporarily unavailable. Try again in a minute.' }); } if (normalized.includes('content policy') || normalized.includes('safety') || normalized.includes('blocked')) { return i18n.t('aiErrors.safety', { provider, defaultValue: '{{provider}} blocked this request for safety reasons. Try rephrasing your input.' }); } - return i18n.t('aiErrors.unexpectedProvider', 'The AI provider returned an unexpected error. Please try again later.'); + return i18n.t('aiErrors.unexpectedProvider', 'The AI provider returned an unexpected error. Try again in a minute.'); } return error instanceof Error ? error.message : i18n.t('aiErrors.unexpected', 'An unexpected error occurred'); diff --git a/src/lib/ai/sources.test.ts b/src/lib/ai/sources.test.ts index 3b967142..311a49d8 100644 --- a/src/lib/ai/sources.test.ts +++ b/src/lib/ai/sources.test.ts @@ -105,6 +105,6 @@ describe('autosaveDiscoveredSources', () => { ], 'attack', DEBATE); expect(toast).toHaveBeenCalledOnce(); - expect(vi.mocked(toast).mock.calls[0][0]).toMatchObject({ title: 'Added 2 sources to your Library' }); + expect(vi.mocked(toast).mock.calls[0][0]).toMatchObject({ title: 'Added 2 sources to your library' }); }); }); diff --git a/src/lib/debateowl-intro.ts b/src/lib/debateowl-intro.ts index 0f406aac..e1eb4fb3 100644 --- a/src/lib/debateowl-intro.ts +++ b/src/lib/debateowl-intro.ts @@ -1,6 +1,6 @@ /** * The one-time DebateOwl welcome: the owl says hello, says what changed (the look only) and that - * DebateLab is slowly becoming DebateOwl. It opens once per browser, inside the signed-in app. + * DebateLab is becoming DebateOwl. It opens once per browser, inside the signed-in app. */ export const INTRO_SEEN_KEY = 'debateowl_intro_seen_v1'; diff --git a/src/locales/en.json b/src/locales/en.json index ebb083b2..3c80a64b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -13,9 +13,9 @@ "flowchart": "Flowchart", "history": "History", "features": "Features", - "whatsNew": "What's New", + "whatsNew": "What's new", "pricing": "Pricing", - "howItWorks": "How It Works", + "howItWorks": "How it works", "startTrial": "Start trial", "settings": "Settings", "openMenu": "Open menu", @@ -257,8 +257,8 @@ "sourceSaveFailed": "Source save failed", "sourceSaveFailedDesc": "Could not save “{{title}}” to your library.", "sourceAssessmentSaveFailed": "The source ranking could not be saved.", - "sourcesAutoSaved_one": "Added {{count}} source to your Library", - "sourcesAutoSaved_other": "Added {{count}} sources to your Library", + "sourcesAutoSaved_one": "Added {{count}} source to your library", + "sourcesAutoSaved_other": "Added {{count}} sources to your library", "sourcesAutoSavedDesc": "Research found them. They are in the Auto-saved folder, and the ones switched on ground your next requests." }, "generatedArtifacts": { @@ -336,8 +336,6 @@ } }, "home": { - "title": "DebateLab", - "subtitle": "Choose a mode to start.", "prepare": "Prepare", "practice": "Practice", "utilities": "Utilities", @@ -366,18 +364,18 @@ "heading": "What are you working on?" }, "generate": { - "emptyTitle": "Ready to Generate", - "emptyDesc": "Enter your motion and preferences to generate a complete debate case with arguments, counters, and strategic analysis.", - "saved": "Case Saved", + "emptyTitle": "Your case will appear here", + "emptyDesc": "Add a motion, pick your side, and generate.", + "saved": "Case saved", "savedDesc": "Your case has been saved to history.", "savePrompt": "Enter a title for this case:", - "failed": "Generation Failed", - "timeout": "Request Timed Out", + "failed": "Generation failed", + "timeout": "Request timed out", "timeoutDesc": "The AI took too long to respond. Please try again.", - "authRequired": "Authentication Required", - "flowchartImported": "Case Imported", + "authRequired": "Sign-in required", + "flowchartImported": "Case imported", "flowchartImportedDesc": "Your case has been imported to Flowcharts.", - "flowchartImportFailed": "Import Failed", + "flowchartImportFailed": "Import failed", "flowchartImportFailedDesc": "Could not build flowchart from case data.", "flowchartNoData": "No valid case data to import.", "caseTitle": "Case title", @@ -394,31 +392,31 @@ "flowchartOtherDebate": "The Flowchart takes only a case generated in the current debate. Generate the case here to send it." }, "attack": { - "emptyTitle": "No Mercy Mode", - "emptyDesc": "Paste your argument to receive ruthless criticism, identify logical fallacies, and get actionable improvements.", - "failed": "Attack Analysis Failed", + "emptyTitle": "Your critique will appear here", + "emptyDesc": "Paste an argument to find its weak points.", + "failed": "Attack analysis failed", "working": "Reviewing your case…", "previousCritique": "Critique of your previous argument", "previousCritiqueDesc": "Your latest version was not reviewed, so this critique may not match the text in the form." }, "brainstorm": { - "emptyTitle": "Unlock Creative Angles", - "emptyDesc": "Describe your motion to explore unconventional argument angles, identify tradeoffs, and find your strongest positions.", - "failed": "Brainstorming Failed", + "emptyTitle": "Your angles will appear here", + "emptyDesc": "Describe a motion to explore new angles.", + "failed": "Brainstorming failed", "working": "Finding angles…" }, "factoids": { - "emptyTitle": "Evidence That Wins", - "emptyDesc": "Enter a claim to find supporting factoids with real citations, context, and guidance on how to use them effectively.", - "failed": "Research Failed", - "savedToLibrary": "Saved to Library", + "emptyTitle": "Your evidence will appear here", + "emptyDesc": "Enter a claim to find cited evidence.", + "failed": "Research failed", + "savedToLibrary": "Saved to library", "savedToLibraryDesc": "This evidence is now available for grounding.", "saveFailed": "Could not save source.", "working": "Searching for evidence…" }, "ask": { - "failed": "Error", - "timeout": "Request Timed Out", + "failed": "Something went wrong", + "timeout": "Request timed out", "timeoutDesc": "The request took too long. Please try again.", "saved": "Saved", "savedDesc": "Source added to library.", @@ -441,17 +439,17 @@ "chat": {}, "settings": { "title": "Settings", - "aiOutputLanguage": "Language (UI + AI Output)", - "uiLanguage": "Interface Language", - "outputLanguage": "AI Output Language", + "aiOutputLanguage": "Language (UI + AI output)", + "uiLanguage": "Interface language", + "outputLanguage": "AI output language", "theme": "Theme", "light": "Light", "dark": "Dark", "system": "System", "subscription": "Subscription", - "tierTrial": "Free Trial", + "tierTrial": "Free trial", "tierPro": "Pro", - "tierLegacyFree": "Legacy Free", + "tierLegacyFree": "Legacy free", "tierExpired": "Expired", "ends": "Ends", "loading": "Loading...", @@ -466,7 +464,7 @@ "promoAccessUntil": "Your Pro access comes from a promo code and runs until {{date}}. Billing is optional until then; set it up only to keep Pro after that date.", "promoAccess": "Your Pro access comes from a promo code, so billing is optional.", "accessExpired": "Your access has expired. Upgrade to continue using AI tools.", - "globalSettings": "Global Settings", + "globalSettings": "General", "account": "Account", "accountDesc": "How you sign in, and a copy of everything you saved.", "signedInAs": "Signed in as", @@ -477,20 +475,20 @@ "signInDemo": "Sign-in: demo code", "signOut": "Sign out", "signOutFailed": "Could not sign out. Please try again.", - "aiModels": "AI Models", + "aiModels": "AI models", "aiModelsDesc": "Select your preferred model directly from each feature page using the model dropdown in the header.", "admin": "Admin", "adminDesc": "Open the admin dashboard for usage, users, limits, and promo codes.", "openAdmin": "Open admin panel", "developer": "Developer", "developerDesc": "Switch the app between production and staging Supabase projects. You will be signed out.", - "switchToStaging": "Switch to Staging", - "switchToProduction": "Switch to Production", - "envSwitchTitle": "Switch Environment", + "switchToStaging": "Switch to staging", + "switchToProduction": "Switch to production", + "envSwitchTitle": "Switch environment", "envSwitchDesc": "You'll be signed out and the app will reload pointing at {{target}}. You'll need to sign in again.", "envSwitchConfirm": "Switch & sign out", "stagingBanner": "Staging backend", - "dangerZone": "Danger Zone", + "dangerZone": "Delete data", "dangerDesc": "Permanently delete all your saved data. This only affects your account ({{email}}).", "deleteAllData": "Delete all my data", "deleteDialogTitle": "Delete all data", @@ -498,11 +496,11 @@ "typeEmailConfirm": "Type your email to confirm", "deleteCancelled": "Cancelled", "deleteCancelledDesc": "Email didn't match. No data was deleted.", - "deleteSuccess": "Data Deleted", + "deleteSuccess": "Data deleted", "deleteSuccessDesc": "All your saved data has been permanently removed.", "deleteFailed": "Error", "deleteFailedDesc": "Failed to delete data. Please try again.", - "noSubscription": "No Subscription Found", + "noSubscription": "No subscription found", "noSubscriptionDesc": "This account is not connected to Stripe billing. Upgrade to Pro to start a subscription.", "portalError": "Error", "adminAccessFailed": "Could not check admin access.", @@ -875,8 +873,9 @@ }, "notFound": { "title": "404", - "message": "Oops! Page not found", - "link": "Return to home" + "message": "Page not found", + "link": "Return to home", + "description": "That link doesn't go anywhere. It may have moved." }, "footer": { "terms": "Terms", @@ -935,24 +934,24 @@ } }, "features": { - "title": "Everything You Need to Win", - "generate": "Case Generator", + "title": "Everything you need to win", + "generate": "Case generator", "generateDesc": "Build complete cases with structured arguments, evidence, and counter-strategies.", - "attack": "Case Attacker", + "attack": "Case attacker", "attackDesc": "Stress-test your arguments and find weaknesses before opponents do.", "brainstorm": "Brainstorm", "brainstormDesc": "Explore unconventional angles and discover fresh argument paths.", "factoids": "Factoids", "factoidsDesc": "Find debate-ready evidence with verifiable citations.", - "ask": "Ask Coach", + "ask": "Ask coach", "askDesc": "Get mentoring on strategy, structure, and technique.", "crossfire": "Crossfire", "crossfireDesc": "Practice focused questions and answers in a timed exchange.", "sparring": "Sparring", "sparringDesc": "Practice a timed round against an opponent and review the scorecard.", - "library": "Source Library", + "library": "Source library", "libraryDesc": "Manage sources that ground AI responses in real evidence.", - "timer": "Debate Timer", + "timer": "Debate timer", "timerDesc": "Practice with tournament-accurate timing controls.", "flowchart": "Flowchart", "flowchartDesc": "Map argument structures and rebuttal links visually.", @@ -960,7 +959,7 @@ "historyDesc": "Access saved cases, chats, and previous prep sessions." }, "howItWorks": { - "title": "How It Works", + "title": "How it works", "subtitle": "Three steps to better debate prep.", "step1Title": "Research", "step1Desc": "AI scans verified sources and builds context for your motion.", @@ -970,11 +969,11 @@ "step3Desc": "Refine with coaching, timing drills, and argument mapping." }, "useCases": { - "title": "Built For Everyone in Debate", + "title": "Built for everyone in debate", "description": "Whether you are competing, coaching, or judging, DebateLab gives you the edge.", - "forDebaters": "For Debaters", - "forCoaches": "For Coaches", - "forJudges": "For Judges", + "forDebaters": "For debaters", + "forCoaches": "For coaches", + "forJudges": "For judges", "debater1": "Generate cases in seconds, not hours", "debater2": "Find evidence with real citations", "debater3": "Stress-test arguments before rounds", @@ -987,12 +986,12 @@ "judge2": "Understand argument structures quickly", "judge3": "Find evidence to verify claims", "judge4": "Map argument flows visually", - "debaterSubtitle": "Cut Prep Time in Half", - "coachSubtitle": "Scale Your Feedback", - "judgeSubtitle": "Deepen Your Analysis" + "debaterSubtitle": "Cut prep time in half", + "coachSubtitle": "Scale your feedback", + "judgeSubtitle": "Deepen your analysis" }, "pricing": { - "title": "Simple, Transparent Pricing", + "title": "Simple, clear pricing", "subtitle": "Start free, upgrade when you're ready.", "trial": "Trial", "trialPrice": "$0", @@ -1013,14 +1012,14 @@ "startTrial": "Start free trial", "subscribe": "Subscribe now", "noFreePlan": "There is no permanent free plan. After your trial ends, a subscription is required to continue using DebateLab.", - "trialBadge": "New Users", + "trialBadge": "New users", "trialDesc": "Use the full workflow before deciding on Pro.", - "proBadge": "Power Users", + "proBadge": "Power users", "proDesc": "For heavy prep weeks, tougher topics, and higher daily limits.", "asOf": "Prices as of {{asOf}}." }, "faq": { - "title": "Frequently Asked Questions", + "title": "Frequently asked questions", "q1": "How much does DebateLab cost?", "a1": "As of {{asOf}}, {{product}} offers a {{trialDays}}-day free trial with no credit card required. After that, Pro is ${{proMonthly}}/month + tax for expanded access and all 10 models. There is no permanent free plan.", "q2": "What do I get with Pro?", @@ -1074,43 +1073,43 @@ } }, "finalCta": { - "title": "Ready to Build Your Next Winning Case?", + "title": "Ready to build your next case?", "subtitle": "Join debaters who prep smarter with AI.", "cta": "Launch DebateLab", "freeTrialAvailable": "Free trial available" }, "whatsNew": { - "title": "What's New", + "title": "What's new", "subtitle": "Latest updates and improvements.", - "sub1Title": "Subscription Tiers", + "sub1Title": "Subscription tiers", "sub1Desc": "Free trial and Pro plan with higher usage limits.", - "sub2Title": "Argument Flowcharts", + "sub2Title": "Argument flowcharts", "sub2Desc": "Visual argument mapping with drag-and-drop nodes.", - "sub3Title": "Upgraded Timer", + "sub3Title": "Upgraded timer", "sub3Desc": "Tournament-accurate timing with custom formats.", - "sub4Title": "Saved Prep History", + "sub4Title": "Saved prep history", "sub4Desc": "Access all your previous cases and sessions.", - "sub5Title": "Crossfire Practice", + "sub5Title": "Crossfire practice", "sub5Desc": "Train focused questions and answers in a timed exchange.", - "sub5Badge": "New Tool", - "sub6Title": "Sparring Rounds", + "sub5Badge": "New tool", + "sub6Title": "Sparring rounds", "sub6Desc": "Practice a timed debate round and review the scorecard.", - "sub6Badge": "New Tool", + "sub6Badge": "New tool", "sub1Badge": "Billing", - "sub2Badge": "New Tool", + "sub2Badge": "New tool", "sub3Badge": "Improved", "sub4Badge": "Productivity" }, "deepDive": { - "title": "Active Grounding™ Technology", + "title": "Active Grounding™ technology", "subtitle": "How we keep AI responses honest.", - "step1": "AI Scanning via Exa", + "step1": "AI scanning via Exa", "step1Desc": "Neural search finds relevant, high-quality sources.", - "step2": "Knowledge Base", + "step2": "Knowledge base", "step2Desc": "Your sources are vectorized for semantic matching.", - "step3": "Context Injection", + "step3": "Context injection", "step3Desc": "Verified facts are injected into the AI prompt.", - "step4": "Grounded Output", + "step4": "Grounded output", "step4Desc": "Responses cite real sources, not hallucinations.", "preview": { "processHeading": "// Real-time Context Process", @@ -1124,26 +1123,26 @@ } }, "rateLimit": { - "title": "Rate Limit Exceeded" + "title": "Too many requests" }, "generateForm": { - "motion": "Motion / Topic", + "motion": "Motion / topic", "motionPlaceholder": "e.g., This house believes that artificial intelligence will create more jobs than it destroys", "side": "Side", "sidePro": "In favor", "sideCon": "Against", - "format": "Debate Format", - "role": "Speaker Role", + "format": "Debate format", + "role": "Speaker role", "roleIntro": "Introductor (4 min)", "roleR1": "R1 - Rebuttal/Arg (6 min)", "roleR2": "R2 - Rebuttal/Build (6 min)", "roleConc": "Conclusor (3 min)", "moreOptions": "More options", - "length": "Case Length", + "length": "Case length", "lengthBrief": "Brief", "lengthStandard": "Standard", "lengthComprehensive": "Comprehensive", - "audience": "Audience Level", + "audience": "Audience level", "audienceBeginner": "Beginner", "audienceIntermediate": "Intermediate", "audienceAdvanced": "Advanced", @@ -1151,9 +1150,9 @@ "toneFormal": "Formal", "toneConversational": "Conversational", "toneAggressive": "Aggressive", - "generating": "Generating case...", + "generating": "Generating case…", "submit": "Generate debate case", - "listening": "Listening... Speak your motion clearly", + "listening": "Listening… Speak your motion clearly", "stopListening": "Stop listening", "startVoice": "Start voice input", "stopVoice": "Stop voice input", @@ -1161,7 +1160,7 @@ "styleNote": "Guides writing style; it does not change research." }, "generateResults": { - "incomplete": "Generation Incomplete", + "incomplete": "Generation incomplete", "incompleteDesc": "The AI response was incomplete or malformed. Please try generating again.", "proposition": "In favor", "opposition": "Against", @@ -1176,23 +1175,23 @@ "exordioDesc": "Your compelling opening", "hook": "Hook", "transition": "Transition", - "caseStructure": "Case Structure", + "caseStructure": "Case structure", "caseStructureDesc": "Your argument framework", "thesis": "Thesis", "framework": "Framework", - "burdenOfProof": "Burden of Proof", - "keyDefinitions": "Key Definitions", + "burdenOfProof": "Burden of proof", + "keyDefinitions": "Key definitions", "noDefinitions": "No definitions provided", "arguments": "Arguments", "structuredArguments": "structured arguments", - "predictedOpponent": "Predicted Opponent Case", + "predictedOpponent": "Predicted opponent case", "predictedOpponentDesc": "What they'll likely argue", - "likelyArguments": "Likely Arguments", - "strategicWeaknesses": "Strategic Weaknesses", - "keyClashes": "Key Clashes", - "preparedCounters": "Prepared Counters", + "likelyArguments": "Likely arguments", + "strategicWeaknesses": "Strategic weaknesses", + "keyClashes": "Key clashes", + "preparedCounters": "Prepared counters", "preparedCountersDesc": "Ready-to-use rebuttals", - "needsSource": "Needs Source", + "needsSource": "Needs source", "reasoning": "Reasoning:", "evidence": "Evidence:", "searchSuggestion": "Search suggestion:", @@ -1212,43 +1211,43 @@ "unresearchedCaveat": "AI-generated, and no web research backs this evidence. Check facts and sources before using them in a round." }, "attackForm": { - "argument": "Your Argument", + "argument": "Your argument", "argumentPlaceholder": "Paste your debate case or argument here...", "options": "Options", "moreOptions": "More options", - "intensity": "Attack Intensity", - "intensityMild": "Mild (Constructive)", - "intensityModerate": "Moderate (Balanced)", - "intensityRuthless": "Ruthless (No Mercy)", - "paradigm": "Judge Paradigm", + "intensity": "Attack intensity", + "intensityMild": "Mild (constructive)", + "intensityModerate": "Moderate (balanced)", + "intensityRuthless": "Ruthless (no mercy)", + "paradigm": "Judge paradigm", "paradigmLogic": "Logic-Heavy", "paradigmValues": "Values-Focused", - "paradigmPolicy": "Policy/Impact", - "detectFallacies": "Detect Fallacies", + "paradigmPolicy": "Policy/impact", + "detectFallacies": "Detect fallacies", "detectFallaciesDesc": "Identify logical fallacies in the argument", - "analyzeWeighing": "Analyze Weighing", + "analyzeWeighing": "Analyze weighing", "analyzeWeighingDesc": "Evaluate impact comparison and prioritization", "analyzing": "Analyzing case...", "submit": "Find weaknesses" }, "attackResults": { - "overall": "Overall Assessment", - "caseStrength": "Case Strength", - "weaknesses": "Identified Weaknesses", + "overall": "Overall assessment", + "caseStrength": "Case strength", + "weaknesses": "Identified weaknesses", "weaknessesCount": "{{count}} issues found, sorted by severity", - "fallacies": "Fallacies Detected", + "fallacies": "Fallacies detected", "fallaciesDesc": "Logical errors in your argument", - "crossEx": "Cross-Examination Questions", + "crossEx": "Cross-examination questions", "crossExDesc": "Questions opponents might ask you", - "weighing": "Weighing Analysis", + "weighing": "Weighing analysis", "weighingDesc": "How well you compare impacts", - "currentWeighing": "Current Weighing", - "missingComparisons": "Missing Comparisons", - "improvements": "Improvement Suggestions", - "strengths": "Strength Areas", + "currentWeighing": "Current weighing", + "missingComparisons": "Missing comparisons", + "improvements": "Improvement suggestions", + "strengths": "Strength areas", "whyWeak": "Why it's weak:", "opponentAttack": "Opponent attack:", - "improvedVersion": "Improved Version", + "improvedVersion": "Improved version", "purpose": "Purpose:", "expectedTrap": "Expected trap:", "in": "in", @@ -1273,42 +1272,42 @@ "weaknessesCount_one": "{{count}} issue found, sorted by severity", "weaknessesCount_other": "{{count}} issues found, sorted by severity", "resultTitle": "Critique", - "criticalCount_one": "{{count}} Critical", - "criticalCount_other": "{{count}} Critical", - "majorCount_one": "{{count}} Major", - "majorCount_other": "{{count}} Major", - "minorCount_one": "{{count}} Minor", - "minorCount_other": "{{count}} Minor" + "criticalCount_one": "{{count}} critical", + "criticalCount_other": "{{count}} critical", + "majorCount_one": "{{count}} major", + "majorCount_other": "{{count}} major", + "minorCount_one": "{{count}} minor", + "minorCount_other": "{{count}} minor" }, "brainstormForm": { - "motion": "Motion / Topic", + "motion": "Motion / topic", "motionPlaceholder": "e.g., Universal Basic Income should be implemented globally", - "side": "Side to Explore", - "sideBoth": "Explore Both Sides", + "side": "Side to explore", + "sideBoth": "Explore both sides", "sidePro": "In favor Only", - "sideCon": "Against Only", - "outputFormat": "Output Format", - "outputTop5": "Top 5 Best Angles", - "outputMindmap": "Mind Map Style", - "outputBuckets": "Categorized Buckets", - "existing": "What You Already Have (Optional)", + "sideCon": "Against only", + "outputFormat": "Output format", + "outputTop5": "Top 5 best angles", + "outputMindmap": "Mind map style", + "outputBuckets": "Categorized buckets", + "existing": "What you already have (optional)", "existingPlaceholder": "Share any ideas or angles you've already considered...", "generating": "Generating ideas...", "submit": "Start brainstorming" }, "brainstormResults": { - "clarification": "Clarification Needed", + "clarification": "Clarification needed", "clarificationDesc": "Please answer these questions for better results", - "top5": "Top 5 Argument Angles", + "top5": "Top 5 argument angles", "top5Desc": "Best options ranked by potential", - "argMap": "Argument Map", + "argMap": "Argument map", "argMapDesc": "Ideas organized by category", - "nextSteps": "Recommended Next Steps", - "warnings": "Warnings & Tradeoffs", + "nextSteps": "Recommended next steps", + "warnings": "Warnings & tradeoffs", "pro": "PRO", "con": "CON", "neutral": "NEUTRAL", - "untitled": "Untitled Idea", + "untitled": "Untitled idea", "tradeoffs": "Tradeoffs:", "discuss": "Discuss ideas", "exportedDocx": "Brainstorm exported to DOCX!", @@ -1327,39 +1326,39 @@ "direction": "Position", "directionFor": "In favor", "directionAgainst": "Against", - "region": "Region (Optional)", + "region": "Region (optional)", "regionPlaceholder": "Global by default", "sourceCount": "Sources", - "timeframe": "Timeframe (Optional)", + "timeframe": "Timeframe (optional)", "timeframePlaceholder": "e.g., 2020-2024, Last decade", - "sourceType": "Source Type", - "sourceAny": "Any Reputable Source", - "sourceAcademic": "Academic Only", - "sourceGovernment": "Government Sources", - "sourceNews": "Major News", + "sourceType": "Source type", + "sourceAny": "Any reputable source", + "sourceAcademic": "Academic only", + "sourceGovernment": "Government sources", + "sourceNews": "Major news", "finding": "Finding evidence...", - "submit": "Find Factoids" + "submit": "Find factoids" }, "factoidsResults": { - "verificationStatus": "Verification Status", + "verificationStatus": "Verification status", "verified": "Verified", "unverified": "Unverified", "pending": "Pending", - "importantNotes": "Important Notes", + "importantNotes": "Important notes", "factoids": "Factoids", "factoidsDesc": "Evidence points with citations", - "findMore": "Find More Evidence", + "findMore": "Find more evidence", "findMoreDesc": "Suggested search queries", "relevance": "Relevance:", - "keyFigure": "Key Figure:", + "keyFigure": "Key figure:", "context": "Context:", "limitations": "Limitations:", - "unknownSource": "Unknown Source", + "unknownSource": "Unknown source", "unknown": "Unknown", - "viewSource": "View Source", + "viewSource": "View source", "urlNeedsVerification": "URL requires verification", "howToUse": "How to use:", - "saveToLibrary": "Save to Library", + "saveToLibrary": "Save to library", "saveClaimToLibrary": "Save {{claim}} to library", "discuss": "Discuss evidence", "exportedDocx": "Factoids exported to DOCX!", @@ -1373,19 +1372,19 @@ "resultTitle": "Evidence", "foundCount_one": "Found {{count}} factoid", "foundCount_other": "Found {{count}} factoids", - "verifiedCount_one": "{{count}} Verified", - "verifiedCount_other": "{{count}} Verified" + "verifiedCount_one": "{{count}} verified", + "verifiedCount_other": "{{count}} verified" }, "askChat": { - "title": "Ask Your Coach", + "title": "Ask your coach", "coachAvatarAlt": "The owl, your debate coach", "subtitle": "Get answers to debate questions", "startConversation": "Start a conversation", "startDesc": "Ask anything about debate strategy, arguments, or get help with your case.", "modeGeneral": "General", "modeClarify": "Clarify", - "modeProsCons": "Pros/Cons", - "modeArgMap": "Arg Map", + "modeProsCons": "Pros/cons", + "modeArgMap": "Arg map", "citations": "Citations", "webSearch": "Web search", "citationResults": "Citations:", @@ -1402,10 +1401,10 @@ "followUp": "Follow-up questions:", "pros": "Pros", "cons": "Cons", - "placeholder": "Ask your debate coach...", + "placeholder": "Ask your debate coach…", "inputLabel": "Message to your debate coach", "placeholderListening": "Listening...", - "saveToLibrary": "Save to Library", + "saveToLibrary": "Save to library", "saveMessage": "Save message to library", "savedChats": "Saved chats", "savedChatsDesc": "Resume prior coaching conversations.", @@ -1437,21 +1436,21 @@ "options": "Options" }, "limitModal": { - "trialEnded": "Free Trial Ended", + "trialEnded": "Your free trial has ended", "trialEndedDesc": "Your {{days}}-day free trial has ended. Upgrade to Pro to keep using DebateLab.", - "limitReached": "Daily Access Paused", + "limitReached": "Daily AI uses used up", "limitReachedDesc": "You've used {{current}} of {{limit}} included AI uses today. Access refreshes at midnight UTC.", "weightedDesc": "This model counts as {{units}} uses, and you have {{remaining}} left today. Pick a lighter model or wait until midnight UTC.", "weightedTooLargeDesc": "This model counts as {{units}} uses, more than your daily limit of {{limit}}. Pick a lighter model.", - "weeklyReached": "Weekly AI Allowance Used", + "weeklyReached": "Weekly AI allowance used", "weeklyReachedDesc": "You've used this week's AI allowance. It refreshes on Monday at 00:00 UTC. Contact support if you need more.", "ok": "OK", "upgradeNote": "More AI prep access + high reasoning", "notNow": "Not now", "gotIt": "OK", - "paymentFailed": "Payment Failed", + "paymentFailed": "Payment failed", "paymentFailedDesc": "Your last payment failed. Update your payment method in Settings to keep using DebateLab.", - "subscriptionEnded": "Pro Subscription Ended", + "subscriptionEnded": "Your Pro subscription has ended", "subscriptionEndedDesc": "Your Pro subscription has ended. Resubscribe to keep using DebateLab.", "subscriptionEndedOnDesc": "Your Pro subscription ended on {{date}}. Resubscribe to keep using DebateLab.", "updatePayment": "Update payment method", @@ -1460,7 +1459,7 @@ "roundShortDesc": "This round needs {{units}} AI uses and you have {{remaining}} left today. Pick a lighter model or wait until midnight UTC." }, "modelModal": { - "title": "Pro Reasoning", + "title": "Pro reasoning", "description": "High reasoning is reserved for Pro members.", "descriptionWithModel": "High reasoning with {{model}} is reserved for Pro members.", "upgradeNote": "Upgrade to unlock high reasoning.", @@ -1496,10 +1495,10 @@ "apply": "Apply", "promoCode": "Have a promo code?", "promoPlaceholder": "Enter promo code", - "checkoutError": "Checkout Error", + "checkoutError": "Checkout error", "checkoutErrorDesc": "Failed to start checkout", - "codeRedeemed": "Code Redeemed", - "invalidCode": "Invalid Code", + "codeRedeemed": "Code redeemed", + "invalidCode": "Invalid code", "redeemError": "Error", "redeemErrorDesc": "Failed to redeem code", "titleYourPlan": "Your plan", @@ -1512,16 +1511,16 @@ "freePeriod_other": "for {{count}} days" }, "onboarding": { - "welcome": "Welcome to DebateLab! 🎉", + "welcome": "Welcome to DebateLab", "generate": "Generate", "generateDesc": "Build a full case for your motion. Brainstorm, Factoids, and Flowchart sit beside it under Prepare.", "library": "Library", "libraryDesc": "Add sources to your Current Debate. Every tool grounds its answers in them.", - "ready": "You're Ready! 🚀", - "readyDesc": "Pick the mode that matches what you need for the next round.", + "ready": "You're ready", + "readyDesc": "Pick a tool for your next round.", "back": "Back", "close": "Close", - "getStarted": "Get started!", + "getStarted": "Get started", "next": "Next", "nextWithProgress": "Next (Step {step} of {steps})", "skip": "Skip tour", @@ -1530,20 +1529,20 @@ }, "debateOwlIntro": { "greeting": "Hi, I'm DebateOwl!", - "greetingNamed": "Hi, I'm {{name}}, the DebateOwl owl!", + "greetingNamed": "Hi, I'm {{name}}!", "newLook": "DebateLab has a new look.", "company": "I'll keep you company while things load.", "sameTools": "Same tools as before, in the same spots.", - "noticeTitle": "DebateLab is slowly becoming DebateOwl", - "noticeBody": "Same app, same account, same saved work, with a new look and name. We're moving to help more people find us on Google.", + "noticeTitle": "DebateLab is becoming DebateOwl", + "noticeBody": "Your account and saved work stay the same.", "skipAnimation": "Skip animation", "done": "Got it" }, "timer": { - "title": "Debate Timer", + "title": "Debate timer", "displayLabel": "Timer display", "speechProgress": "{{speech}} progress", - "selectFormat": "Select Format", + "selectFormat": "Select format", "formatStandard": "School 4-6-6-3", "formatLD": "Lincoln-Douglas", "formatPF": "Public Forum", @@ -1602,7 +1601,7 @@ "resetDone": "Timer reset" }, "flowchart": { - "title": "Argument Flowchart", + "title": "Argument flowchart", "load": "Load", "save": "Save", "addNode": "Add node", @@ -1610,14 +1609,14 @@ "delete": "Delete", "exportPng": "Export PNG", "saveChanges": "Save changes", - "saveFlowchart": "Save Flowchart", - "savedFlowcharts": "Saved Flowcharts", + "saveFlowchart": "Save flowchart", + "savedFlowcharts": "Saved flowcharts", "savedFlowchartsDesc": "Load a previously saved flowchart", - "saveFlowchartDialog": "Save Flowchart", + "saveFlowchartDialog": "Save flowchart", "saveFlowchartDialogDesc": "Give your flowchart a name to save it", - "editNode": "Edit Node", + "editNode": "Edit node", "editNodeDesc": "Modify the selected node", - "addNodeDialog": "Add Node", + "addNodeDialog": "Add node", "addNodeDialogDesc": "Create a new argument node", "noSavedFlowcharts": "No saved flowcharts yet", "labelField": "Label", @@ -1626,8 +1625,8 @@ "typeField": "Type", "strengthField": "Strength", "labelPlaceholder": "Short label...", - "titlePlaceholder": "Flowchart title...", - "descPlaceholder": "Description...", + "titlePlaceholder": "Flowchart title…", + "descPlaceholder": "Description…", "typeArgument": "Argument", "typeAttack": "Attack", "typeRebuttal": "Rebuttal", @@ -1641,10 +1640,10 @@ "legendAttack": "Attack", "legendRebuttal": "Rebuttal", "legendDropped": "Dropped", - "savedToast": "Flowchart saved successfully", - "saveFailedToast": "Failed to save flowchart", + "savedToast": "Flowchart saved", + "saveFailedToast": "Could not save flowchart", "loadedToast": "Flowchart loaded", - "deleteFailedToast": "Failed to delete flowchart", + "deleteFailedToast": "Could not delete flowchart", "starter": { "thesisLabel": "Your Thesis", "thesisDesc": "Click to edit or add new nodes", @@ -1700,11 +1699,11 @@ "tabBrainstorms": "Brainstorms", "tabFactoids": "Factoids", "tabSparring": "Sparring", - "casesTitle": "Saved Cases", - "chatsTitle": "Chat History", - "attacksTitle": "Attack Analyses", - "brainstormsTitle": "Brainstorm Sessions", - "factoidsTitle": "Saved Factoids", + "casesTitle": "Saved cases", + "chatsTitle": "Chat history", + "attacksTitle": "Attack analyses", + "brainstormsTitle": "Brainstorm sessions", + "factoidsTitle": "Saved factoids", "emptyCases": "No saved cases yet", "emptyCasesDesc": "Generated cases you save will appear here.", "emptyChats": "No saved chats yet", @@ -1741,7 +1740,7 @@ "crossfireTitle": "Crossfire rounds", "emptyCrossfire": "No Crossfire rounds yet", "emptyCrossfireDesc": "Finish a Crossfire round to review its transcript and debrief here.", - "startCrossfire": "Start Crossfire", + "startCrossfire": "Start crossfire", "crossfireTurns_one": "{{count}} turn", "crossfireTurns_other": "{{count}} turns", "open": "Open", @@ -1756,7 +1755,7 @@ "openCrossfireAria": "Open Crossfire round: {{title}}" }, "libraryPage": { - "title": "Source Library", + "title": "Source library", "addSource": "Add source", "cancel": "Cancel", "clearAll": "Clear all", @@ -1793,14 +1792,14 @@ "sortFolder": "Folder", "allFolders": "All folders", "importUrlRequired": "Enter a URL to import.", - "importFailed": "Import Failed", + "importFailed": "Import failed", "importFailedDesc": "Could not import this source.", "duplicateSource": "Source already in library", "duplicateSourceDesc": "This URL or file is already saved.", "fileReadFailed": "Could not read this file.", - "saveToLibrary": "Save to Library", - "addNewTitle": "Add New Source", - "yourKBTitle": "Your Knowledge Base", + "saveToLibrary": "Save to library", + "addNewTitle": "Add a source", + "yourKBTitle": "Your sources", "manualSource": "Manual source", "searchSources": "Search sources", "searchPlaceholder": "Search sources, folders, or content...", @@ -1810,11 +1809,11 @@ "titlePlaceholder": "Source title...", "originPlaceholder": "URL or reference...", "contentPlaceholder": "Paste source content...", - "emptyTitle": "No Sources Yet", + "emptyTitle": "No sources yet", "emptyDesc": "Add sources to ground AI responses in real evidence.", - "manualEntry": "Manual Entry", - "groundingActive": "Grounding Active", - "groundingDisabled": "Grounding Disabled", + "manualEntry": "Manual entry", + "groundingActive": "Grounding active", + "groundingDisabled": "Grounding disabled", "groundingActiveDesc": "AI responses use your sources", "groundingDisabledDesc": "AI responses are not grounded", "clearFailed": "Failed to clear sources", @@ -1858,8 +1857,8 @@ "offlineNotSaved": "You are offline. Reconnect and try again.", "toggleFailed": "Could not change this source", "toggleFailedDesc": "It is back to how it was. Try again.", - "sourceSaved": "Saved {{title}} to the Library", - "duplicateUrlDesc": "This page is already in this debate's Library.", + "sourceSaved": "Saved {{title}} to your library", + "duplicateUrlDesc": "This page is already in this debate's library.", "loadingSources": "Loading sources…", "folderUpdateFailed": "Could not change this folder", "sourceCount_one": "{{count}} source", @@ -1883,14 +1882,14 @@ "removedDesc_other": "{{count}} auto-saved sources removed from your library." }, "sharedPage": { - "title": "Shared Content", + "title": "Shared content", "notFoundTitle": "Shared link not found", "backHome": "Back to home", "noCode": "No share code provided", "notFound": "Content not found", "loadFailed": "Failed to load shared content", "contentNotFound": "This shared content is no longer available", - "goToApp": "Go to App", + "goToApp": "Go to app", "openApp": "Open app", "sharedOn": "Shared on", "viewCount_one": "{{count}} view", @@ -1957,39 +1956,39 @@ "format": "Format", "contentionNumber": "Contention #{{number}}", "noThesis": "No thesis provided.", - "assessmentSummary": "Assessment Summary", + "assessmentSummary": "Assessment summary", "noSummary": "No summary provided.", - "strengthAreas": "Strength Areas", + "strengthAreas": "Strength areas", "severity": "Severity", "type": "Type", "description": "Description", - "attackVector": "Attack Vector", + "attackVector": "Attack vector", "fallacy": "Fallacy", "location": "Location", "explanation": "Explanation", "question": "Question", "purpose": "Purpose", - "expectedTrap": "Expected Trap", - "weighingAnalysis": "Weighing Analysis", + "expectedTrap": "Expected trap", + "weighingAnalysis": "Weighing analysis", "noWeighingAnalysis": "No current weighing analysis.", - "missingComparisons": "Missing Comparisons", - "improvementSuggestions": "Improvement Suggestions", - "topIdeasCount": "{{count}} Top Ideas", - "topArgumentAngles": "Top 5 Argument Angles", + "missingComparisons": "Missing comparisons", + "improvementSuggestions": "Improvement suggestions", + "topIdeasCount": "{{count}} top ideas", + "topArgumentAngles": "Top 5 argument angles", "rank": "Rank", "title": "Title", - "argumentCategories": "Argument Categories", - "strategicBuckets": "Strategic Buckets", - "recommendedNextSteps": "Recommended Next Steps", - "warningsAndTradeoffs": "Warnings & Tradeoffs", - "factoidsSummary": "{{count}} Facts • {{verified}} Verified", - "keyFactsAndEvidence": "Key Facts & Evidence", + "argumentCategories": "Argument categories", + "strategicBuckets": "Strategic buckets", + "recommendedNextSteps": "Recommended next steps", + "warningsAndTradeoffs": "Warnings & tradeoffs", + "factoidsSummary": "{{count}} facts • {{verified}} verified", + "keyFactsAndEvidence": "Key facts & evidence", "claim": "Claim", "statusLabel": "Status", "relevance": "Relevance", - "detailedAnalysis": "Detailed Analysis", + "detailedAnalysis": "Detailed analysis", "limitations": "Limitations", - "furtherResearchSuggestions": "Further Research Suggestions", + "furtherResearchSuggestions": "Further research suggestions", "warnings": "Warnings", "status": { "verified": "Verified", @@ -2007,38 +2006,38 @@ "target": "Target", "counter": "Counter", "rebuttal": "Rebuttal", - "debateCase": "Debate Case", + "debateCase": "Debate case", "side": "Side", "proposition": "In favor", "opposition": "Against", - "caseAttack": "Case Attack Analysis", - "overallScore": "Overall Score", - "whyWeak": "Why Weak", - "howOpponentAttacks": "How Opponent Attacks", - "improvedVersion": "Improved Version", + "caseAttack": "Case attack analysis", + "overallScore": "Overall score", + "whyWeak": "Why weak", + "howOpponentAttacks": "How opponent attacks", + "improvedVersion": "Improved version", "noWeaknesses": "No weaknesses identified.", "noFallacies": "No fallacies detected.", "noCrossEx": "No cross-examination questions provided.", - "brainstormResults": "Brainstorm Results", - "whyTop": "Why Top", - "researchFactoids": "Research Factoids", - "keyFigure": "Key Figure", + "brainstormResults": "Brainstorm results", + "whyTop": "Why top", + "researchFactoids": "Research factoids", + "keyFigure": "Key figure", "context": "Context", - "howToUse": "How to Use", + "howToUse": "How to use", "source": "Source", "noFactoids": "No factoids found.", "exordio": "Exordio", - "caseStructure": "Case Structure", + "caseStructure": "Case structure", "thesis": "Thesis", "framework": "Framework", - "burdenOfProof": "Burden of Proof", - "keyDefinitions": "Key Definitions", + "burdenOfProof": "Burden of proof", + "keyDefinitions": "Key definitions", "arguments": "Arguments", - "predictedOpponent": "Predicted Opponent Case", - "counters": "Prepared Counters", - "weaknesses": "Weaknesses Identified", - "fallacies": "Fallacies Detected", - "crossExQuestions": "Cross-Examination Questions", + "predictedOpponent": "Predicted opponent case", + "counters": "Prepared counters", + "weaknesses": "Weaknesses identified", + "fallacies": "Fallacies detected", + "crossExQuestions": "Cross-examination questions", "motion": "Motion", "sparringTranscript": "Sparring transcript", "sparringConfiguration": "Round configuration", @@ -2061,10 +2060,10 @@ "sparringImprovementAreas": "Work on next", "sparringScorecardUnavailable": "A final scorecard is not available yet.", "sparringNoTranscript": "No speeches were submitted for this round.", - "factoidsSummary_one": "{{count}} Fact • {{verified}} Verified", - "factoidsSummary_other": "{{count}} Facts • {{verified}} Verified", - "topIdeasCount_one": "{{count}} Top Idea", - "topIdeasCount_other": "{{count}} Top Ideas" + "factoidsSummary_one": "{{count}} fact • {{verified}} verified", + "factoidsSummary_other": "{{count}} facts • {{verified}} verified", + "topIdeasCount_one": "{{count}} top idea", + "topIdeasCount_other": "{{count}} top ideas" }, "sparring": { "pro": "Pro", @@ -2299,12 +2298,12 @@ "duration3m": "3 min", "duration5m": "5 min", "duration8m": "8 min", - "sourceLibraryLabel": "Use Source Library grounding", + "sourceLibraryLabel": "Use source library grounding", "sourceLibraryDesc": "Ground the AI's questions and answers in your active library sources.", - "sourceLibraryEmpty": "Your Source Library has no active sources yet.", + "sourceLibraryEmpty": "Your source library has no active sources yet.", "seedQuestionsLabel": "Opening questions (optional)", "start": "Start crossfire", - "loading": "Loading crossfire...", + "loading": "Loading crossfire…", "thinking": "The coach is thinking...", "retryResponse": "Retry response", "timeout": "The request timed out", @@ -2539,8 +2538,8 @@ "instructionsNotUsed": "Your debate settings are included. Free-form instructions are not used here, so they cannot change how this exchange is run.", "sourcesHeading": "Library sources", "sparringNoSources": "Live sparring rounds use only the transcript, the motion, and your debate settings.", - "sourcesOff": "Source Library is off for this request.", - "noSources": "This debate has no Library sources yet.", + "sourcesOff": "Source library is off for this request.", + "noSources": "This debate has no library sources yet.", "resultsHeading": "Saved results", "sparringNoResults": "Saved results stay out of live rounds so the opponent and judge respond only to what is said.", "noResults": "Nothing saved in this debate yet.", @@ -2549,7 +2548,7 @@ "tooLarge": "Too large to fit with the rest, so it will be left out.", "empty": "Nothing usable yet.", "shortened": "Shortened to fit.", - "inactive": "Turned off in your Library.", + "inactive": "Turned off in your library.", "pinned": "pinned", "pinAria": "Pin {{title}} for every request in this debate", "unpinAria": "Unpin {{title}}", @@ -2627,10 +2626,10 @@ "genericProvider": "AI provider", "quota": "{{provider}} quota exceeded. Please inform the site admin.", "config": "{{provider}} is not configured correctly. Please inform the site admin.", - "rateLimit": "{{provider}} rate limit exceeded. Please try again later.", - "unavailable": "{{provider}} is temporarily unavailable. Please try again later.", + "rateLimit": "{{provider}} rate limit exceeded. Try again in a minute.", + "unavailable": "{{provider}} is temporarily unavailable. Try again in a minute.", "safety": "{{provider}} blocked this request for safety reasons. Try rephrasing your input.", - "unexpectedProvider": "The AI provider returned an unexpected error. Please try again later.", + "unexpectedProvider": "The AI provider returned an unexpected error. Try again in a minute.", "invalidOutput": "The AI's answer came back malformed. Please try again.", "unexpected": "An unexpected error occurred", "offline": "Check your connection and try again.", @@ -2652,7 +2651,7 @@ "remaining_other": "You have {{count}} left today." }, "resultActions": { - "savedToHistory": "Saved to History", + "savedToHistory": "Saved to history", "saveFailed": "Could not save. Try again." }, "billing": { @@ -2729,7 +2728,7 @@ "alreadySubscribed": "You already have a subscription. Manage it from Settings.", "promoExpired": "This promo code has expired", "promoExhausted": "This promo code has reached its usage limit", - "promoApplied": "Promo code applied!", + "promoApplied": "Promo code applied", "promoDiscountNoted": "Discount code saved! It will be applied when you upgrade.", "promoInvalid": "Invalid or expired promo code", "promoAlreadyRedeemed": "You've already redeemed this code", diff --git a/src/locales/es.json b/src/locales/es.json index a961cfac..f4f4dae5 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -336,8 +336,6 @@ } }, "home": { - "title": "DebateLab", - "subtitle": "Elige un modo para comenzar.", "prepare": "Preparar", "practice": "Practicar", "utilities": "Utilidades", @@ -366,15 +364,15 @@ "heading": "¿En qué estás trabajando?" }, "generate": { - "emptyTitle": "Tu caso te espera", - "emptyDesc": "Completa el formulario para generar un caso de debate estructurado con argumentos, evidencia y contra-estrategias.", + "emptyTitle": "Tu caso aparecerá aquí", + "emptyDesc": "Añade una moción, elige tu lado y genera.", "saved": "Caso guardado", "savedDesc": "Tu caso ha sido guardado en el historial.", "savePrompt": "Ingresa un título para este caso:", "failed": "Error al generar", "timeout": "Tiempo agotado", "timeoutDesc": "La IA tardó demasiado en responder. Inténtalo de nuevo.", - "authRequired": "Autenticación requerida", + "authRequired": "Inicia sesión para continuar", "flowchartImported": "Caso importado", "flowchartImportedDesc": "Tu caso se importó a Diagramas.", "flowchartImportFailed": "Error al importar", @@ -394,22 +392,22 @@ "flowchartOtherDebate": "El diagrama solo acepta un caso generado en el debate actual. Genera el caso aquí para enviarlo." }, "attack": { - "emptyTitle": "Modo sin piedad", - "emptyDesc": "Pega tu argumento para recibir críticas despiadadas, identificar falacias lógicas y obtener mejoras accionables.", + "emptyTitle": "Tu crítica aparecerá aquí", + "emptyDesc": "Pega un argumento para encontrar sus puntos débiles.", "failed": "Error en el análisis de ataque", "working": "Revisando tu caso…", "previousCritique": "Crítica de tu argumento anterior", "previousCritiqueDesc": "Tu última versión no se revisó, así que esta crítica puede no coincidir con el texto del formulario." }, "brainstorm": { - "emptyTitle": "Desbloquea ángulos creativos", - "emptyDesc": "Describe tu moción para explorar ángulos de argumentación no convencionales, identificar compromisos y encontrar tus posiciones más fuertes.", + "emptyTitle": "Tus ángulos aparecerán aquí", + "emptyDesc": "Describe una moción para explorar nuevos ángulos.", "failed": "Error en la lluvia de ideas", "working": "Buscando enfoques…" }, "factoids": { - "emptyTitle": "Evidencia que gana", - "emptyDesc": "Ingresa una afirmación para encontrar datos de apoyo con citas reales, contexto y guía sobre cómo usarlos efectivamente.", + "emptyTitle": "Tu evidencia aparecerá aquí", + "emptyDesc": "Escribe una afirmación para encontrar evidencia con citas.", "failed": "Error en la investigación", "savedToLibrary": "Guardado en biblioteca", "savedToLibraryDesc": "Esta evidencia ahora está disponible para fundamentación.", @@ -417,7 +415,7 @@ "working": "Buscando evidencia…" }, "ask": { - "failed": "Error", + "failed": "Algo salió mal", "timeout": "Tiempo agotado", "timeoutDesc": "La solicitud tardó demasiado. Inténtalo de nuevo.", "saved": "Guardado", @@ -466,7 +464,7 @@ "promoAccessUntil": "Tu acceso Pro viene de un código promocional y dura hasta el {{date}}. Hasta entonces la facturación es opcional; configúrala solo si quieres seguir en Pro después de esa fecha.", "promoAccess": "Tu acceso Pro viene de un código promocional, así que la facturación es opcional.", "accessExpired": "Tu acceso ha caducado. Mejora el plan para seguir usando las herramientas de IA.", - "globalSettings": "Ajustes globales", + "globalSettings": "Ajustes generales", "account": "Cuenta", "accountDesc": "Cómo inicias sesión y una copia de todo lo guardado en tu cuenta.", "signedInAs": "Sesión iniciada como", @@ -490,7 +488,7 @@ "envSwitchDesc": "Se cerrará tu sesión y la app se recargará apuntando a {{target}}. Tendrás que iniciar sesión de nuevo.", "envSwitchConfirm": "Cambiar y cerrar sesión", "stagingBanner": "Backend de staging", - "dangerZone": "Zona de peligro", + "dangerZone": "Eliminar datos", "dangerDesc": "Elimina permanentemente todos tus datos guardados. Solo afecta a tu cuenta ({{email}}).", "deleteAllData": "Eliminar todos mis datos", "deleteDialogTitle": "Eliminar todos los datos", @@ -875,8 +873,9 @@ }, "notFound": { "title": "404", - "message": "¡Ups! Página no encontrada", - "link": "Volver al inicio" + "message": "Página no encontrada", + "link": "Volver al inicio", + "description": "Ese enlace no lleva a ninguna parte. Puede que se haya movido." }, "footer": { "terms": "Términos", @@ -992,7 +991,7 @@ "judgeSubtitle": "Profundiza tu análisis" }, "pricing": { - "title": "Precios simples y transparentes", + "title": "Precios simples y claros", "subtitle": "Comienza gratis, mejora cuando estés listo.", "trial": "Prueba", "trialPrice": "$0", @@ -1074,7 +1073,7 @@ } }, "finalCta": { - "title": "¿Listo para construir tu próximo caso ganador?", + "title": "¿Listo para construir tu próximo caso?", "subtitle": "Únete a debatientes que se preparan mejor con IA.", "cta": "Lanzar DebateLab", "freeTrialAvailable": "Prueba gratuita disponible" @@ -1124,7 +1123,7 @@ } }, "rateLimit": { - "title": "Límite de solicitudes excedido" + "title": "Demasiadas solicitudes" }, "generateForm": { "motion": "Moción / tema", @@ -1151,9 +1150,9 @@ "toneFormal": "Formal", "toneConversational": "Conversacional", "toneAggressive": "Agresivo", - "generating": "Generando caso...", + "generating": "Generando caso…", "submit": "Generar caso de debate", - "listening": "Escuchando... Dicta tu moción claramente", + "listening": "Escuchando… Dicta tu moción claramente", "stopListening": "Dejar de escuchar", "startVoice": "Iniciar entrada de voz", "stopVoice": "Detener entrada de voz", @@ -1437,9 +1436,9 @@ "options": "Opciones" }, "limitModal": { - "trialEnded": "Prueba gratuita finalizada", + "trialEnded": "Tu prueba gratuita ha terminado", "trialEndedDesc": "Tu prueba gratuita de {{days}} días ha terminado. Mejora a Pro para seguir usando DebateLab.", - "limitReached": "Acceso diario pausado", + "limitReached": "Usos diarios de IA agotados", "limitReachedDesc": "Has usado {{current}} de {{limit}} usos de IA incluidos hoy. El acceso se renueva a medianoche UTC.", "weightedDesc": "Este modelo cuenta como {{units}} usos y te quedan {{remaining}} hoy. Elige un modelo más ligero o espera a la medianoche UTC.", "weightedTooLargeDesc": "Este modelo cuenta como {{units}} usos, más que tu límite diario de {{limit}}. Elige un modelo más ligero.", @@ -1451,7 +1450,7 @@ "gotIt": "Entendido", "paymentFailed": "Pago fallido", "paymentFailedDesc": "Tu último pago falló. Actualiza tu método de pago en Ajustes para seguir usando DebateLab.", - "subscriptionEnded": "Suscripción Pro finalizada", + "subscriptionEnded": "Tu suscripción Pro ha terminado", "subscriptionEndedDesc": "Tu suscripción Pro ha terminado. Vuelve a suscribirte para seguir usando DebateLab.", "subscriptionEndedOnDesc": "Tu suscripción Pro terminó el {{date}}. Vuelve a suscribirte para seguir usando DebateLab.", "updatePayment": "Actualizar método de pago", @@ -1512,12 +1511,12 @@ "freePeriod_other": "durante {{count}} días" }, "onboarding": { - "welcome": "¡Bienvenido a DebateLab! 🎉", - "ready": "¡Estás listo! 🚀", - "readyDesc": "Elige el modo que se ajuste a lo que necesitas para la próxima ronda.", + "welcome": "Bienvenido a DebateLab", + "ready": "Estás listo", + "readyDesc": "Elige una herramienta para tu próxima ronda.", "back": "Atrás", "close": "Cerrar", - "getStarted": "¡Comenzar!", + "getStarted": "Comenzar", "next": "Siguiente", "nextWithProgress": "Siguiente (paso {step} de {steps})", "skip": "Saltar tour", @@ -1530,12 +1529,12 @@ }, "debateOwlIntro": { "greeting": "¡Hola, soy DebateOwl!", - "greetingNamed": "¡Hola, soy {{name}}, el búho de DebateOwl!", + "greetingNamed": "¡Hola, soy {{name}}!", "newLook": "DebateLab tiene un aspecto nuevo.", "company": "Te haré compañía mientras algo carga.", "sameTools": "Las mismas herramientas de siempre, en su sitio.", - "noticeTitle": "DebateLab se está convirtiendo poco a poco en DebateOwl", - "noticeBody": "La misma app, la misma cuenta y el mismo trabajo guardado, con un aspecto y un nombre nuevos. Cambiamos para que más gente nos encuentre en Google.", + "noticeTitle": "DebateLab se está convirtiendo en DebateOwl", + "noticeBody": "Tu cuenta y tu trabajo guardado siguen igual.", "skipAnimation": "Saltar animación", "done": "Entendido" }, @@ -1626,8 +1625,8 @@ "typeField": "Tipo", "strengthField": "Fuerza", "labelPlaceholder": "Etiqueta corta...", - "titlePlaceholder": "Título del diagrama...", - "descPlaceholder": "Descripción...", + "titlePlaceholder": "Título del diagrama…", + "descPlaceholder": "Descripción…", "typeArgument": "Argumento", "typeAttack": "Ataque", "typeRebuttal": "Refutación", @@ -1641,10 +1640,10 @@ "legendAttack": "Ataque", "legendRebuttal": "Refutación", "legendDropped": "Abandonado", - "savedToast": "Diagrama guardado exitosamente", - "saveFailedToast": "Error al guardar diagrama", + "savedToast": "Diagrama guardado", + "saveFailedToast": "No se pudo guardar el diagrama", "loadedToast": "Diagrama cargado", - "deleteFailedToast": "Error al eliminar diagrama", + "deleteFailedToast": "No se pudo eliminar el diagrama", "starter": { "thesisLabel": "Tu tesis", "thesisDesc": "Haz clic para editar o añadir nuevos nodos", @@ -1797,8 +1796,8 @@ "duplicateSourceDesc": "Esta URL o archivo ya está guardado.", "fileReadFailed": "No se pudo leer este archivo.", "saveToLibrary": "Guardar en biblioteca", - "addNewTitle": "Añadir nueva fuente", - "yourKBTitle": "Tu base de conocimiento", + "addNewTitle": "Añadir una fuente", + "yourKBTitle": "Tus fuentes", "titleLabel": "Título", "originLabel": "Origen", "contentLabel": "Contenido", @@ -1858,7 +1857,7 @@ "offlineNotSaved": "Estás sin conexión. Vuelve a conectarte e inténtalo de nuevo.", "toggleFailed": "No se pudo cambiar esta fuente", "toggleFailedDesc": "Volvió a como estaba. Inténtalo de nuevo.", - "sourceSaved": "{{title}} se guardó en la biblioteca", + "sourceSaved": "{{title}} se guardó en tu biblioteca", "duplicateUrlDesc": "Esta página ya está en la biblioteca de este debate.", "loadingSources": "Cargando fuentes…", "folderUpdateFailed": "No se pudo cambiar esta carpeta", @@ -2304,7 +2303,7 @@ "sourceLibraryEmpty": "Aún no tienes fuentes activas en tu Biblioteca.", "seedQuestionsLabel": "Preguntas iniciales (opcional)", "start": "Iniciar interrogatorio", - "loading": "Cargando el interrogatorio...", + "loading": "Cargando el interrogatorio…", "thinking": "El coach está pensando...", "retryResponse": "Reintentar respuesta", "timeout": "La solicitud agotó el tiempo", @@ -2627,10 +2626,10 @@ "genericProvider": "El proveedor de IA", "quota": "{{provider}} superó su cuota. Avisa al administrador del sitio.", "config": "{{provider}} no está bien configurado. Avisa al administrador del sitio.", - "rateLimit": "{{provider}} superó el límite de solicitudes. Inténtalo más tarde.", - "unavailable": "{{provider}} no está disponible por ahora. Inténtalo más tarde.", + "rateLimit": "{{provider}} superó el límite de solicitudes. Inténtalo de nuevo en un minuto.", + "unavailable": "{{provider}} no está disponible por ahora. Inténtalo de nuevo en un minuto.", "safety": "{{provider}} bloqueó esta solicitud por seguridad. Prueba a reformularla.", - "unexpectedProvider": "El proveedor de IA devolvió un error inesperado. Inténtalo más tarde.", + "unexpectedProvider": "El proveedor de IA devolvió un error inesperado. Inténtalo de nuevo en un minuto.", "invalidOutput": "La respuesta de la IA llegó con un formato incorrecto. Inténtalo de nuevo.", "unexpected": "Se produjo un error inesperado", "offline": "Comprueba tu conexión e inténtalo de nuevo.", @@ -2729,7 +2728,7 @@ "alreadySubscribed": "Ya tienes una suscripción. Gestiónala desde Ajustes.", "promoExpired": "Este código promocional ha caducado", "promoExhausted": "Este código promocional ha alcanzado su límite de usos", - "promoApplied": "¡Código promocional aplicado!", + "promoApplied": "Código promocional aplicado", "promoDiscountNoted": "¡Código de descuento guardado! Se aplicará cuando mejores tu plan.", "promoInvalid": "Código promocional no válido o caducado", "promoAlreadyRedeemed": "Ya has canjeado este código", diff --git a/src/locales/hu.json b/src/locales/hu.json index fb6c31df..40613971 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -336,8 +336,6 @@ } }, "home": { - "title": "DebateLab", - "subtitle": "Válassz egy módot a kezdéshez.", "prepare": "Felkészülés", "practice": "Gyakorlás", "utilities": "Eszközök", @@ -366,15 +364,15 @@ "heading": "Min dolgozol most?" }, "generate": { - "emptyTitle": "Az eseted vár", - "emptyDesc": "Töltsd ki az űrlapot egy strukturált vitaeset generálásához érvekkel, bizonyítékokkal és ellen-stratégiákkal.", + "emptyTitle": "Az eseted itt jelenik meg", + "emptyDesc": "Add meg az indítványt, válassz oldalt, és generálj.", "saved": "Eset mentve", "savedDesc": "Az eseted mentésre került az előzményekbe.", "savePrompt": "Adj meg egy címet ehhez az esethez:", "failed": "Generálás sikertelen", "timeout": "Időtúllépés", "timeoutDesc": "Az MI túl sokáig válaszolt. Próbáld újra.", - "authRequired": "Hitelesítés szükséges", + "authRequired": "Bejelentkezés szükséges", "flowchartImported": "Eset importálva", "flowchartImportedDesc": "Az eseted átkerült a folyamatábrákhoz.", "flowchartImportFailed": "Import sikertelen", @@ -394,22 +392,22 @@ "flowchartOtherDebate": "A folyamatábra csak a jelenlegi vitában létrehozott esetet fogadja. Hozd létre itt az esetet, hogy elküldhesd." }, "attack": { - "emptyTitle": "Kíméletlen mód", - "emptyDesc": "Illeszd be az érvedet kíméletlen kritikáért, logikai hibák azonosításáért és megvalósítható javításokért.", + "emptyTitle": "A kritika itt jelenik meg", + "emptyDesc": "Illessz be egy érvet, és megkeressük a gyenge pontjait.", "failed": "Támadás elemzés sikertelen", "working": "Az eseted átnézése…", "previousCritique": "Az előző érvelésed kritikája", "previousCritiqueDesc": "A legutóbbi változatodat nem értékeltük, ezért ez a kritika eltérhet az űrlapban lévő szövegtől." }, "brainstorm": { - "emptyTitle": "Kreatív szögek feloldása", - "emptyDesc": "Írd le az indítványodat szokatlan érvelési szögek felfedezéséhez, kompromisszumok azonosításához és legerősebb pozícióid megtalálásához.", + "emptyTitle": "Az érvelési szögek itt jelennek meg", + "emptyDesc": "Írj le egy indítványt, és új szögeket keresünk.", "failed": "Ötletelés sikertelen", "working": "Szempontok keresése…" }, "factoids": { - "emptyTitle": "Győztes bizonyítékok", - "emptyDesc": "Adj meg egy állítást támogató tények kereséséhez valós hivatkozásokkal, kontextussal és útmutatóval a hatékony felhasználáshoz.", + "emptyTitle": "A bizonyítékok itt jelennek meg", + "emptyDesc": "Adj meg egy állítást, és hivatkozott bizonyítékot keresünk.", "failed": "Kutatás sikertelen", "savedToLibrary": "Mentve a könyvtárba", "savedToLibraryDesc": "Ez a bizonyíték mostantól elérhető megalapozáshoz.", @@ -417,7 +415,7 @@ "working": "Bizonyítékok keresése…" }, "ask": { - "failed": "Hiba", + "failed": "Valami nem sikerült", "timeout": "Időtúllépés", "timeoutDesc": "A kérés túl sokáig tartott. Próbáld újra.", "saved": "Mentve", @@ -466,7 +464,7 @@ "promoAccessUntil": "A Pro hozzáférésed promóciós kódból származik (lejárat: {{date}}). Addig a számlázás beállítása nem kötelező; csak akkor állítsd be, ha ezután is Pro szeretnél maradni.", "promoAccess": "A Pro hozzáférésed promóciós kódból származik, így a számlázás beállítása nem kötelező.", "accessExpired": "A hozzáférésed lejárt. Válts magasabb csomagra az MI-eszközök további használatához.", - "globalSettings": "Globális beállítások", + "globalSettings": "Általános", "account": "Fiók", "accountDesc": "Hogyan jelentkezel be, és másolat minden mentett adatodról.", "signedInAs": "Bejelentkezve mint", @@ -490,7 +488,7 @@ "envSwitchDesc": "Kijelentkeztetünk és az app újratöltődik {{target}} felé mutatva. Újra be kell jelentkezned.", "envSwitchConfirm": "Váltás és kijelentkezés", "stagingBanner": "Staging háttérrendszer", - "dangerZone": "Veszélyzóna", + "dangerZone": "Adatok törlése", "dangerDesc": "Véglegesen törli az összes mentett adatodat. Ez csak a fiókodat érinti ({{email}}).", "deleteAllData": "Összes adatom törlése", "deleteDialogTitle": "Összes adat törlése", @@ -875,8 +873,9 @@ }, "notFound": { "title": "404", - "message": "Hoppá! Az oldal nem található", - "link": "Vissza a kezdőlapra" + "message": "Az oldal nem található", + "link": "Vissza a kezdőlapra", + "description": "Ez a hivatkozás nem vezet sehova. Lehet, hogy elköltözött." }, "footer": { "terms": "Feltételek", @@ -992,7 +991,7 @@ "judgeSubtitle": "Mélyítsd el az elemzésedet" }, "pricing": { - "title": "Egyszerű, átlátható árazás", + "title": "Egyszerű, világos árazás", "subtitle": "Kezdj ingyen, frissíts amikor készen állsz.", "trial": "Próba", "trialPrice": "$0", @@ -1074,7 +1073,7 @@ } }, "finalCta": { - "title": "Készen állsz a következő győztes eseted felépítésére?", + "title": "Készen állsz a következő eseted felépítésére?", "subtitle": "Csatlakozz a vitázókhoz, akik MI-vel készülnek okosabban.", "cta": "DebateLab indítása", "freeTrialAvailable": "Ingyenes próba elérhető" @@ -1124,7 +1123,7 @@ } }, "rateLimit": { - "title": "Kéréslimit túllépve" + "title": "Túl sok kérés" }, "generateForm": { "motion": "Indítvány / téma", @@ -1151,9 +1150,9 @@ "toneFormal": "Formális", "toneConversational": "Társalgási", "toneAggressive": "Agresszív", - "generating": "Eset generálása...", + "generating": "Eset generálása…", "submit": "Vitaeset generálása", - "listening": "Hallgatás... Mondd el az indítványt tisztán", + "listening": "Hallgatás… Mondd el az indítványt tisztán", "stopListening": "Hallgatás leállítása", "startVoice": "Hangbevitel indítása", "stopVoice": "Hangbevitel leállítása", @@ -1437,9 +1436,9 @@ "options": "Beállítások" }, "limitModal": { - "trialEnded": "Ingyenes próba lejárt", + "trialEnded": "Lejárt az ingyenes próbaidőszakod", "trialEndedDesc": "A {{days}} napos ingyenes próbaidőszakod lejárt. Válts Pro-ra a DebateLab további használatához.", - "limitReached": "Napi hozzáférés szünetel", + "limitReached": "Elfogyott a napi MI-keret", "limitReachedDesc": "Elérted a mai keretet: {{current}} / {{limit}} MI-használatot vettél igénybe. A hozzáférés éjfélkor (UTC) frissül.", "weightedDesc": "Ez a modell {{units}} használatnak számít, és ma még {{remaining}} maradt. Válassz könnyebb modellt, vagy várj éjfélig (UTC).", "weightedTooLargeDesc": "Ez a modell {{units}} használatnak számít, ami több, mint a napi {{limit}} kereted. Válassz könnyebb modellt.", @@ -1451,7 +1450,7 @@ "gotIt": "Rendben", "paymentFailed": "Sikertelen fizetés", "paymentFailedDesc": "Az utolsó fizetésed sikertelen volt. Frissítsd a fizetési módodat a Beállításokban a DebateLab további használatához.", - "subscriptionEnded": "A Pro-előfizetés véget ért", + "subscriptionEnded": "Lejárt a Pro-előfizetésed", "subscriptionEndedDesc": "A Pro-előfizetésed véget ért. Fizess elő újra a DebateLab további használatához.", "subscriptionEndedOnDesc": "A Pro-előfizetésed véget ért ({{date}}). Fizess elő újra a DebateLab további használatához.", "updatePayment": "Fizetési mód frissítése", @@ -1512,12 +1511,12 @@ "freePeriod_other": "{{count}} napig" }, "onboarding": { - "welcome": "Üdvözlünk a DebateLabban! 🎉", - "ready": "Készen állsz! 🚀", - "readyDesc": "Válaszd azt a módot, amelyre a következő fordulóhoz szükséged van.", + "welcome": "Üdvözlünk a DebateLabban", + "ready": "Készen állsz", + "readyDesc": "Válassz eszközt a következő fordulódhoz.", "back": "Vissza", "close": "Bezárás", - "getStarted": "Kezdjük!", + "getStarted": "Kezdjük", "next": "Következő", "nextWithProgress": "Következő ({step}/{steps})", "skip": "Túra kihagyása", @@ -1530,12 +1529,12 @@ }, "debateOwlIntro": { "greeting": "Szia, én vagyok a DebateOwl!", - "greetingNamed": "Szia, {{name}} vagyok, a DebateOwl baglya!", + "greetingNamed": "Szia, {{name}} vagyok!", "newLook": "A DebateLab új külsőt kapott.", "company": "Veled leszek, amíg valami betöltődik.", "sameTools": "Ugyanazok az eszközök, ugyanott, ahol eddig.", - "noticeTitle": "A DebateLabből lassan DebateOwl lesz", - "noticeBody": "Ugyanaz az alkalmazás, ugyanaz a fiók, ugyanazok a mentett munkák, csak új külsővel és új névvel. Azért váltunk, hogy többen megtaláljanak minket a Google-ben.", + "noticeTitle": "A DebateLabból DebateOwl lesz", + "noticeBody": "A fiókod és a mentett munkáid ugyanazok maradnak.", "skipAnimation": "Animáció kihagyása", "done": "Értem" }, @@ -1626,8 +1625,8 @@ "typeField": "Típus", "strengthField": "Erősség", "labelPlaceholder": "Rövid címke...", - "titlePlaceholder": "A folyamatábra címe...", - "descPlaceholder": "Leírás...", + "titlePlaceholder": "A folyamatábra címe…", + "descPlaceholder": "Leírás…", "typeArgument": "Érv", "typeAttack": "Támadás", "typeRebuttal": "Cáfolat", @@ -1641,7 +1640,7 @@ "legendAttack": "Támadás", "legendRebuttal": "Cáfolat", "legendDropped": "Elejtett", - "savedToast": "Folyamatábra sikeresen mentve", + "savedToast": "Folyamatábra mentve", "saveFailedToast": "Nem sikerült menteni a folyamatábrát", "loadedToast": "Folyamatábra betöltve", "deleteFailedToast": "Nem sikerült törölni a folyamatábrát", @@ -1797,8 +1796,8 @@ "duplicateSourceDesc": "Ez az URL vagy fájl már mentve van.", "fileReadFailed": "Nem sikerült beolvasni ezt a fájlt.", "saveToLibrary": "Mentés a könyvtárba", - "addNewTitle": "Új forrás hozzáadása", - "yourKBTitle": "A tudásbázisod", + "addNewTitle": "Forrás hozzáadása", + "yourKBTitle": "A forrásaid", "titleLabel": "Cím", "originLabel": "Eredet", "contentLabel": "Tartalom", @@ -1858,7 +1857,7 @@ "offlineNotSaved": "Nincs internetkapcsolat. Csatlakozz újra, és próbáld meg ismét.", "toggleFailed": "Nem sikerült módosítani ezt a forrást", "toggleFailedDesc": "Visszaállt a korábbi állapotra. Próbáld újra.", - "sourceSaved": "A(z) {{title}} bekerült a könyvtárba", + "sourceSaved": "A(z) {{title}} bekerült a könyvtáradba", "duplicateUrlDesc": "Ez az oldal már szerepel ennek a vitának a könyvtárában.", "loadingSources": "Források betöltése…", "folderUpdateFailed": "Nem sikerült módosítani ezt a mappát", @@ -2304,7 +2303,7 @@ "sourceLibraryEmpty": "Még nincs aktív forrásod a Forráskönyvtárban.", "seedQuestionsLabel": "Nyitó kérdések (opcionális)", "start": "Keresztkérdezés indítása", - "loading": "Keresztkérdezés betöltése...", + "loading": "Keresztkérdezés betöltése…", "thinking": "Az edző gondolkodik...", "retryResponse": "Válasz újrapróbálása", "timeout": "A kérés időtúllépés miatt megszakadt", @@ -2627,10 +2626,10 @@ "genericProvider": "Az MI-szolgáltató", "quota": "{{provider}}: elfogyott a keret. Szólj az oldal adminisztrátorának.", "config": "{{provider}}: hibás beállítás. Szólj az oldal adminisztrátorának.", - "rateLimit": "{{provider}}: túl sok kérés. Próbáld újra később.", - "unavailable": "{{provider}}: átmenetileg nem érhető el. Próbáld újra később.", + "rateLimit": "{{provider}}: túl sok kérés. Próbáld újra egy perc múlva.", + "unavailable": "{{provider}}: átmenetileg nem érhető el. Próbáld újra egy perc múlva.", "safety": "{{provider}}: biztonsági okokból elutasította a kérést. Próbáld másképp megfogalmazni.", - "unexpectedProvider": "Az MI-szolgáltató váratlan hibát adott. Próbáld újra később.", + "unexpectedProvider": "Az MI-szolgáltató váratlan hibát adott. Próbáld újra egy perc múlva.", "invalidOutput": "Az MI válasza hibás formátumban érkezett. Próbáld újra.", "unexpected": "Váratlan hiba történt", "offline": "Ellenőrizd a kapcsolatot, és próbáld újra.", @@ -2729,7 +2728,7 @@ "alreadySubscribed": "Már van előfizetésed. A Beállításokban kezelheted.", "promoExpired": "Ez a promóciós kód lejárt", "promoExhausted": "Ez a promóciós kód elérte a felhasználási korlátját", - "promoApplied": "Promóciós kód beváltva!", + "promoApplied": "Promóciós kód beváltva", "promoDiscountNoted": "Kedvezménykód rögzítve! Előfizetéskor automatikusan érvényesül.", "promoInvalid": "Érvénytelen vagy lejárt promóciós kód", "promoAlreadyRedeemed": "Ezt a kódot már beváltottad", diff --git a/src/pages/BrainstormPage.tsx b/src/pages/BrainstormPage.tsx index 01086750..42d0dc7f 100644 --- a/src/pages/BrainstormPage.tsx +++ b/src/pages/BrainstormPage.tsx @@ -94,7 +94,7 @@ export default function BrainstormPage() { try { await saveCurrentBrainstorm((lastRun.current?.data.motion || brainstormForm?.motion || t('generatedArtifacts.brainstormTitle')).slice(0, 300)); markResultSaved(result); - toast({ title: t('resultActions.savedToHistory', 'Saved to History') }); + toast({ title: t('resultActions.savedToHistory', 'Saved to history') }); } catch (error) { console.error('Failed to save brainstorm:', error); toast({ title: t('resultActions.saveFailed', 'Could not save. Try again.'), variant: 'destructive' }); diff --git a/src/pages/FactoidsPage.tsx b/src/pages/FactoidsPage.tsx index f30c17f9..42f26ecc 100644 --- a/src/pages/FactoidsPage.tsx +++ b/src/pages/FactoidsPage.tsx @@ -121,7 +121,7 @@ export default function FactoidsPage() { try { await saveCurrentFactoids((lastInput.current?.claim || factoidsForm?.claim || t('generatedArtifacts.factoidsTitle')).slice(0, 300)); markResultSaved(result); - toast({ title: t('resultActions.savedToHistory', 'Saved to History') }); + toast({ title: t('resultActions.savedToHistory', 'Saved to history') }); } catch (error) { console.error('Failed to save factoids:', error); toast({ title: t('resultActions.saveFailed', 'Could not save. Try again.'), variant: 'destructive' }); diff --git a/src/pages/HistoryPage.tsx b/src/pages/HistoryPage.tsx index b72f3e11..224035ce 100644 --- a/src/pages/HistoryPage.tsx +++ b/src/pages/HistoryPage.tsx @@ -406,7 +406,7 @@ export default function HistoryPage() { icon={Flame} title={t('historyPage.emptyCrossfire', 'No Crossfire rounds yet')} description={t('historyPage.emptyCrossfireDesc', 'Finish a Crossfire round to review its transcript and debrief here.')} - actionLabel={t('historyPage.startCrossfire', 'Start Crossfire')} + actionLabel={t('historyPage.startCrossfire', 'Start crossfire')} onAction={() => navigate('/app/crossfire')} /> ) : ( diff --git a/src/pages/LibraryPage.tsx b/src/pages/LibraryPage.tsx index c4e246ff..00764e1c 100644 --- a/src/pages/LibraryPage.tsx +++ b/src/pages/LibraryPage.tsx @@ -291,7 +291,7 @@ export default function LibraryPage() { }); }; const showSaved = (title: string) => { - toast({ title: t('libraryPage.sourceSaved', { title, defaultValue: 'Saved {{title}} to the Library' }) }); + toast({ title: t('libraryPage.sourceSaved', { title, defaultValue: 'Saved {{title}} to your library' }) }); }; const handleAdd = async () => { diff --git a/src/test/a11y/names-and-headings.test.tsx b/src/test/a11y/names-and-headings.test.tsx index b2cc0a8b..12b6ee96 100644 --- a/src/test/a11y/names-and-headings.test.tsx +++ b/src/test/a11y/names-and-headings.test.tsx @@ -84,21 +84,21 @@ describe('accessible names and headings', () => { it('names Generate selects from their visible labels', () => { render(<GenerateForm onSubmit={() => {}} isLoading={false} debatePrefill={false} />); expect(screen.getByRole('combobox', { name: 'Side' })).toBeInTheDocument(); - expect(screen.getByRole('combobox', { name: 'Speaker Role' })).toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: 'Speaker role' })).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'More options' })); - expect(screen.getByRole('combobox', { name: 'Case Length' })).toBeInTheDocument(); - expect(screen.getByRole('combobox', { name: 'Audience Level' })).toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: 'Case length' })).toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: 'Audience level' })).toBeInTheDocument(); expect(screen.getByRole('combobox', { name: 'Tone' })).toBeInTheDocument(); }); it('names Attack switches from their labels and selects from theirs', () => { render(<AttackForm onSubmit={() => {}} isLoading={false} debateContext={false} />); fireEvent.click(screen.getByRole('button', { name: /More options/ })); - expect(screen.getByRole('switch', { name: 'Detect Fallacies' })).toHaveAccessibleDescription('Identify logical fallacies in the argument'); - expect(screen.getByRole('switch', { name: 'Analyze Weighing' })).toHaveAccessibleDescription('Evaluate impact comparison and prioritization'); - expect(screen.getByRole('combobox', { name: 'Attack Intensity' })).toBeInTheDocument(); - expect(screen.getByRole('combobox', { name: 'Judge Paradigm' })).toBeInTheDocument(); + expect(screen.getByRole('switch', { name: 'Detect fallacies' })).toHaveAccessibleDescription('Identify logical fallacies in the argument'); + expect(screen.getByRole('switch', { name: 'Analyze weighing' })).toHaveAccessibleDescription('Evaluate impact comparison and prioritization'); + expect(screen.getByRole('combobox', { name: 'Attack intensity' })).toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: 'Judge paradigm' })).toBeInTheDocument(); }); it('names the timer progress bar with the current speech and a numeric value', async () => { diff --git a/src/test/components/EnvironmentSwitcher.test.tsx b/src/test/components/EnvironmentSwitcher.test.tsx index aca60e64..170a8c23 100644 --- a/src/test/components/EnvironmentSwitcher.test.tsx +++ b/src/test/components/EnvironmentSwitcher.test.tsx @@ -36,7 +36,7 @@ describe('EnvironmentSwitcher', () => { render(<EnvironmentSwitcher reload={reload} />); expect(screen.getByText('production')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Switch to Staging' })); + fireEvent.click(screen.getByRole('button', { name: 'Switch to staging' })); expect(mocks.signOut).not.toHaveBeenCalled(); expect(screen.getByRole('dialog')).toHaveTextContent('pointing at staging'); @@ -47,7 +47,7 @@ describe('EnvironmentSwitcher', () => { it('leaves the environment alone when the dialog is cancelled', () => { const reload = vi.fn(); render(<EnvironmentSwitcher reload={reload} />); - fireEvent.click(screen.getByRole('button', { name: 'Switch to Staging' })); + fireEvent.click(screen.getByRole('button', { name: 'Switch to staging' })); fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(mocks.signOut).not.toHaveBeenCalled(); @@ -61,7 +61,7 @@ describe('EnvironmentSwitcher', () => { vi.spyOn(console, 'warn').mockImplementation(() => {}); const reload = vi.fn(); render(<EnvironmentSwitcher reload={reload} />); - fireEvent.click(screen.getByRole('button', { name: 'Switch to Production' })); + fireEvent.click(screen.getByRole('button', { name: 'Switch to production' })); await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Switch & sign out' })); }); expect(mocks.setActiveEnv).toHaveBeenCalledWith('production'); expect(reload).toHaveBeenCalledOnce(); @@ -95,7 +95,7 @@ describe('StagingBanner', () => { const reload = vi.fn(); render(<StagingBanner reload={reload} />); expect(screen.getByRole('status')).toHaveTextContent('Staging backend'); - await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Switch to Production' })); }); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Switch to production' })); }); expect(mocks.signOut).toHaveBeenCalledOnce(); expect(mocks.setActiveEnv).toHaveBeenCalledWith('production'); expect(reload).toHaveBeenCalledOnce(); diff --git a/src/test/components/LimitReachedModal.test.tsx b/src/test/components/LimitReachedModal.test.tsx index 30fae3b4..17d33b79 100644 --- a/src/test/components/LimitReachedModal.test.tsx +++ b/src/test/components/LimitReachedModal.test.tsx @@ -77,7 +77,7 @@ describe('LimitReachedModal', () => { it.each(['trial_expired', 'trial_ended'] as const)('still offers Pro when the trial has ended (%s)', (variant) => { renderModal(variant); - expect(screen.getByRole('heading', { name: 'Free Trial Ended' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Your free trial has ended' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Upgrade to Pro' })).toBeInTheDocument(); }); @@ -86,7 +86,7 @@ describe('LimitReachedModal', () => { subscription.isPro = false; subscription.billingState = 'payment_failed'; const { onUpgrade, onOpenChange } = renderModal('payment_failed'); - expect(screen.getByRole('heading', { name: 'Payment Failed' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Payment failed' })).toBeInTheDocument(); expect(screen.queryByText(/free trial/i)).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Upgrade to Pro/ })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Update payment method' })); @@ -100,7 +100,7 @@ describe('LimitReachedModal', () => { subscription.billingState = 'subscription_ended'; subscription.billingDate = '2026-10-01T12:00:00.000Z'; const { onUpgrade } = renderModal('subscription_ended'); - expect(screen.getByRole('heading', { name: 'Pro Subscription Ended' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Your Pro subscription has ended' })).toBeInTheDocument(); expect(screen.getByText(/Your Pro subscription ended on 1 Oct 2026/)).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Resubscribe' })); expect(onUpgrade).toHaveBeenCalled(); diff --git a/src/test/components/SubscriptionStatus.test.tsx b/src/test/components/SubscriptionStatus.test.tsx index 2b10c4f5..e58bca00 100644 --- a/src/test/components/SubscriptionStatus.test.tsx +++ b/src/test/components/SubscriptionStatus.test.tsx @@ -30,7 +30,7 @@ const CASES: Array<{ text: RegExp; button: string; }> = [ - { name: 'trial', props: { billingState: 'trial', billingDate: DATE, billingDateKind: 'ends' }, badge: 'Free Trial', date: 'Ends on 22 Nov 2026', text: /AI access is active/, button: 'Upgrade to Pro' }, + { name: 'trial', props: { billingState: 'trial', billingDate: DATE, billingDateKind: 'ends' }, badge: 'Free trial', date: 'Ends on 22 Nov 2026', text: /AI access is active/, button: 'Upgrade to Pro' }, { // QB-02: an override on an expired trial works, so it is not "Expired". name: 'extended access', @@ -87,7 +87,7 @@ const CASES: Array<{ { name: 'legacy free', props: { billingState: 'legacy_free', billingDate: DATE, billingDateKind: 'ends', dailyLimit: 50 }, - badge: 'Legacy Free', + badge: 'Legacy free', date: 'Ends on 22 Nov 2026', text: /AI access is active/, button: 'Upgrade to Pro', diff --git a/src/test/generate/generate-form-role.test.tsx b/src/test/generate/generate-form-role.test.tsx index c042c030..3b7ce42f 100644 --- a/src/test/generate/generate-form-role.test.tsx +++ b/src/test/generate/generate-form-role.test.tsx @@ -21,7 +21,7 @@ describe('GenerateForm role from the Current Debate', () => { const onSubmit = vi.fn(); render(<GenerateForm onSubmit={onSubmit} isLoading={false} />); await waitFor(() => expect(screen.getByRole('textbox', { name: /Motion/ })).toHaveValue('This House would make public transport free')); - expect(screen.getByRole('combobox', { name: 'Speaker Role' })).toHaveTextContent('Any role'); + expect(screen.getByRole('combobox', { name: 'Speaker role' })).toHaveTextContent('Any role'); fireEvent.click(screen.getByRole('button', { name: 'Generate debate case' })); await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); expect(onSubmit.mock.calls[0][0]).toMatchObject({ motion: 'This House would make public transport free', role: 'general' }); diff --git a/src/test/i18n-untranslated.test.ts b/src/test/i18n-untranslated.test.ts index a38c7d28..4d11a051 100644 --- a/src/test/i18n-untranslated.test.ts +++ b/src/test/i18n-untranslated.test.ts @@ -13,7 +13,6 @@ import toolsHu from '../locales/tools/hu.json'; */ const SAME_AS_ENGLISH: Record<string, string[]> = { 'Product, plan and file-format names': [ - 'es translation:home.title', 'hu translation:home.title', 'es translation:settings.tierPro', 'hu translation:settings.tierPro', 'es translation:pricing.pro', 'hu translation:pricing.pro', 'es translation:pricingModal.pro', 'hu translation:pricingModal.pro', @@ -58,7 +57,7 @@ const SAME_AS_ENGLISH: Record<string, string[]> = { 'es translation:features.sparring', 'es translation:historyPage.tabSparring', 'es translation:debate.kinds.sparring', ], 'Spanish words spelled the same as the English ones': [ - 'es translation:common.error', 'es translation:ask.failed', 'es translation:settings.deleteFailed', + 'es translation:common.error', 'es translation:settings.deleteFailed', 'es translation:settings.portalError', 'es translation:admin.error', 'es translation:pricingModal.redeemError', 'es translation:workspaces.original', 'es translation:login.emailTab', 'es translation:generateForm.toneFormal', 'es translation:generateForm.roleIntro', 'es translation:generateForm.roleConc', diff --git a/src/test/onboarding/debateowl-intro.test.tsx b/src/test/onboarding/debateowl-intro.test.tsx index 29b92b20..1345ed3d 100644 --- a/src/test/onboarding/debateowl-intro.test.tsx +++ b/src/test/onboarding/debateowl-intro.test.tsx @@ -51,7 +51,7 @@ describe('DebateOwlIntro', () => { it('Got it marks it seen; Skip animation only skips ahead', () => { const storage = renderIntro(); - expect(screen.getByRole('dialog', { name: 'DebateLab is slowly becoming DebateOwl' })).toBeTruthy(); + expect(screen.getByRole('dialog', { name: 'DebateLab is becoming DebateOwl' })).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: 'Skip animation' })); expect(screen.queryByRole('button', { name: 'Skip animation' })).toBeNull(); expect(shouldShowIntro({ search: '', storage, env: appEnv })).toBe(true); @@ -72,6 +72,6 @@ describe('DebateOwlIntro', () => { it('Ollie introduces itself by name', () => { renderIntro(); - expect(screen.getByText("Hi, I'm Ollie, the DebateOwl owl!")).toBeTruthy(); + expect(screen.getByText("Hi, I'm Ollie!")).toBeTruthy(); }); }); diff --git a/src/test/pages/SharedPage.test.tsx b/src/test/pages/SharedPage.test.tsx index 386e68be..4da479ab 100644 --- a/src/test/pages/SharedPage.test.tsx +++ b/src/test/pages/SharedPage.test.tsx @@ -58,7 +58,7 @@ describe('a missing share link', () => { renderShare(); expect(await screen.findByRole('heading', { level: 1, name: 'Shared link not found' })).toBeInTheDocument(); - expect(screen.queryByRole('heading', { name: 'Shared Content' })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Shared content' })).not.toBeInTheDocument(); expect(screen.getByText('This shared content is no longer available')).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Back to home' })).toHaveAttribute('href', '/'); // The heading commits before the passive effect that sets the title and robots meta runs. @@ -85,7 +85,7 @@ describe('a missing share link', () => { expect(await screen.findByRole('heading', { level: 1, name: 'Practice case' })).toBeInTheDocument(); expect(screen.getByText('A short note')).toBeInTheDocument(); - expect(document.title).toBe('Shared Content · DebateLab'); + expect(document.title).toBe('Shared content · DebateLab'); }); it('keeps naming a missing link after a language switch', async () => { From daa3d0d6240f18f1602be5d733d0cf8d0ac79e6f Mon Sep 17 00:00:00 2001 From: Adam <adam.gaborjani.szabo@gmail.com> Date: Tue, 27 Oct 2026 08:53:42 +0000 Subject: [PATCH 03/12] Tool pages' empty panel shrinks under the form on phones and iPads EmptyState gains a compact size (56 px owl, no badge, tighter padding) and compact-below-lg, which Generate, Attack, Brainstorm and Factoids use: below lg the panel stacks under a form that is already the next step, so it no longer sits there as a tall block. Titles balance across lines. It also takes an optional icon, an h1 level, an eyebrow line and an action element, for the not-found pages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/components/ui/empty-state.tsx | 72 +++++++++++++++++++++++-------- src/pages/AttackPage.tsx | 2 +- src/pages/BrainstormPage.tsx | 2 +- src/pages/FactoidsPage.tsx | 2 +- src/pages/GeneratePage.tsx | 2 +- 5 files changed, 59 insertions(+), 21 deletions(-) diff --git a/src/components/ui/empty-state.tsx b/src/components/ui/empty-state.tsx index e0f376aa..c5500dad 100644 --- a/src/components/ui/empty-state.tsx +++ b/src/components/ui/empty-state.tsx @@ -1,38 +1,76 @@ +import type { ReactNode } from 'react'; import type { LucideIcon } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Owl, type OwlPose } from '@/components/brand/Owl'; import { cn } from '@/lib/utils'; interface EmptyStateProps { - icon: LucideIcon; + /** The tool's icon, shown as a badge on the owl. */ + icon?: LucideIcon; title: string; description?: string; + /** A short muted line above the title, such as a status code. */ + eyebrow?: string; actionLabel?: string; onAction?: () => void; + /** The next step as an element, such as a router link inside a Button. Wins over actionLabel. */ + action?: ReactNode; /** The owl's pose; it points at the action by default. */ pose?: OwlPose; - /** Heading level, so the title fits the page outline (h2 under a page h1). */ - headingLevel?: 'h2' | 'h3'; + /** Heading level, so the title fits the page outline (h1 when it is the page, h2 under a page h1). */ + headingLevel?: 'h1' | 'h2' | 'h3'; + /** + * `compact` is a small owl and no badge, for a panel beside a form that is already the next + * step. `compact-below-lg` is compact on phones and iPads, where the panel stacks under the + * form, and regular beside it on desktop. + */ + size?: 'regular' | 'compact' | 'compact-below-lg'; className?: string; } /** Nothing here yet: the owl with the tool's icon badge, one line on what goes here, and the next step. */ -export function EmptyState({ icon: Icon, title, description, actionLabel, onAction, pose = 'point', headingLevel: Heading = 'h2', className }: EmptyStateProps) { +export function EmptyState({ + icon: Icon, title, description, eyebrow, actionLabel, onAction, action, pose = 'point', + headingLevel: Heading = 'h2', size = 'regular', className, +}: EmptyStateProps) { + const compact = size === 'compact'; + const responsive = size === 'compact-below-lg'; + const next = action ?? (actionLabel && onAction ? <Button onClick={onAction}>{actionLabel}</Button> : null); return ( - <div className={cn('flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-border-strong bg-card/60 px-6 py-10 text-center motion-safe:animate-in fade-in-0 duration-300', className)}> - <div className="relative mb-4"> - <Owl pose={pose} size={112} /> - <span className="absolute -bottom-1 -right-2 flex h-10 w-10 items-center justify-center rounded-full border-2 border-card bg-brand-tint text-primary shadow-sm" aria-hidden="true"> - <Icon className="h-5 w-5" /> - </span> - </div> - <Heading className="font-display text-xl font-[600] leading-tight text-heading">{title}</Heading> - {description && <p className="mt-1.5 max-w-sm text-sm text-muted-foreground">{description}</p>} - {actionLabel && onAction && ( - <Button className="mt-5" onClick={onAction}> - {actionLabel} - </Button> + <div + className={cn( + 'flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-border-strong bg-card/60 px-6 text-center motion-safe:animate-in fade-in-0 duration-300', + compact ? 'py-6' : responsive ? 'py-6 lg:py-10' : 'py-10', + className, )} + > + <div className={cn('relative', compact ? 'mb-3' : responsive ? 'mb-3 lg:mb-4' : 'mb-4')}> + {/* Decorative, so a second owl for the other size costs nothing to assistive tech. */} + {(compact || responsive) && <Owl pose={pose} size={56} className={cn(responsive && 'lg:hidden')} />} + {!compact && <Owl pose={pose} size={112} className={cn(responsive && 'hidden lg:inline-block')} />} + {Icon && !compact && ( + <span + className={cn( + 'absolute -bottom-1 -right-2 h-10 w-10 items-center justify-center rounded-full border-2 border-card bg-brand-tint text-primary shadow-sm', + responsive ? 'hidden lg:flex' : 'flex', + )} + aria-hidden="true" + > + <Icon className="h-5 w-5" /> + </span> + )} + </div> + {eyebrow && <p className="mb-1 font-display text-sm font-[600] tracking-[0.08em] text-muted-foreground">{eyebrow}</p>} + <Heading + className={cn( + 'text-balance font-display font-[600] leading-tight text-heading', + compact ? 'text-lg' : responsive ? 'text-lg lg:text-xl' : Heading === 'h1' ? 'text-2xl sm:text-3xl' : 'text-xl', + )} + > + {title} + </Heading> + {description && <p className="mt-1.5 max-w-sm text-balance text-sm text-muted-foreground">{description}</p>} + {next && <div className="mt-5">{next}</div>} </div> ); } diff --git a/src/pages/AttackPage.tsx b/src/pages/AttackPage.tsx index b8478d31..54c73443 100644 --- a/src/pages/AttackPage.tsx +++ b/src/pages/AttackPage.tsx @@ -149,7 +149,7 @@ export default function AttackPage() { /> </div> ) : ( - <EmptyState icon={Swords} title={t('attack.emptyTitle')} description={t('attack.emptyDesc')} /> + <EmptyState size="compact-below-lg" icon={Swords} title={t('attack.emptyTitle')} description={t('attack.emptyDesc')} /> )} </div> </div> diff --git a/src/pages/BrainstormPage.tsx b/src/pages/BrainstormPage.tsx index 42d0dc7f..245bcc44 100644 --- a/src/pages/BrainstormPage.tsx +++ b/src/pages/BrainstormPage.tsx @@ -136,7 +136,7 @@ export default function BrainstormPage() { <BrainstormResults result={brainstormResult} discoveredSources={discoveredSources} onDiscuss={handleDiscuss} onSave={() => void handleSave()} saved={resultSaved} saving={saving} /> </div> ) : ( - <EmptyState icon={Lightbulb} title={t('brainstorm.emptyTitle')} description={t('brainstorm.emptyDesc')} /> + <EmptyState size="compact-below-lg" icon={Lightbulb} title={t('brainstorm.emptyTitle')} description={t('brainstorm.emptyDesc')} /> )} </div> </div> diff --git a/src/pages/FactoidsPage.tsx b/src/pages/FactoidsPage.tsx index 42f26ecc..2d762160 100644 --- a/src/pages/FactoidsPage.tsx +++ b/src/pages/FactoidsPage.tsx @@ -172,7 +172,7 @@ export default function FactoidsPage() { /> </div> ) : ( - <EmptyState icon={BookOpen} title={t('factoids.emptyTitle')} description={t('factoids.emptyDesc')} /> + <EmptyState size="compact-below-lg" icon={BookOpen} title={t('factoids.emptyTitle')} description={t('factoids.emptyDesc')} /> )} </div> </div> diff --git a/src/pages/GeneratePage.tsx b/src/pages/GeneratePage.tsx index 44cd7f60..765958ab 100644 --- a/src/pages/GeneratePage.tsx +++ b/src/pages/GeneratePage.tsx @@ -324,7 +324,7 @@ export default function GeneratePage() { /> </div> ) : ( - <EmptyState icon={Target} pose={run.error ? 'surprised' : 'point'} title={t('generate.emptyTitle')} description={t('generate.emptyDesc')} /> + <EmptyState size="compact-below-lg" icon={Target} pose={run.error ? 'surprised' : 'point'} title={t('generate.emptyTitle')} description={t('generate.emptyDesc')} /> )} </div> </div> From 466d88407870ba46f2319cbc5df0037a65a965f0 Mon Sep 17 00:00:00 2001 From: Adam <adam.gaborjani.szabo@gmail.com> Date: Tue, 27 Oct 2026 08:54:34 +0000 Subject: [PATCH 04/12] 404 and a bad share link look the same Both render EmptyState with the surprised owl at 112 px, an h1 title, a short line and the home link. The 404 page's giant "404" becomes a muted line above "Page not found", and the share page's grey share icon makes way for the owl. The 404 owl drops from 168 px to the empty-state size. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/pages/NotFound.tsx | 26 ++++++++++++++++---------- src/pages/SharedPage.tsx | 22 ++++++++++++++-------- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx index 5af0575f..ed367ea6 100644 --- a/src/pages/NotFound.tsx +++ b/src/pages/NotFound.tsx @@ -1,7 +1,7 @@ import { Link } from "react-router-dom"; -import { Owl } from "@/components/brand/Owl"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; +import { EmptyState } from "@/components/ui/empty-state"; import { PublicPageShell, useHomePath } from "@/components/layout/PublicPageShell"; import { usePageHead } from "@/lib/seo/use-page-head"; @@ -12,15 +12,21 @@ const NotFound = () => { usePageHead("*"); return ( - <PublicPageShell className="flex min-h-[60vh] max-w-[65ch] flex-col items-center justify-center text-center"> - {/* The owl is as surprised as you are. */} - <Owl pose="surprised" size={168} priority className="mb-2" /> - <h1 className="mb-3 font-display text-6xl font-[600] tracking-[-0.02em] text-heading">{t('notFound.title')}</h1> - <p className="mb-6 text-xl text-muted-foreground">{t('notFound.message')}</p> - {/* An in-app link: no full reload through the startup shell, and signed-in users stay in the app. */} - <Button asChild> - <Link to={home}>{t('notFound.link')}</Link> - </Button> + <PublicPageShell className="flex min-h-[60vh] max-w-xl flex-col justify-center"> + {/* The owl is as surprised as you are; a bad share link looks the same. */} + <EmptyState + pose="surprised" + headingLevel="h1" + eyebrow={t('notFound.title')} + title={t('notFound.message')} + description={t('notFound.description')} + action={( + // An in-app link: no full reload through the startup shell, and signed-in users stay in the app. + <Button asChild> + <Link to={home}>{t('notFound.link')}</Link> + </Button> + )} + /> </PublicPageShell> ); }; diff --git a/src/pages/SharedPage.tsx b/src/pages/SharedPage.tsx index 7a8d7062..cf4af748 100644 --- a/src/pages/SharedPage.tsx +++ b/src/pages/SharedPage.tsx @@ -5,7 +5,7 @@ import { Link, useParams } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { supabase } from '@/lib/supabase/client'; -import { Share2 } from 'lucide-react'; +import { EmptyState } from '@/components/ui/empty-state'; import { formatEuropeanDate } from '@/lib/date-format'; import { PublicPageShell, useHomePath } from '@/components/layout/PublicPageShell'; import { usePageLabel } from '@/lib/seo/use-page-head'; @@ -155,13 +155,19 @@ export default function SharedPage() { if (error || !item) { return ( - <PublicPageShell className="max-w-[65ch] text-center"> - <Share2 className="mx-auto mb-4 h-8 w-8 text-muted-foreground" aria-hidden="true" /> - <h1 className="text-2xl font-bold tracking-tight">{t('sharedPage.notFoundTitle')}</h1> - <p className="mt-2 text-muted-foreground">{error || t('sharedPage.notFound')}</p> - <Button asChild className="mt-6"> - <Link to={home}>{t('sharedPage.backHome')}</Link> - </Button> + <PublicPageShell className="flex min-h-[60vh] max-w-xl flex-col justify-center"> + {/* Same look as the 404 page. */} + <EmptyState + pose="surprised" + headingLevel="h1" + title={t('sharedPage.notFoundTitle')} + description={error || t('sharedPage.notFound')} + action={( + <Button asChild> + <Link to={home}>{t('sharedPage.backHome')}</Link> + </Button> + )} + /> </PublicPageShell> ); } From 99e9dd1566a760a78b5d124a2f25fc210c287365 Mon Sep 17 00:00:00 2001 From: Adam <adam.gaborjani.szabo@gmail.com> Date: Tue, 27 Oct 2026 08:58:47 +0000 Subject: [PATCH 05/12] Rows of peers wrap evenly at every width - Home: the three utilities are one equal row; on phones the icon sits above the label so Hungarian fits. - History: the seven tabs are one sideways row below lg, fading at the right edge, and seven equal columns from lg, icon above label. They keep 44 px on every touch screen, iPads included. - Timer: every speech chip is in view, in balanced rows (8 speeches are 4 + 4 on a phone, one row from md), with the time under the code. - Landing hero: the three plan stats stay three across on phones, and on iPad the text and preview sit in one centred column. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/components/features/tools/tool-links.tsx | 3 +- src/components/free-tools/DebateTimer.tsx | 32 +++++++++++++++----- src/components/landing/Hero.tsx | 13 ++++---- src/pages/HistoryPage.tsx | 14 +++++++-- src/pages/HomePage.tsx | 13 ++++++-- 5 files changed, 55 insertions(+), 20 deletions(-) diff --git a/src/components/features/tools/tool-links.tsx b/src/components/features/tools/tool-links.tsx index d697474d..8c4fa524 100644 --- a/src/components/features/tools/tool-links.tsx +++ b/src/components/features/tools/tool-links.tsx @@ -53,7 +53,7 @@ export const UTILITY_COUNT = UTILITY_TOOLS.length; * A link to a tool. `row` shows the description (Tools page lists); `tile` shows only * the label (the debate dashboard and the utility strip). */ -export function ToolLink({ tool, variant, tourId }: { tool: Tool; variant: 'row' | 'tile'; tourId?: string }) { +export function ToolLink({ tool, variant, tourId, className }: { tool: Tool; variant: 'row' | 'tile'; tourId?: string; className?: string }) { const { t } = useTranslation(); const Icon = tool.icon; const prefetchHandlers = useRoutePrefetch(tool.href); @@ -67,6 +67,7 @@ export function ToolLink({ tool, variant, tourId }: { tool: Tool; variant: 'row' className={cn( 'tile flex min-h-11 items-center gap-2 rounded-md px-3.5 py-2 text-[0.9375rem] font-bold text-foreground', 'focus-visible:outline-hidden focus-visible:ring-[3px] focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background', + className, )} > <Icon className="h-4 w-4 shrink-0 text-primary" aria-hidden="true" /> diff --git a/src/components/free-tools/DebateTimer.tsx b/src/components/free-tools/DebateTimer.tsx index b7f91332..da63a47a 100644 --- a/src/components/free-tools/DebateTimer.tsx +++ b/src/components/free-tools/DebateTimer.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; +import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { ChevronLeft, ChevronRight, Maximize, Minimize, Pause, Play, RotateCcw, Volume2, VolumeX } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -32,6 +32,16 @@ const SIDE_TONE: Record<RoundSegment['side'], string> = { both: 'bg-slate-500 dark:bg-slate-400', }; +/** The fewest equal rows of at most `max` chips, as columns: 7 at 4 a row is 4 + 3, never 4 + 2 + 1. */ +const balancedColumns = (count: number, max: number) => Math.ceil(count / Math.ceil(count / max)); + +// At most 4 speech chips a row on phones, 6 from sm and 8 from md (an iPad shows a whole school round). +const chipColumns = (count: number) => ({ + '--chips': balancedColumns(count, 4), + '--chips-sm': balancedColumns(count, 6), + '--chips-md': balancedColumns(count, 8), +}) as CSSProperties; + function isTyping(target: EventTarget | null): boolean { return target instanceof HTMLElement && (target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)); } @@ -244,23 +254,29 @@ export function DebateTimer({ storageKey, embedded = false, onFirstStart, footer </div> )} - <nav aria-label={t('timer.speeches')} className="overflow-x-auto px-4 pt-4"> - <ol className="flex gap-2 pb-2"> + <nav aria-label={t('timer.speeches')} className="px-4 pt-4"> + {/* Every speech in view, in rows of equal length: 8 speeches are 4 + 4 on a phone and one row from md. */} + <ol + className="grid grid-cols-[repeat(var(--chips),minmax(0,1fr))] gap-2 pb-2 sm:grid-cols-[repeat(var(--chips-sm),minmax(0,1fr))] md:grid-cols-[repeat(var(--chips-md),minmax(0,1fr))]" + style={chipColumns(format.segments.length)} + > {format.segments.map((entry, index) => ( - <li key={entry.id} className="shrink-0"> + <li key={entry.id} className="min-w-0"> <button type="button" onClick={() => select(index)} aria-current={index === round.segmentIndex ? 'step' : undefined} title={entry.label} className={cn( - 'flex min-h-11 items-center gap-2 rounded-md border px-3 text-sm transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring', + 'flex min-h-11 w-full flex-col items-center justify-center rounded-md border px-1.5 py-1 text-sm leading-tight transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring', index === round.segmentIndex ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-background hover:bg-muted', )} > - <span className={cn('h-2 w-2 rounded-full', SIDE_TONE[entry.side])} aria-hidden="true" /> - <span className="font-medium">{entry.short}</span> - <span className={index === round.segmentIndex ? 'text-primary-foreground/80' : 'text-muted-foreground'}>{formatDuration(entry.seconds)}</span> + <span className="flex max-w-full items-center gap-1.5"> + <span className={cn('h-2 w-2 shrink-0 rounded-full', SIDE_TONE[entry.side])} aria-hidden="true" /> + <span className="truncate font-medium">{entry.short}</span> + </span> + <span className={cn('text-xs tabular-nums', index === round.segmentIndex ? 'text-primary-foreground/80' : 'text-muted-foreground')}>{formatDuration(entry.seconds)}</span> </button> </li> ))} diff --git a/src/components/landing/Hero.tsx b/src/components/landing/Hero.tsx index d850d13e..d0d5ccbd 100644 --- a/src/components/landing/Hero.tsx +++ b/src/components/landing/Hero.tsx @@ -32,7 +32,7 @@ const Hero = () => { <div className="section-container relative z-10"> <div className="grid grid-cols-1 items-center gap-14 lg:grid-cols-[1.05fr_0.95fr]"> - <div> + <div className="mx-auto w-full max-w-2xl lg:mx-0 lg:max-w-none"> <Reveal onLoad className="mb-6"> {/* rounded-2xl, not a pill: long Spanish and Hungarian copy wraps cleanly. */} <span className="inline-flex items-start gap-2 rounded-lg border-2 border-brand-tint-border bg-brand-tint px-4 py-2 text-sm font-bold text-foreground"> @@ -70,11 +70,12 @@ const Hero = () => { </a> </Reveal> - <Reveal onLoad delay={0.15} className="mt-8 grid gap-3 sm:grid-cols-3"> + {/* Three across at every width, so on a phone they take one short row under the buttons. */} + <Reveal onLoad delay={0.15} className="mt-8 grid grid-cols-3 gap-2 sm:gap-3"> {planHighlights.map((item) => ( - <div key={item.label} className="rounded-lg border-2 border-border bg-card p-4"> - <p className="text-xs font-extrabold uppercase tracking-[0.08em] text-muted-foreground">{item.label}</p> - <p className="mt-1 font-display text-lg font-[600] text-heading">{item.value}</p> + <div key={item.label} className="rounded-lg border-2 border-border bg-card p-3 sm:p-4"> + <p className="text-balance text-[0.6875rem] font-extrabold uppercase leading-4 tracking-[0.04em] text-muted-foreground sm:text-xs sm:tracking-[0.08em]">{item.label}</p> + <p className="mt-1 text-balance font-display text-base font-[600] text-heading sm:text-lg">{item.value}</p> </div> ))} </Reveal> @@ -133,7 +134,7 @@ const HeroPreview = () => { ]; return ( - <Reveal onLoad delay={0.1} className="relative"> + <Reveal onLoad delay={0.1} className="relative mx-auto w-full max-w-2xl lg:mx-0 lg:max-w-none"> <div className="absolute -inset-8 bg-linear-to-r from-plume-400/25 via-plume-200/20 to-[rgb(255_196_20/0.22)] blur-3xl" /> {/* The owl points the visitor at the product. */} <Owl pose="point" size={140} priority className="absolute -bottom-10 -left-6 z-20 hidden -scale-x-100 sm:block lg:-left-12" /> diff --git a/src/pages/HistoryPage.tsx b/src/pages/HistoryPage.tsx index 224035ce..cd6a2173 100644 --- a/src/pages/HistoryPage.tsx +++ b/src/pages/HistoryPage.tsx @@ -28,6 +28,7 @@ import { UnsortedWork } from '@/components/features/debate/UnsortedWork'; import { PageHeader } from '@/components/layout/PageHeader'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { parseSavedCrossfire, SavedCrossfireView } from '@/components/features/crossfire/SavedCrossfireView'; +import { cn } from '@/lib/utils'; /** History scope value for work that belongs to no debate. */ const OUTSIDE_DEBATES = 'outside'; @@ -38,8 +39,15 @@ const isHistoryTab = (value: string | null): value is HistoryTab => HISTORY_TABS // Lists scroll with the page on phones; wide screens get a bounded inner list. const LIST_CLASS = 'space-y-4 lg:max-h-[500px] lg:overflow-y-auto lg:pr-4'; -// Phones wrap the tabs onto more lines instead of a sideways scroll, each one a 44px target. -const TAB_CLASS = 'min-h-11 gap-2 sm:min-h-0'; +// Seven tabs never wrap unevenly. Below lg they are one row that scrolls sideways, the last tab +// fading out to show there is more; Hungarian labels don't fit seven across an iPad. From lg they +// share the width equally, icon above label. Every touch screen, iPads included, gets 44px targets. +const TAB_LIST_CLASS = cn( + 'h-auto w-full justify-start overflow-x-auto snap-x scroll-px-4 pr-8 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden', + '[mask-image:linear-gradient(to_right,#000_calc(100%-2.5rem),transparent)]', + 'lg:grid lg:grid-cols-7 lg:overflow-visible lg:pr-1 lg:[mask-image:none]', +); +const TAB_CLASS = 'min-h-11 shrink-0 snap-start gap-2 pointer-fine:min-h-0 lg:min-w-0 lg:flex-col lg:gap-1 lg:whitespace-normal lg:px-2 lg:py-2 lg:text-balance'; export default function HistoryPage() { const { t } = useTranslation(); @@ -179,7 +187,7 @@ export default function HistoryPage() { {scope === OUTSIDE_DEBATES ? <UnsortedWork /> : ( <Tabs value={tab} onValueChange={setTab} className="space-y-6"> - <TabsList className="h-auto w-full flex-wrap justify-start"> + <TabsList className={TAB_LIST_CLASS}> <TabsTrigger value="cases" className={TAB_CLASS}><FileText className="h-4 w-4" /> {t('historyPage.tabCases')}</TabsTrigger> <TabsTrigger value="chats" className={TAB_CLASS}><MessageCircle className="h-4 w-4" /> {t('historyPage.tabChats')}</TabsTrigger> <TabsTrigger value="attacks" className={TAB_CLASS}><Zap className="h-4 w-4" /> {t('historyPage.tabAttacks')}</TabsTrigger> diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index e06097fc..c2cef9c7 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -104,8 +104,17 @@ export default function HomePage() { <section className="mt-8 border-t-2 border-border pt-6" aria-labelledby="home-utilities-title"> <h2 id="home-utilities-title" className="text-lg font-extrabold text-heading">{t('home.utilities', 'Utilities')}</h2> - <div className="mt-3 flex flex-wrap gap-3"> - {UTILITY_TOOLS.map((tool) => <ToolLink key={tool.key} tool={tool} variant="tile" tourId={`mode-${tool.key}`} />)} + {/* One even row at every width; on phones the icon sits above the label so Hungarian fits. */} + <div className="mt-3 grid grid-cols-3 gap-2 sm:gap-3"> + {UTILITY_TOOLS.map((tool) => ( + <ToolLink + key={tool.key} + tool={tool} + variant="tile" + tourId={`mode-${tool.key}`} + className="w-full max-sm:flex-col max-sm:justify-center max-sm:gap-1 max-sm:px-2 max-sm:text-center max-sm:text-sm" + /> + ))} </div> </section> </div> From 94ae8ecd0df04c2bd7bbcccaf974d6a575fbab91 Mon Sep 17 00:00:00 2001 From: Adam <adam.gaborjani.szabo@gmail.com> Date: Tue, 27 Oct 2026 08:59:44 +0000 Subject: [PATCH 06/12] Bigger touch targets on toasts, tool pages and login - Toasts: on touch screens the action button is 44 px tall and the close button 32 px (they were 32 and about 20). - Free tool pages: inline links get 4 px above and below without moving the text, breadcrumbs and format source links are at least 24 px, and the flow sheet's strip checkbox sits in a 44 px row. - Login: "Back to DebateLab" is at least 24 px tall with a mouse, as well as 44 px on touch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/components/free-tools/FlowSheet.tsx | 4 ++-- src/components/free-tools/FormatReference.tsx | 2 +- src/components/free-tools/ToolPageLayout.tsx | 8 ++++---- src/components/ui/sonner.tsx | 5 +++-- src/pages/LoginPage.tsx | 2 +- 5 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/components/free-tools/FlowSheet.tsx b/src/components/free-tools/FlowSheet.tsx index 02804444..baf421ef 100644 --- a/src/components/free-tools/FlowSheet.tsx +++ b/src/components/free-tools/FlowSheet.tsx @@ -79,8 +79,8 @@ export function FlowSheet() { <option value="portrait">{t('flow.portrait')}</option> </select> </label> - <label className="flex min-h-10 items-center gap-2 self-end text-sm font-medium"> - <input type="checkbox" className="h-4 w-4" checked={strip} onChange={(event) => setStrip(event.target.checked)} /> + <label className="flex min-h-11 min-w-11 cursor-pointer items-center gap-2 self-end text-sm font-medium"> + <input type="checkbox" className="h-4 w-4 cursor-pointer" checked={strip} onChange={(event) => setStrip(event.target.checked)} /> {questions.length > 0 ? t('flow.questionStrip') : t('flow.notesStrip')} </label> {(['motion', 'round', 'judge'] as const).map((key) => ( diff --git a/src/components/free-tools/FormatReference.tsx b/src/components/free-tools/FormatReference.tsx index 5b7cd494..27fc9880 100644 --- a/src/components/free-tools/FormatReference.tsx +++ b/src/components/free-tools/FormatReference.tsx @@ -46,7 +46,7 @@ export function FormatReference({ format, headingLevel = 3 }: { format: RoundFor {format.sources.map((source, index) => ( <span key={source.url}> {index > 0 && '; '} - <a href={source.url} rel="noopener" target="_blank">{source.label}</a> + <a href={source.url} rel="noopener" target="_blank" className="inline-block py-1">{source.label}</a> </span> ))} </p> diff --git a/src/components/free-tools/ToolPageLayout.tsx b/src/components/free-tools/ToolPageLayout.tsx index 6e9ec618..11e85f78 100644 --- a/src/components/free-tools/ToolPageLayout.tsx +++ b/src/components/free-tools/ToolPageLayout.tsx @@ -24,7 +24,7 @@ export type FreeTool = { route: ToolPageRoute; name: string; blurb: string }; /** Long-form copy under a tool: a readable measure and plain headings. */ export function ToolArticle({ children, className }: { children: ReactNode; className?: string }) { return ( - <div className={cn('mx-auto max-w-[68ch] space-y-10 text-base leading-7 text-foreground [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-4 [&_h2]:text-2xl [&_h2]:font-semibold [&_h2]:tracking-tight [&_h3]:text-lg [&_h3]:font-semibold [&_li]:mt-1 [&_ol]:list-decimal [&_ol]:space-y-1 [&_ol]:pl-6 [&_p]:mt-3 [&_ul]:list-disc [&_ul]:pl-6', className)}> + <div className={cn('mx-auto max-w-[68ch] space-y-10 text-base leading-7 text-foreground [&_a:not(.not-prose)]:py-1 [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-4 [&_h2]:text-2xl [&_h2]:font-semibold [&_h2]:tracking-tight [&_h3]:text-lg [&_h3]:font-semibold [&_li]:mt-1 [&_ol]:list-decimal [&_ol]:space-y-1 [&_ol]:pl-6 [&_p]:mt-3 [&_ul]:list-disc [&_ul]:pl-6', className)}> {children} </div> ); @@ -127,11 +127,11 @@ export function ToolPageLayout({ route, title, intro, shortAnswer, tool, childre <main id={MAIN_CONTENT_ID} className="container mx-auto px-4 pb-16 pt-6 focus-visible:outline-hidden"> <nav aria-label={t('layout.breadcrumb')} className="text-sm text-muted-foreground print:hidden"> <ol className="flex flex-wrap items-center gap-1"> - <li><Link to={home} className="hover:text-foreground">{site.product}</Link></li> + <li><Link to={home} className="inline-flex min-h-6 items-center hover:text-foreground pointer-coarse:min-h-11">{site.product}</Link></li> <li aria-hidden="true">/</li> {crumb ? ( <> - <li><Link to={hub} className="hover:text-foreground">{t('layout.allTools')}</Link></li> + <li><Link to={hub} className="inline-flex min-h-6 items-center hover:text-foreground pointer-coarse:min-h-11">{t('layout.allTools')}</Link></li> <li aria-hidden="true">/</li> <li aria-current="page" className="text-foreground">{crumb}</li> </> @@ -142,7 +142,7 @@ export function ToolPageLayout({ route, title, intro, shortAnswer, tool, childre </nav> <div className="mx-auto mt-6 max-w-[68ch] print:hidden"> <h1 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl">{title}</h1> - <div className="mt-3 text-lg leading-8 text-muted-foreground">{intro}</div> + <div className="mt-3 text-lg leading-8 text-muted-foreground [&_a]:py-1">{intro}</div> {/* Before the tool: the timer, calculator and hub cards carry H2s of their own. */} <section aria-labelledby="short-answer" className="mt-8"> <h2 id="short-answer" className="text-xl font-semibold tracking-tight text-foreground">{shortAnswer.question}</h2> diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx index 0406d957..7d4b9e0b 100644 --- a/src/components/ui/sonner.tsx +++ b/src/components/ui/sonner.tsx @@ -26,15 +26,16 @@ const Toaster = ({ ...props }: ToasterProps) => { "group toast group-[.toaster]:bg-card! group-[.toaster]:text-foreground! group-[.toaster]:border-2! group-[.toaster]:border-border! group-[.toaster]:shadow-lg! group-[.toaster]:rounded-lg! group-[.toaster]:font-sans! group-[.toaster]:gap-3!", title: "group-[.toast]:font-extrabold! group-[.toast]:text-heading!", description: "group-[.toast]:text-muted-foreground group-[.toast]:font-semibold!", + // On touch screens the action is 44 px tall and the close button 32 px. // Each kind gets its colour and its own icon, so the meaning never rests on colour. icon: "group-[.toast]:size-8! group-[.toast]:rounded-full! group-[.toast]:flex! group-[.toast]:items-center! group-[.toast]:justify-center! group-[.toast]:shrink-0! group-[.toast]:m-0!", success: "[&_[data-icon]]:bg-success-tint [&_[data-icon]]:text-success-text", error: "[&_[data-icon]]:bg-danger-tint [&_[data-icon]]:text-danger-text", warning: "[&_[data-icon]]:bg-warning-tint [&_[data-icon]]:text-warning-text", info: "[&_[data-icon]]:bg-brand-tint [&_[data-icon]]:text-primary", - actionButton: "group-[.toast]:bg-brand! group-[.toast]:text-white! group-[.toast]:font-display! group-[.toast]:rounded-md! group-[.toast]:shadow-[0_3px_0_var(--do-primary-ledge)]! group-[.toast]:h-8! group-[.toast]:px-3!", + actionButton: "group-[.toast]:bg-brand! group-[.toast]:text-white! group-[.toast]:font-display! group-[.toast]:rounded-md! group-[.toast]:shadow-[0_3px_0_var(--do-primary-ledge)]! group-[.toast]:h-8! group-[.toast]:px-3! pointer-coarse:group-[.toast]:h-11!", cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground", - closeButton: "group-[.toast]:bg-card! group-[.toast]:text-muted-foreground! group-[.toast]:border-2! group-[.toast]:border-border! hover:group-[.toast]:text-foreground!", + closeButton: "group-[.toast]:bg-card! group-[.toast]:text-muted-foreground! group-[.toast]:border-2! group-[.toast]:border-border! hover:group-[.toast]:text-foreground! pointer-coarse:group-[.toast]:size-8!", }, }} {...props} diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 27a665f2..815f908a 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -328,7 +328,7 @@ export default function LoginPage() { // Below md (768 px), phones included, the card starts near the top; from md up it sits in the middle. <main className="min-h-screen flex flex-col items-center justify-start gap-4 bg-background px-4 py-4 md:justify-center md:py-8"> <div className="flex w-full max-w-md items-center justify-between gap-4"> - <Link to="/" className="inline-flex items-center gap-1.5 rounded-sm text-sm text-muted-foreground transition-colors hover:text-foreground pointer-coarse:min-h-11"> + <Link to="/" className="inline-flex min-h-6 items-center gap-1.5 rounded-sm text-sm text-muted-foreground transition-colors hover:text-foreground pointer-coarse:min-h-11"> <ArrowLeft className="h-4 w-4" aria-hidden="true" /> {t('login.backHome', 'Back to DebateLab')} </Link> From f1cc9c8249d1ebda72724bf9590afa37a0084f2a Mon Sep 17 00:00:00 2001 From: Adam <adam.gaborjani.szabo@gmail.com> Date: Tue, 27 Oct 2026 09:00:55 +0000 Subject: [PATCH 07/12] Intro fits a phone; the tour ends with the owl - One-time intro: on phones the owl circle is 112 px instead of 144, the title balances across lines, and both footer buttons are 44 px with Got it filling the row. With the shorter copy it fits 390 x 844 without scrolling. - Tour: the last step uses the first step's Fredoka heading and a small celebrating owl, in place of the rocket emoji. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../features/onboarding/DebateOwlIntro.tsx | 14 ++++++++------ .../features/onboarding/OnboardingTour.tsx | 7 +++++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/components/features/onboarding/DebateOwlIntro.tsx b/src/components/features/onboarding/DebateOwlIntro.tsx index d9646754..6e46d76c 100644 --- a/src/components/features/onboarding/DebateOwlIntro.tsx +++ b/src/components/features/onboarding/DebateOwlIntro.tsx @@ -66,7 +66,8 @@ export function DebateOwlIntro({ onClose, owlName = OWL_NAME }: { onClose: () => > <div className="flex flex-col items-center gap-5 px-6 pt-7 sm:flex-row sm:items-center sm:gap-6 sm:px-8 sm:pt-8"> <motion.div - className="grid h-36 w-36 shrink-0 place-items-center rounded-full bg-plume-100 dark:bg-plume-900" + // 112 px on phones, the empty-state size, so the whole dialog fits a 390 x 844 screen. + className="grid size-28 shrink-0 place-items-center rounded-full bg-plume-100 sm:size-36 dark:bg-plume-900" initial={reduceMotion ? false : { scale: 0.6, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} transition={{ type: 'spring', stiffness: 260, damping: 18 }} @@ -77,7 +78,7 @@ export function DebateOwlIntro({ onClose, owlName = OWL_NAME }: { onClose: () => animate={{ y: 0, opacity: 1 }} transition={{ type: 'spring', stiffness: 300, damping: 16, delay: 0.15 }} > - <Owl pose={pose} size={136} priority /> + <Owl pose={pose} size={136} priority className="max-sm:size-[106px]!" /> </motion.span> </motion.div> @@ -108,19 +109,20 @@ export function DebateOwlIntro({ onClose, owlName = OWL_NAME }: { onClose: () => </div> <div className="px-6 pb-6 pt-6 sm:px-8"> - <DialogTitle className="text-[1.375rem] leading-7">{t('debateOwlIntro.noticeTitle')}</DialogTitle> + <DialogTitle className="text-balance text-[1.375rem] leading-7">{t('debateOwlIntro.noticeTitle')}</DialogTitle> <DialogDescription className="mt-2 text-base leading-relaxed text-foreground"> {t('debateOwlIntro.noticeBody')} </DialogDescription> </div> <div className="flex min-h-[4.75rem] items-center justify-between gap-3 border-t-2 border-border px-6 py-4 sm:px-8"> - {settled ? <span /> : ( - <Button variant="ghost" size="sm" className="-ml-3.5 text-muted-foreground" onClick={() => setSettled(true)}> + {settled ? <span className="max-sm:hidden" /> : ( + <Button variant="ghost" size="sm" className="min-h-11 text-muted-foreground sm:-ml-3.5" onClick={() => setSettled(true)}> {t('debateOwlIntro.skipAnimation')} </Button> )} - <Button ref={doneButton} onClick={onClose}>{t('debateOwlIntro.done')}</Button> + {/* On phones Got it fills the row, and the whole row once Skip animation is gone. */} + <Button ref={doneButton} onClick={onClose} className="min-h-11 max-sm:flex-1">{t('debateOwlIntro.done')}</Button> </div> </DialogContent> </Dialog> diff --git a/src/components/features/onboarding/OnboardingTour.tsx b/src/components/features/onboarding/OnboardingTour.tsx index 9212f796..999705f4 100644 --- a/src/components/features/onboarding/OnboardingTour.tsx +++ b/src/components/features/onboarding/OnboardingTour.tsx @@ -3,6 +3,7 @@ import { useEffect, useCallback, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import Joyride, { ACTIONS, CallBackProps, EVENTS, STATUS, Step } from 'react-joyride'; import { useLocation } from 'react-router-dom'; +import { Owl } from '@/components/brand/Owl'; import { Button } from '@/components/ui/button'; import { hasCompletedOnboarding as completedFor, useOnboardingStore } from '@/hooks/use-onboarding'; import { useDebateStore } from '@/lib/stores/debate-store'; @@ -80,8 +81,10 @@ export function OnboardingTour({ eligibleAt, userId }: { eligibleAt?: number; us { target: 'body', content: ( - <div className="text-center"> - <h2 className="text-xl font-bold mb-2">{t('onboarding.ready')}</h2> + <div className="flex flex-col items-center text-center"> + {/* A small celebration, once: the owl in place of the old emoji. */} + <Owl pose="celebrating" size={72} className="mb-2" /> + <h2 className="mb-2 text-balance font-display text-xl font-[600] text-heading">{t('onboarding.ready')}</h2> <p className="text-muted-foreground"> {t('onboarding.readyDesc')} </p> From 734e57b1ddf148a9b248b6aa95a8d3d345cb271e Mon Sep 17 00:00:00 2001 From: Adam <adam.gaborjani.szabo@gmail.com> Date: Tue, 27 Oct 2026 09:03:33 +0000 Subject: [PATCH 08/12] iPads get the top navigation bar The app's top navigation now starts at md (768 px) instead of lg, so an iPad shows Tools, Current debate, Library, History and Settings in the bar instead of the menu button and a second Current debate row. Between md and lg only the active link shows its label; the others are icons that keep their names for screen readers, so Hungarian fits at 768 px. The theme toggle still appears from lg. The e2e helpers that open the menu on narrow screens use the same 768 px. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- e2e/admin-announcements.spec.ts | 2 +- e2e/navigation-performance.spec.ts | 2 +- src/components/layout/AppLayout.tsx | 25 ++++++++++++++----------- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/e2e/admin-announcements.spec.ts b/e2e/admin-announcements.spec.ts index 63a1f3a3..9b22ca08 100644 --- a/e2e/admin-announcements.spec.ts +++ b/e2e/admin-announcements.spec.ts @@ -15,7 +15,7 @@ test.beforeEach(async ({ page }) => { }); async function openAppNav(page: Page, label: string) { - if ((page.viewportSize()?.width ?? 1280) < 1024) { + if ((page.viewportSize()?.width ?? 1280) < 768) { await page.getByRole('button', { name: 'Open menu' }).click(); await page.getByRole('navigation', { name: 'Mobile navigation' }) .getByRole('link', { name: new RegExp(`^${label}`) }) diff --git a/e2e/navigation-performance.spec.ts b/e2e/navigation-performance.spec.ts index dc195ce9..b7994f20 100644 --- a/e2e/navigation-performance.spec.ts +++ b/e2e/navigation-performance.spec.ts @@ -55,7 +55,7 @@ function homeToolLink(page: Page, tool: string) { } async function openHome(page: Page) { - if ((page.viewportSize()?.width ?? 1280) < 1024) { + if ((page.viewportSize()?.width ?? 1280) < 768) { await page.getByRole('button', { name: 'Open menu' }).click(); await page.getByRole('navigation', { name: 'Mobile navigation' }).getByRole('link', { name: /^Tools/ }).click(); } else { diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index 7976fc9d..2ee1d754 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -52,7 +52,10 @@ const desktopLinkClass = cn( "text-muted-foreground hover:text-foreground hover:bg-accent" ); // The plume tint keeps primary text above 4.5:1 in both themes (5.9 light, 7.9 dark). -const activeLinkClass = "bg-brand-tint text-primary hover:bg-brand-tint hover:text-primary"; +const activeLinkClass = "bg-brand-tint text-primary hover:bg-brand-tint hover:text-primary [&>span]:not-sr-only"; +// From md to lg (iPads) the bar is too narrow for every label, so only the active link shows its +// label; the others are icons that keep their name for screen readers. +const navLabelClass = "max-lg:sr-only"; const ANNOUNCEMENT_CACHE_KEY = 'debatelab-announcements'; @@ -175,10 +178,10 @@ export function AppLayout() { void loadAnnouncements(); }, [loadAnnouncements, location.pathname]); - // The menu is `lg:hidden`: widening the window would hide it while it still holds the page modal. + // The menu is `md:hidden`: widening the window would hide it while it still holds the page modal. useEffect(() => { if (!mobileMenuOpen) return; - const wide = window.matchMedia('(min-width: 1024px)'); + const wide = window.matchMedia('(min-width: 768px)'); const close = () => { if (wide.matches) setMobileMenuOpen(false); }; close(); wide.addEventListener('change', close); @@ -275,7 +278,7 @@ export function AppLayout() { </Link> {/* Desktop Nav */} - <nav aria-label={t('nav.mainNavigation')} className="hidden lg:flex min-w-0 flex-1 items-center gap-0.5 xl:gap-1 ml-4"> + <nav aria-label={t('nav.mainNavigation')} className="hidden md:flex min-w-0 flex-1 items-center gap-0.5 xl:gap-1 ml-4"> <Link to="/app" {...homePrefetch} @@ -284,7 +287,7 @@ export function AppLayout() { className={cn(desktopLinkClass, section === 'tools' && activeLinkClass)} > <LayoutGrid className="h-4 w-4 shrink-0" aria-hidden="true" /> - <span>{t('nav.tools')}</span> + <span className={navLabelClass}>{t('nav.tools')}</span> </Link> <CurrentDebateMenu variant="desktop" /> {libraryNavItems.map((item) => ( @@ -297,7 +300,7 @@ export function AppLayout() { activeClassName={activeLinkClass} > <item.icon className="h-4 w-4 shrink-0" aria-hidden="true" /> - <span>{t(`nav.${item.key}`)}</span> + <span className={navLabelClass}>{t(`nav.${item.key}`)}</span> </NavLink> ))} </nav> @@ -312,7 +315,7 @@ export function AppLayout() { to="/app/settings" aria-label={t('nav.settings', 'Settings')} data-tour="nav-settings" - className="hidden lg:inline-flex h-10 w-10 items-center justify-center rounded-full text-muted-foreground transition-[color,background-color,transform] duration-150 hover:bg-accent hover:text-foreground active:scale-90 focus-visible:outline-hidden focus-visible:ring-[3px] focus-visible:ring-ring focus-visible:ring-offset-2" + className="hidden md:inline-flex h-10 w-10 items-center justify-center rounded-full text-muted-foreground transition-[color,background-color,transform] duration-150 hover:bg-accent hover:text-foreground active:scale-90 focus-visible:outline-hidden focus-visible:ring-[3px] focus-visible:ring-ring focus-visible:ring-offset-2" activeClassName={activeLinkClass} > <Settings className="h-5 w-5" aria-hidden="true" /> @@ -324,7 +327,7 @@ export function AppLayout() { ref={mobileMenuTriggerRef} variant="ghost" size="icon" - className="rounded-full lg:hidden text-muted-foreground" + className="rounded-full md:hidden text-muted-foreground" aria-label={mobileMenuOpen ? t('nav.closeMenu') : t('nav.openMenu')} aria-expanded={mobileMenuOpen} > @@ -333,10 +336,10 @@ export function AppLayout() { </DialogPrimitive.Trigger> {/* Mobile Nav: a modal panel under the header, with a scrim over the page. */} <DialogPrimitive.Portal> - <DialogPrimitive.Overlay className="fixed inset-0 z-40 bg-[var(--do-overlay)] lg:hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 ease-out-strong data-[state=open]:duration-180 data-[state=closed]:duration-120" /> + <DialogPrimitive.Overlay className="fixed inset-0 z-40 bg-[var(--do-overlay)] md:hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 ease-out-strong data-[state=open]:duration-180 data-[state=closed]:duration-120" /> <DialogPrimitive.Content aria-describedby={undefined} - className="native-scroll-y native-mobile-menu fixed inset-x-0 top-(--app-header-bottom,8rem) z-40 overflow-y-auto rounded-b-xl border-b-2 border-border bg-card p-3 shadow-xl lg:hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:slide-in-from-top-1 data-[state=closed]:slide-out-to-top-1 ease-out-strong data-[state=open]:duration-180 data-[state=closed]:duration-120" + className="native-scroll-y native-mobile-menu fixed inset-x-0 top-(--app-header-bottom,8rem) z-40 overflow-y-auto rounded-b-xl border-b-2 border-border bg-card p-3 shadow-xl md:hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:slide-in-from-top-1 data-[state=closed]:slide-out-to-top-1 ease-out-strong data-[state=open]:duration-180 data-[state=closed]:duration-120" > <DialogPrimitive.Title className="sr-only">{t('nav.mobileNavigation')}</DialogPrimitive.Title> <nav aria-label={t('nav.mobileNavigation')}> @@ -375,7 +378,7 @@ export function AppLayout() { </div> {/* Mobile Current Debate row */} - <div className="container min-w-0 border-t-2 border-border/70 px-4 py-1 max-[359px]:px-2 lg:hidden"> + <div className="container min-w-0 border-t-2 border-border/70 px-4 py-1 max-[359px]:px-2 md:hidden"> <CurrentDebateMenu variant="mobile" /> </div> </header> From 2b3bb6158289bf3601d611ebd3deff7c239d7090 Mon Sep 17 00:00:00 2001 From: Adam <adam.gaborjani.szabo@gmail.com> Date: Tue, 27 Oct 2026 09:07:27 +0000 Subject: [PATCH 09/12] Leave library names as on main The library's name is settled in its own PR (#238), so this one keeps every library-name string as main has it: the page title, the list card, "Save to Library", the saved and auto-saved toasts, Crossfire's grounding label and the running-text mentions, in all three languages. Tests and recipes go back to the same strings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../features/library-sources.md | 4 +-- .../recipes/account-data.spec.ts | 2 +- .../recipes/current-debate.spec.ts | 2 +- .../verify-debatelab/recipes/library.spec.ts | 4 +-- e2e/admin-announcements.spec.ts | 2 +- e2e/full-stack/account-data.spec.ts | 2 +- e2e/full-stack/account-deletion.spec.ts | 2 +- e2e/full-stack/case-generation.spec.ts | 2 +- e2e/full-stack/current-debate.spec.ts | 2 +- e2e/full-stack/debate-context.spec.ts | 2 +- e2e/staging/authed-flows.spec.ts | 2 +- .../features/debate/DebateContextControl.tsx | 6 ++-- src/lib/ai/sources.test.ts | 2 +- src/locales/en.json | 32 +++++++++---------- src/locales/es.json | 4 +-- src/locales/hu.json | 4 +-- src/pages/LibraryPage.tsx | 2 +- 17 files changed, 38 insertions(+), 38 deletions(-) diff --git a/.claude/skills/verify-debatelab/features/library-sources.md b/.claude/skills/verify-debatelab/features/library-sources.md index 425514f7..0131426a 100644 --- a/.claude/skills/verify-debatelab/features/library-sources.md +++ b/.claude/skills/verify-debatelab/features/library-sources.md @@ -8,7 +8,7 @@ Users save reference texts to their Library. The Library belongs to the Current - `lib-toggle` pauses a source through its switch, which keeps it out of prompts. - `lib-offline` offline, the switch does not move and a toast says `You are offline. Reconnect and try again.` A write that fails online puts the switch back with a toast. - `lib-edit-while-switching` the source editor's untouched fields follow the stored source while it is open (a switch that rolls back shows as rolled back), and `Save` sends only the fields edited in the sheet. Text typed while a save runs stays an edit and goes out with the next `Save`. -- `lib-dedupe` keeps one row per web page per debate. URLs match after normalizing (host without `www.`, no scheme, fragment, tracking parameters or trailing slash). Importing a page the debate has warns `This page is already in this debate's library.` before any fetch; research refreshes the existing row instead of adding one. +- `lib-dedupe` keeps one row per web page per debate. URLs match after normalizing (host without `www.`, no scheme, fragment, tracking parameters or trailing slash). Importing a page the debate has warns `This page is already in this debate's Library.` before any fetch; research refreshes the existing row instead of adding one. - `lib-layout` on a phone the row's controls take their own line, so a long title gets the row's width and two lines. - `lib-ground` includes only active sources' text in the `system` of later AI calls (Generate, Brainstorm, and others). - `lib-delete` deletes a source. It stays gone after a reload and in the `sources` table. @@ -27,7 +27,7 @@ Preconditions: - Doctor passes. The user is signed in (`signedIn` fixture). A Current Debate exists: `await startDebate(page, motion)`. Without one, `Add source` stays disabled. -- **Add.** Click the button named `Add source`. Fill the textbox named `Title` (exact), fill the textbox named `Content` (exact) with a unique marker, and click `Save to library`. Leaving either empty shows an inline error under the field and moves focus to it. The switch named `Toggle <title>` is checked. `sources` has a row with `is_active: true` and the debate's `debate_id`. +- **Add.** Click the button named `Add source`. Fill the textbox named `Title` (exact), fill the textbox named `Content` (exact) with a unique marker, and click `Save to Library`. Leaving either empty shows an inline error under the field and moves focus to it. The switch named `Toggle <title>` is checked. `sources` has a row with `is_active: true` and the debate's `debate_id`. - **Toggle.** Click the switch named `Toggle <title>`. It becomes not checked. - **Offline toggle.** `page.context().setOffline(true)`, click the switch: the offline toast shows, the switch stays checked, and the row's `is_active` is unchanged. Still offline, open the source, change its `Content` and click `Save`: the toast `Could not save this source.` shows and the typed text stays in the sheet. Set offline back to false and click `Save` again: `Source updated` shows and the row has the new content with `is_active` unchanged. - **Duplicate import.** Insert a URL source through `admin`, then import the same page with `?utm_source=x#top`: the duplicate warning shows and no `import-source` request is made. diff --git a/.claude/skills/verify-debatelab/recipes/account-data.spec.ts b/.claude/skills/verify-debatelab/recipes/account-data.spec.ts index f95e36ea..0de92a80 100644 --- a/.claude/skills/verify-debatelab/recipes/account-data.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/account-data.spec.ts @@ -14,7 +14,7 @@ async function addSource(page: Page, title: string, content: string) { await page.getByRole("button", { name: "Add source" }).click(); await page.getByRole("textbox", { name: "Title", exact: true }).fill(title); await page.getByRole("textbox", { name: "Content", exact: true }).fill(content); - await page.getByRole("button", { name: "Save to library" }).click(); + await page.getByRole("button", { name: "Save to Library" }).click(); await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked(); } diff --git a/.claude/skills/verify-debatelab/recipes/current-debate.spec.ts b/.claude/skills/verify-debatelab/recipes/current-debate.spec.ts index 064b3fc3..2ad52b5d 100644 --- a/.claude/skills/verify-debatelab/recipes/current-debate.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/current-debate.spec.ts @@ -16,7 +16,7 @@ async function addSource(page: Page, title: string, content: string) { await page.getByRole("button", { name: "Add source" }).click(); await page.getByRole("textbox", { name: "Title", exact: true }).fill(title); await page.getByRole("textbox", { name: "Content", exact: true }).fill(content); - await page.getByRole("button", { name: "Save to library" }).click(); + await page.getByRole("button", { name: "Save to Library" }).click(); await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked(); } diff --git a/.claude/skills/verify-debatelab/recipes/library.spec.ts b/.claude/skills/verify-debatelab/recipes/library.spec.ts index d07bcd8a..c998f954 100644 --- a/.claude/skills/verify-debatelab/recipes/library.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/library.spec.ts @@ -9,7 +9,7 @@ async function addSource(page: Page, title: string, content: string) { await page.getByRole("button", { name: "Add source" }).click(); await page.getByRole("textbox", { name: "Title", exact: true }).fill(title); await page.getByRole("textbox", { name: "Content", exact: true }).fill(content); - await page.getByRole("button", { name: "Save to library" }).click(); + await page.getByRole("button", { name: "Save to Library" }).click(); await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked(); } @@ -185,7 +185,7 @@ test("lib-dedupe and lib-layout: a page saved once per debate, and long titles k await page.getByRole("button", { name: "Add source" }).click(); await page.getByRole("textbox", { name: "Import from URL" }).fill("http://example.org/estudios/deberes-escolares-primaria?utm_source=x#top"); await page.getByRole("button", { name: "Import URL" }).click(); - await expect(page.getByText("This page is already in this debate's library.")).toBeVisible(); + await expect(page.getByText("This page is already in this debate's Library.")).toBeVisible(); expect(imported).toBe(false); await evidence.shot("02-duplicate-warning"); const { data } = await admin.from("sources").select("id").eq("user_id", userId); diff --git a/e2e/admin-announcements.spec.ts b/e2e/admin-announcements.spec.ts index 9b22ca08..dc602594 100644 --- a/e2e/admin-announcements.spec.ts +++ b/e2e/admin-announcements.spec.ts @@ -111,7 +111,7 @@ test('keeps announcements stable across routes and refreshes on a full reload', await expect(page.getByRole('heading', { name: 'Scheduled maintenance' })).toHaveCount(0); await openAppNav(page, 'Library'); - await expect(page.getByRole('heading', { name: 'Source library', exact: true })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Source Library', exact: true })).toBeVisible(); await openAppNav(page, 'Tools'); await expect(page.getByRole('main').getByRole('heading', { name: 'What are you working on?', exact: true })).toBeVisible(); expect(listRequestCount()).toBe(1); diff --git a/e2e/full-stack/account-data.spec.ts b/e2e/full-stack/account-data.spec.ts index 751b1a55..bb622a7a 100644 --- a/e2e/full-stack/account-data.spec.ts +++ b/e2e/full-stack/account-data.spec.ts @@ -7,7 +7,7 @@ async function addSource(page: Page, title: string, content: string) { await page.getByRole("button", { name: "Add source" }).click(); await page.getByRole("textbox", { name: "Title", exact: true }).fill(title); await page.getByRole("textbox", { name: "Content", exact: true }).fill(content); - await page.getByRole("button", { name: "Save to library" }).click(); + await page.getByRole("button", { name: "Save to Library" }).click(); await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked(); } diff --git a/e2e/full-stack/account-deletion.spec.ts b/e2e/full-stack/account-deletion.spec.ts index 1fbc0f1d..9e83ed9b 100644 --- a/e2e/full-stack/account-deletion.spec.ts +++ b/e2e/full-stack/account-deletion.spec.ts @@ -12,7 +12,7 @@ async function addSource(page: Page, title: string, content: string) { await page.getByRole("button", { name: "Add source" }).click(); await page.getByRole("textbox", { name: "Title", exact: true }).fill(title); await page.getByRole("textbox", { name: "Content", exact: true }).fill(content); - await page.getByRole("button", { name: "Save to library" }).click(); + await page.getByRole("button", { name: "Save to Library" }).click(); await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked(); } diff --git a/e2e/full-stack/case-generation.spec.ts b/e2e/full-stack/case-generation.spec.ts index 6b03bfc9..d04c4abe 100644 --- a/e2e/full-stack/case-generation.spec.ts +++ b/e2e/full-stack/case-generation.spec.ts @@ -15,7 +15,7 @@ test("a signed-in user generates a case grounded in a saved Library source", asy // exceeded the Edge Function's 200 KB body limit before sources loaded server-side. await page.getByRole("textbox", { name: "Content", exact: true }) .fill(`${marker} Tallinn ridership rose 14 percent after fares were removed. ${"Detailed ridership evidence. ".repeat(9_000)}`); - await page.getByRole("button", { name: "Save to library" }).click(); + await page.getByRole("button", { name: "Save to Library" }).click(); await expect(page.getByText("Grounding active")).toBeVisible(); const userId = await user.userId(); diff --git a/e2e/full-stack/current-debate.spec.ts b/e2e/full-stack/current-debate.spec.ts index eddefb31..a3defe3b 100644 --- a/e2e/full-stack/current-debate.spec.ts +++ b/e2e/full-stack/current-debate.spec.ts @@ -20,7 +20,7 @@ async function addSource(page: Page, title: string, content: string) { await page.getByRole("button", { name: "Add source" }).click(); await page.getByRole("textbox", { name: "Title", exact: true }).fill(title); await page.getByRole("textbox", { name: "Content", exact: true }).fill(content); - await page.getByRole("button", { name: "Save to library" }).click(); + await page.getByRole("button", { name: "Save to Library" }).click(); await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked(); } diff --git a/e2e/full-stack/debate-context.spec.ts b/e2e/full-stack/debate-context.spec.ts index 629f5112..6f0f186e 100644 --- a/e2e/full-stack/debate-context.spec.ts +++ b/e2e/full-stack/debate-context.spec.ts @@ -20,7 +20,7 @@ async function addSource(page: Page, title: string, content: string) { await page.getByRole("button", { name: "Add source" }).click(); await page.getByRole("textbox", { name: "Title", exact: true }).fill(title); await page.getByRole("textbox", { name: "Content", exact: true }).fill(content); - await page.getByRole("button", { name: "Save to library" }).click(); + await page.getByRole("button", { name: "Save to Library" }).click(); await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked(); } diff --git a/e2e/staging/authed-flows.spec.ts b/e2e/staging/authed-flows.spec.ts index 6c1a7855..0dd7c9a8 100644 --- a/e2e/staging/authed-flows.spec.ts +++ b/e2e/staging/authed-flows.spec.ts @@ -120,7 +120,7 @@ test('real staging session persists, starts a debate, writes and removes a sourc const sourceInsert = page.waitForResponse((response) => response.request().method() === 'POST' && response.url().includes('/rest/v1/sources') ); - await page.getByRole('button', { name: 'Save to library' }).click(); + await page.getByRole('button', { name: 'Save to Library' }).click(); expect((await sourceInsert).ok()).toBe(true); sourceCreated = true; await expect(page.getByText(sourceTitle, { exact: true })).toBeVisible(); diff --git a/src/components/features/debate/DebateContextControl.tsx b/src/components/features/debate/DebateContextControl.tsx index 3d8deec5..00605ec9 100644 --- a/src/components/features/debate/DebateContextControl.tsx +++ b/src/components/features/debate/DebateContextControl.tsx @@ -174,10 +174,10 @@ function DebateContextReview({ plan, motion, usage, pinsReady, resultsComplete } <p className="text-sm text-muted-foreground"> {plan.feature === 'sparring' ? t('debateContext.sparringNoSources', 'Live sparring rounds use only the transcript, the motion, and your debate settings.') - : t('debateContext.sourcesOff', 'Source library is off for this request.')} + : t('debateContext.sourcesOff', 'Source Library is off for this request.')} </p> ) : plan.sources.length === 0 ? ( - <p className="text-sm text-muted-foreground">{t('debateContext.noSources', 'This debate has no library sources yet.')}</p> + <p className="text-sm text-muted-foreground">{t('debateContext.noSources', 'This debate has no Library sources yet.')}</p> ) : ( <ul className="space-y-1"> {plan.sources.map((source) => ( @@ -231,7 +231,7 @@ function statusNote(item: { overLimit: boolean; omittedForSize: boolean; shorten function SourceRow({ source, onChange }: { source: ContextSourceItem; onChange: (include: boolean) => void }) { const { t } = useTranslation(); const id = `debate-context-source-${source.id}`; - const note = source.included ? statusNote(source, t) : (!source.active ? t('debateContext.inactive', 'Turned off in your library.') : null); + const note = source.included ? statusNote(source, t) : (!source.active ? t('debateContext.inactive', 'Turned off in your Library.') : null); return ( <li className="flex items-start gap-3 rounded-md px-1 py-1.5 pointer-coarse:min-h-11"> <Checkbox id={id} checked={source.included} onCheckedChange={(checked) => onChange(checked === true)} className="mt-0.5 pointer-coarse:after:-top-2" /> diff --git a/src/lib/ai/sources.test.ts b/src/lib/ai/sources.test.ts index 311a49d8..3b967142 100644 --- a/src/lib/ai/sources.test.ts +++ b/src/lib/ai/sources.test.ts @@ -105,6 +105,6 @@ describe('autosaveDiscoveredSources', () => { ], 'attack', DEBATE); expect(toast).toHaveBeenCalledOnce(); - expect(vi.mocked(toast).mock.calls[0][0]).toMatchObject({ title: 'Added 2 sources to your library' }); + expect(vi.mocked(toast).mock.calls[0][0]).toMatchObject({ title: 'Added 2 sources to your Library' }); }); }); diff --git a/src/locales/en.json b/src/locales/en.json index 3c80a64b..ddeb8782 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -257,8 +257,8 @@ "sourceSaveFailed": "Source save failed", "sourceSaveFailedDesc": "Could not save “{{title}}” to your library.", "sourceAssessmentSaveFailed": "The source ranking could not be saved.", - "sourcesAutoSaved_one": "Added {{count}} source to your library", - "sourcesAutoSaved_other": "Added {{count}} sources to your library", + "sourcesAutoSaved_one": "Added {{count}} source to your Library", + "sourcesAutoSaved_other": "Added {{count}} sources to your Library", "sourcesAutoSavedDesc": "Research found them. They are in the Auto-saved folder, and the ones switched on ground your next requests." }, "generatedArtifacts": { @@ -409,7 +409,7 @@ "emptyTitle": "Your evidence will appear here", "emptyDesc": "Enter a claim to find cited evidence.", "failed": "Research failed", - "savedToLibrary": "Saved to library", + "savedToLibrary": "Saved to Library", "savedToLibraryDesc": "This evidence is now available for grounding.", "saveFailed": "Could not save source.", "working": "Searching for evidence…" @@ -949,7 +949,7 @@ "crossfireDesc": "Practice focused questions and answers in a timed exchange.", "sparring": "Sparring", "sparringDesc": "Practice a timed round against an opponent and review the scorecard.", - "library": "Source library", + "library": "Source Library", "libraryDesc": "Manage sources that ground AI responses in real evidence.", "timer": "Debate timer", "timerDesc": "Practice with tournament-accurate timing controls.", @@ -1358,7 +1358,7 @@ "viewSource": "View source", "urlNeedsVerification": "URL requires verification", "howToUse": "How to use:", - "saveToLibrary": "Save to library", + "saveToLibrary": "Save to Library", "saveClaimToLibrary": "Save {{claim}} to library", "discuss": "Discuss evidence", "exportedDocx": "Factoids exported to DOCX!", @@ -1404,7 +1404,7 @@ "placeholder": "Ask your debate coach…", "inputLabel": "Message to your debate coach", "placeholderListening": "Listening...", - "saveToLibrary": "Save to library", + "saveToLibrary": "Save to Library", "saveMessage": "Save message to library", "savedChats": "Saved chats", "savedChatsDesc": "Resume prior coaching conversations.", @@ -1755,7 +1755,7 @@ "openCrossfireAria": "Open Crossfire round: {{title}}" }, "libraryPage": { - "title": "Source library", + "title": "Source Library", "addSource": "Add source", "cancel": "Cancel", "clearAll": "Clear all", @@ -1797,9 +1797,9 @@ "duplicateSource": "Source already in library", "duplicateSourceDesc": "This URL or file is already saved.", "fileReadFailed": "Could not read this file.", - "saveToLibrary": "Save to library", + "saveToLibrary": "Save to Library", "addNewTitle": "Add a source", - "yourKBTitle": "Your sources", + "yourKBTitle": "Your Knowledge Base", "manualSource": "Manual source", "searchSources": "Search sources", "searchPlaceholder": "Search sources, folders, or content...", @@ -1857,8 +1857,8 @@ "offlineNotSaved": "You are offline. Reconnect and try again.", "toggleFailed": "Could not change this source", "toggleFailedDesc": "It is back to how it was. Try again.", - "sourceSaved": "Saved {{title}} to your library", - "duplicateUrlDesc": "This page is already in this debate's library.", + "sourceSaved": "Saved {{title}} to the Library", + "duplicateUrlDesc": "This page is already in this debate's Library.", "loadingSources": "Loading sources…", "folderUpdateFailed": "Could not change this folder", "sourceCount_one": "{{count}} source", @@ -2298,9 +2298,9 @@ "duration3m": "3 min", "duration5m": "5 min", "duration8m": "8 min", - "sourceLibraryLabel": "Use source library grounding", + "sourceLibraryLabel": "Use Source Library grounding", "sourceLibraryDesc": "Ground the AI's questions and answers in your active library sources.", - "sourceLibraryEmpty": "Your source library has no active sources yet.", + "sourceLibraryEmpty": "Your Source Library has no active sources yet.", "seedQuestionsLabel": "Opening questions (optional)", "start": "Start crossfire", "loading": "Loading crossfire…", @@ -2538,8 +2538,8 @@ "instructionsNotUsed": "Your debate settings are included. Free-form instructions are not used here, so they cannot change how this exchange is run.", "sourcesHeading": "Library sources", "sparringNoSources": "Live sparring rounds use only the transcript, the motion, and your debate settings.", - "sourcesOff": "Source library is off for this request.", - "noSources": "This debate has no library sources yet.", + "sourcesOff": "Source Library is off for this request.", + "noSources": "This debate has no Library sources yet.", "resultsHeading": "Saved results", "sparringNoResults": "Saved results stay out of live rounds so the opponent and judge respond only to what is said.", "noResults": "Nothing saved in this debate yet.", @@ -2548,7 +2548,7 @@ "tooLarge": "Too large to fit with the rest, so it will be left out.", "empty": "Nothing usable yet.", "shortened": "Shortened to fit.", - "inactive": "Turned off in your library.", + "inactive": "Turned off in your Library.", "pinned": "pinned", "pinAria": "Pin {{title}} for every request in this debate", "unpinAria": "Unpin {{title}}", diff --git a/src/locales/es.json b/src/locales/es.json index f4f4dae5..75597de7 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1797,7 +1797,7 @@ "fileReadFailed": "No se pudo leer este archivo.", "saveToLibrary": "Guardar en biblioteca", "addNewTitle": "Añadir una fuente", - "yourKBTitle": "Tus fuentes", + "yourKBTitle": "Tu base de conocimiento", "titleLabel": "Título", "originLabel": "Origen", "contentLabel": "Contenido", @@ -1857,7 +1857,7 @@ "offlineNotSaved": "Estás sin conexión. Vuelve a conectarte e inténtalo de nuevo.", "toggleFailed": "No se pudo cambiar esta fuente", "toggleFailedDesc": "Volvió a como estaba. Inténtalo de nuevo.", - "sourceSaved": "{{title}} se guardó en tu biblioteca", + "sourceSaved": "{{title}} se guardó en la biblioteca", "duplicateUrlDesc": "Esta página ya está en la biblioteca de este debate.", "loadingSources": "Cargando fuentes…", "folderUpdateFailed": "No se pudo cambiar esta carpeta", diff --git a/src/locales/hu.json b/src/locales/hu.json index 40613971..d4fec10f 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -1797,7 +1797,7 @@ "fileReadFailed": "Nem sikerült beolvasni ezt a fájlt.", "saveToLibrary": "Mentés a könyvtárba", "addNewTitle": "Forrás hozzáadása", - "yourKBTitle": "A forrásaid", + "yourKBTitle": "A tudásbázisod", "titleLabel": "Cím", "originLabel": "Eredet", "contentLabel": "Tartalom", @@ -1857,7 +1857,7 @@ "offlineNotSaved": "Nincs internetkapcsolat. Csatlakozz újra, és próbáld meg ismét.", "toggleFailed": "Nem sikerült módosítani ezt a forrást", "toggleFailedDesc": "Visszaállt a korábbi állapotra. Próbáld újra.", - "sourceSaved": "A(z) {{title}} bekerült a könyvtáradba", + "sourceSaved": "A(z) {{title}} bekerült a könyvtárba", "duplicateUrlDesc": "Ez az oldal már szerepel ennek a vitának a könyvtárában.", "loadingSources": "Források betöltése…", "folderUpdateFailed": "Nem sikerült módosítani ezt a mappát", diff --git a/src/pages/LibraryPage.tsx b/src/pages/LibraryPage.tsx index 00764e1c..c4e246ff 100644 --- a/src/pages/LibraryPage.tsx +++ b/src/pages/LibraryPage.tsx @@ -291,7 +291,7 @@ export default function LibraryPage() { }); }; const showSaved = (title: string) => { - toast({ title: t('libraryPage.sourceSaved', { title, defaultValue: 'Saved {{title}} to your library' }) }); + toast({ title: t('libraryPage.sourceSaved', { title, defaultValue: 'Saved {{title}} to the Library' }) }); }; const handleAdd = async () => { From cd7244f492a2777fcfaa5968c36633a5d388fdf6 Mon Sep 17 00:00:00 2001 From: Adam <adam.gaborjani.szabo@gmail.com> Date: Tue, 27 Oct 2026 09:13:59 +0000 Subject: [PATCH 10/12] Timer chips wrap between words and match in height With every chip in view, long speech codes such as "A Rebuttal" no longer fit on one line at 390 px. They now wrap between words instead of truncating, the side dot moves next to the time so the code has the full width, and chips in a row share one height. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/components/free-tools/DebateTimer.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/free-tools/DebateTimer.tsx b/src/components/free-tools/DebateTimer.tsx index da63a47a..ab33b071 100644 --- a/src/components/free-tools/DebateTimer.tsx +++ b/src/components/free-tools/DebateTimer.tsx @@ -268,15 +268,16 @@ export function DebateTimer({ storageKey, embedded = false, onFirstStart, footer aria-current={index === round.segmentIndex ? 'step' : undefined} title={entry.label} className={cn( - 'flex min-h-11 w-full flex-col items-center justify-center rounded-md border px-1.5 py-1 text-sm leading-tight transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring', + 'flex h-full min-h-11 w-full flex-col items-center justify-center rounded-md border px-2 py-1 text-[0.8125rem] leading-tight sm:text-sm transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring', index === round.segmentIndex ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-background hover:bg-muted', )} > - <span className="flex max-w-full items-center gap-1.5"> + {/* The code gets the full width, so a long one ("A Rebuttal") wraps between words; the side dot sits by the time. */} + <span className="max-w-full text-balance break-words font-medium">{entry.short}</span> + <span className={cn('flex items-center gap-1 text-xs tabular-nums', index === round.segmentIndex ? 'text-primary-foreground/80' : 'text-muted-foreground')}> <span className={cn('h-2 w-2 shrink-0 rounded-full', SIDE_TONE[entry.side])} aria-hidden="true" /> - <span className="truncate font-medium">{entry.short}</span> + {formatDuration(entry.seconds)} </span> - <span className={cn('text-xs tabular-nums', index === round.segmentIndex ? 'text-primary-foreground/80' : 'text-muted-foreground')}>{formatDuration(entry.seconds)}</span> </button> </li> ))} From 00bc9cb5a2b0c1f5dca4fd434db181b2251eb81b Mon Sep 17 00:00:00 2001 From: Adam <adam.gaborjani.szabo@gmail.com> Date: Tue, 27 Oct 2026 09:19:45 +0000 Subject: [PATCH 11/12] Tour specs expect the welcome without its emoji and ! Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../skills/verify-debatelab/features/auth-demo-login.md | 2 +- .claude/skills/verify-debatelab/recipes/auth.spec.ts | 2 +- .../verify-debatelab/recipes/current-debate.spec.ts | 2 +- e2e/first-run.spec.ts | 8 ++++---- e2e/full-stack/first-run.spec.ts | 4 ++-- e2e/hungarian-parity.spec.ts | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.claude/skills/verify-debatelab/features/auth-demo-login.md b/.claude/skills/verify-debatelab/features/auth-demo-login.md index 5fc35d21..0815c9a3 100644 --- a/.claude/skills/verify-debatelab/features/auth-demo-login.md +++ b/.claude/skills/verify-debatelab/features/auth-demo-login.md @@ -38,7 +38,7 @@ Preconditions: ## Gotchas -- A first sign-in on a new account starts the onboarding tour on `/app`. Its first step (`Welcome to DebateLab!`, `Start your first debate here.`) offers `Start debate`. The fixtures pre-seed `debate-trainer-onboarding` to skip it. The tour is tracked per account (store version 1, `completedUserIds`). A version 0 seed with `hasCompletedOnboarding: true` covers only the next account that signs in, so a second account in the same browser sees the tour. Interactive runs click `Skip tour` (`[data-test-id="button-skip"]`). The tour itself is covered in [current-debate](./current-debate.md). +- A first sign-in on a new account starts the onboarding tour on `/app`. Its first step (`Welcome to DebateLab`, `Start your first debate here.`) offers `Start debate`. The fixtures pre-seed `debate-trainer-onboarding` to skip it. The tour is tracked per account (store version 1, `completedUserIds`). A version 0 seed with `hasCompletedOnboarding: true` covers only the next account that signs in, so a second account in the same browser sees the tour. Interactive runs click `Skip tour` (`[data-test-id="button-skip"]`). The tour itself is covered in [current-debate](./current-debate.md). - Sessions are stored under the `debate-auth-local` localStorage key in local-preview mode. Don't reuse stub-session helpers (`e2e/helpers/auth.ts`). They fake auth and prove rendering only. - Google sign-in is not wired to the local stack. Demo codes, email codes and the email link work here. - The revoked-session check forces one token refresh before it signs out. An Auth outage (network error, 5xx, 429) keeps the session, and that path is covered by `src/test/auth/session-expiry.test.ts`, not by a recipe. diff --git a/.claude/skills/verify-debatelab/recipes/auth.spec.ts b/.claude/skills/verify-debatelab/recipes/auth.spec.ts index 70977576..f5dfcd3f 100644 --- a/.claude/skills/verify-debatelab/recipes/auth.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/auth.spec.ts @@ -230,7 +230,7 @@ test("auth-switch-account: the next account on this browser starts on /app, gets await evidence.step("Signed in as a second account; landed on /app, not /app/settings"); // AUTH-07: the second account gets its own tour. - const tour = page.getByText("Welcome to DebateLab!"); + const tour = page.getByText("Welcome to DebateLab"); await expect(tour).toBeVisible({ timeout: 10_000 }); await evidence.shot("01-second-account-tour"); if (await page.getByRole("button", { name: "Open menu" }).isVisible()) { diff --git a/.claude/skills/verify-debatelab/recipes/current-debate.spec.ts b/.claude/skills/verify-debatelab/recipes/current-debate.spec.ts index 2ad52b5d..2bf4b8a8 100644 --- a/.claude/skills/verify-debatelab/recipes/current-debate.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/current-debate.spec.ts @@ -198,7 +198,7 @@ test("debate-tour: a brand-new account starts its first debate from the onboardi const tooltip = page.locator(".react-joyride__tooltip"); const next = page.locator('[data-test-id="button-primary"]'); - await expect(tooltip).toContainText("Welcome to DebateLab!", { timeout: 15_000 }); + await expect(tooltip).toContainText("Welcome to DebateLab", { timeout: 15_000 }); await expect(tooltip).toContainText("Start your first debate here."); await evidence.shot("01-tour-first-step"); diff --git a/e2e/first-run.spec.ts b/e2e/first-run.spec.ts index f4b4e625..da4be56e 100644 --- a/e2e/first-run.spec.ts +++ b/e2e/first-run.spec.ts @@ -8,13 +8,13 @@ const USER_ID = '00000000-0000-0000-0000-000000000001'; const copy = { en: { - welcome: 'Welcome to DebateLab!', + welcome: 'Welcome to DebateLab', firstDebate: 'Start your first debate here.', startDebate: 'Start debate', motionField: 'Motion', library: 'Library', generate: 'Generate', - ready: "You're Ready!", + ready: "You're ready", finish: 'Get started', generateMotion: 'Motion / topic *', generateSubmit: 'Generate debate case', @@ -24,13 +24,13 @@ const copy = { gateMotion: 'This House would abolish zoos', }, hu: { - welcome: 'Üdvözlünk a DebateLabban!', + welcome: 'Üdvözlünk a DebateLabban', firstDebate: 'Itt indíthatod el az első vitádat.', startDebate: 'Vita indítása', motionField: 'Tétel', library: 'Forráskönyvtár', generate: 'Generálás', - ready: 'Készen állsz!', + ready: 'Készen állsz', finish: 'Kezdjük', generateMotion: 'Indítvány / téma *', generateSubmit: 'Vitaeset generálása', diff --git a/e2e/full-stack/first-run.spec.ts b/e2e/full-stack/first-run.spec.ts index b2376b76..ea8a3736 100644 --- a/e2e/full-stack/first-run.spec.ts +++ b/e2e/full-stack/first-run.spec.ts @@ -10,7 +10,7 @@ const copy = { demoTab: "Demo code", demoCode: "Demo code", signIn: "Sign in with demo code", - welcome: "Welcome to DebateLab!", + welcome: "Welcome to DebateLab", firstDebate: "Start your first debate here.", startDebate: "Start debate", motionField: "Motion", @@ -29,7 +29,7 @@ const copy = { demoTab: "Demókód", demoCode: "Demókód", signIn: "Bejelentkezés demókóddal", - welcome: "Üdvözlünk a DebateLabban!", + welcome: "Üdvözlünk a DebateLabban", firstDebate: "Itt indíthatod el az első vitádat.", startDebate: "Vita indítása", motionField: "Tétel", diff --git a/e2e/hungarian-parity.spec.ts b/e2e/hungarian-parity.spec.ts index 2b365006..15ee5251 100644 --- a/e2e/hungarian-parity.spec.ts +++ b/e2e/hungarian-parity.spec.ts @@ -79,7 +79,7 @@ test.describe('Hungarian parity', () => { await expectHungarianDocument(page); await expect(page.getByRole('heading', { name: 'Felkészülés' })).toBeVisible({ timeout: 15_000 }); - await expect(page.locator('.react-joyride__tooltip')).toContainText('Üdvözlünk a DebateLabban!', { timeout: 15_000 }); + await expect(page.locator('.react-joyride__tooltip')).toContainText('Üdvözlünk a DebateLabban', { timeout: 15_000 }); await expect(page.locator('[data-test-id="button-primary"]')).toContainText('Következő'); await expect(page.locator('[data-test-id="button-skip"]')).toContainText('Túra kihagyása'); await page.locator('[data-test-id="button-skip"]').click(); From 04e2bc5acab5141f43d4dff2cf35cdcac2b8a25d Mon Sep 17 00:00:00 2001 From: Adam <adam.gaborjani.szabo@gmail.com> Date: Tue, 27 Oct 2026 09:53:19 +0000 Subject: [PATCH 12/12] Equal-height timer tiles; hero stat labels fit two lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Timer: the speech grid uses equal rows (auto-rows-fr) and each tile stretches through a flex item instead of a percentage height, so a row with a wrapped name ("A Rebuttal", "B Summary") no longer has tiles of different heights. Every tile is at least 44 px. - Landing hero: on phones the stat labels drop their extra letter-spacing, the cards use tighter side padding, and below 380 px the label steps down to 10 px, with hyphenation where the browser has the language. "PREPARACIÓN DIARIA CON IA" and the Hungarian trial label fit two lines at 360 to 414 px. The copy is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/components/free-tools/DebateTimer.tsx | 8 ++++---- src/components/landing/Hero.tsx | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/components/free-tools/DebateTimer.tsx b/src/components/free-tools/DebateTimer.tsx index ab33b071..c637eb4c 100644 --- a/src/components/free-tools/DebateTimer.tsx +++ b/src/components/free-tools/DebateTimer.tsx @@ -255,20 +255,20 @@ export function DebateTimer({ storageKey, embedded = false, onFirstStart, footer )} <nav aria-label={t('timer.speeches')} className="px-4 pt-4"> - {/* Every speech in view, in rows of equal length: 8 speeches are 4 + 4 on a phone and one row from md. */} + {/* Every speech in view, in rows of equal length (8 speeches are 4 + 4 on a phone, one row from md), every tile as tall as the tallest. */} <ol - className="grid grid-cols-[repeat(var(--chips),minmax(0,1fr))] gap-2 pb-2 sm:grid-cols-[repeat(var(--chips-sm),minmax(0,1fr))] md:grid-cols-[repeat(var(--chips-md),minmax(0,1fr))]" + className="grid auto-rows-fr grid-cols-[repeat(var(--chips),minmax(0,1fr))] gap-2 pb-2 sm:grid-cols-[repeat(var(--chips-sm),minmax(0,1fr))] md:grid-cols-[repeat(var(--chips-md),minmax(0,1fr))]" style={chipColumns(format.segments.length)} > {format.segments.map((entry, index) => ( - <li key={entry.id} className="min-w-0"> + <li key={entry.id} className="flex min-w-0"> <button type="button" onClick={() => select(index)} aria-current={index === round.segmentIndex ? 'step' : undefined} title={entry.label} className={cn( - 'flex h-full min-h-11 w-full flex-col items-center justify-center rounded-md border px-2 py-1 text-[0.8125rem] leading-tight sm:text-sm transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring', + 'flex min-h-11 w-full flex-col items-center justify-center rounded-md border px-2 py-1 text-[0.8125rem] leading-tight sm:text-sm transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring', index === round.segmentIndex ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-background hover:bg-muted', )} > diff --git a/src/components/landing/Hero.tsx b/src/components/landing/Hero.tsx index d0d5ccbd..c10028cc 100644 --- a/src/components/landing/Hero.tsx +++ b/src/components/landing/Hero.tsx @@ -73,8 +73,9 @@ const Hero = () => { {/* Three across at every width, so on a phone they take one short row under the buttons. */} <Reveal onLoad delay={0.15} className="mt-8 grid grid-cols-3 gap-2 sm:gap-3"> {planHighlights.map((item) => ( - <div key={item.label} className="rounded-lg border-2 border-border bg-card p-3 sm:p-4"> - <p className="text-balance text-[0.6875rem] font-extrabold uppercase leading-4 tracking-[0.04em] text-muted-foreground sm:text-xs sm:tracking-[0.08em]">{item.label}</p> + <div key={item.label} className="rounded-lg border-2 border-border bg-card px-2.5 py-3 max-[379px]:px-2 sm:p-4"> + {/* On phones no extra tracking, so long Spanish and Hungarian labels take two lines at most. */} + <p className="text-balance text-[0.6875rem] font-extrabold uppercase leading-4 tracking-normal text-muted-foreground hyphens-auto max-[379px]:text-[0.625rem] sm:text-xs sm:tracking-[0.08em] sm:hyphens-manual">{item.label}</p> <p className="mt-1 text-balance font-display text-base font-[600] text-heading sm:text-lg">{item.value}</p> </div> ))}