diff --git a/.claude/skills/verify-debatelab/SKILL.md b/.claude/skills/verify-debatelab/SKILL.md index b56f7f983..4858f5d08 100644 --- a/.claude/skills/verify-debatelab/SKILL.md +++ b/.claude/skills/verify-debatelab/SKILL.md @@ -83,12 +83,13 @@ It also prints the commit the stack started at and whether there are uncommitted - `admin`: a service-role client for reading rows as a second view. - `fake` / `providerCall(feature)`: what the Edge Function actually sent upstream. Use `fake.fail(feature, status)` to force a provider error and `fake.truncate(feature)` (fake mode only) to cut the next reply off at its length cap. Each call records the `maxTokens` it asked for. - `aiResponse(page, feature, operation?)`: the parsed body of the next `ai` Edge Function response. Pages ask for live generation, so most `ai` responses are event streams; `aiResponse` and `readAIResponse(response)` read the final `result` event (or a plain JSON body) for you, so never call `response.json()` on an `ai` response. Assert that its fields reach the screen, never on model wording, so a recipe passes in both modes. `LIVE` and `AI_TIMEOUT` (30 s fake, 240 s live) let a recipe branch where a check only makes sense live, and a fake-mode branch adds an `unverified` annotation instead of passing silently. +- `followSystemTheme(page)`: new accounts start on the light theme. A recipe that compares light and dark calls this first; it picks System in Settings, so `page.emulateMedia({ colorScheme })` then drives the theme. - `expectStreamed([...])`: every provider call for those features asked the model to stream, which proves the page showed the answer live. - `expectProviderCalls([...])`: what reached the provider. Each test writes `provider-calls.json`, and in live mode the fixture fails the test if any chat call was answered by a model other than the pin. - `evidence`: `step(line)`, `aria(name, locator?)`, `shot(name)` and `json(name, value)`. - Automatic failure on any uncaught page error, horizontal overflow, or remote Supabase request. -Every result, source and practice round belongs to a Current Debate or is explicitly one-off. A tool given a motion when no debate exists opens `Start a debate with this motion?`; a motion that differs from the Current Debate's opens `This is a different motion`. Both open after the submit click and before the AI request, so create the `aiResponse()` promise, click, answer with `chooseForMotion`, then await. Or call `startDebate` first, after which tools prefill its motion and open no dialog. The Library's `Add Source` is disabled until a debate exists. See `features/current-debate.md`. +Every result, source and practice round belongs to a Current Debate or is explicitly one-off. A tool given a motion when no debate exists opens `Start a debate with this motion?`; a motion that differs from the Current Debate's opens `This is a different motion`. Both open after the submit click and before the AI request, so create the `aiResponse()` promise, click, answer with `chooseForMotion`, then await. Or call `startDebate` first, after which tools prefill its motion and open no dialog. The Library's `Add source` is disabled until a debate exists. See `features/current-debate.md`. ```bash $H drive all # every recipe, both viewports @@ -105,9 +106,9 @@ Fake mode also runs `e2e/full-stack/`. Live mode runs the recipes, `e2e/live/`, To prove a feature without a recipe, copy `recipes/case-generation.spec.ts` to `recipes/.spec.ts` and use the selectors from the matching file in `features/`. Recipes are maintained files. Keep the ones worth keeping. -**Interactive.** Run `$H user` to mint a code. It prints `DEMO_CODE=...`, and `down` deletes that user. Then point a browser tool (Playwright MCP `browser_navigate` etc.) at `http://127.0.0.1:18081/login`, choose the `Demo code` tab, fill the `Demo code` textbox, and click `Sign in with demo code`. Save your own screenshots into `.verify/artifacts//`. A new account shows the onboarding tour on `/app`. Its first step offers `Start debate`; to skip the tour, click `Skip Tour` (`[data-test-id="button-skip"]`). Then start a debate from the header (`Start debate`, test id `current-debate-desktop` or `current-debate-mobile`) or answer the motion dialog the first tool opens: `Start debate` files the work in a new debate, `Not now, use it once` keeps it one-off. Interactive runs have no remote-Supabase guard. Doctor's local-mode check is what protects you. +**Interactive.** Run `$H user` to mint a code. It prints `DEMO_CODE=...`, and `down` deletes that user. Then point a browser tool (Playwright MCP `browser_navigate` etc.) at `http://127.0.0.1:18081/login`, choose the `Demo code` tab, fill the `Demo code` textbox, and click `Sign in with demo code`. Save your own screenshots into `.verify/artifacts//`. The one-time DebateOwl welcome never opens on this stack (`VITE_LOCAL_PREVIEW`) or under Playwright (`VITE_E2E`); open `/app?intro=show` to see it, and close it with `Got it` or Esc. A new account shows the onboarding tour on `/app`. Its first step offers `Start debate`; to skip the tour, click `Skip tour` (`[data-test-id="button-skip"]`). Then start a debate from the header (`Start debate`, test id `current-debate-desktop` or `current-debate-mobile`) or answer the motion dialog the first tool opens: `Start debate` files the work in a new debate, `Not now, use it once` keeps it one-off. Interactive runs have no remote-Supabase guard. Doctor's local-mode check is what protects you. -Selectors: use accessible names and roles as the feature files list them (`getByRole("button", { name: "Generate Debate Case" })`). The exceptions use `data-testid`: Crossfire's controls, the Current Debate control, strip and dashboard, and the voice controls (`voice-mode`, `voice-mic`). Use `exact: true` for short names such as `Start` or `Save`, because the header's `Start debate` also matches `Start`. Never use coordinates. +Selectors: use accessible names and roles as the feature files list them (`getByRole("button", { name: "Generate debate case" })`). The exceptions use `data-testid`: Crossfire's controls, the Current Debate control, strip and dashboard, and the voice controls (`voice-mode`, `voice-mic`). Use `exact: true` for short names such as `Start` or `Save`, because the header's `Start debate` also matches `Start`. Never use coordinates. ## Agentic tests and exploration diff --git a/.claude/skills/verify-debatelab/features/README.md b/.claude/skills/verify-debatelab/features/README.md index 89f8f6fcb..3bcb4d850 100644 --- a/.claude/skills/verify-debatelab/features/README.md +++ b/.claude/skills/verify-debatelab/features/README.md @@ -7,7 +7,7 @@ This directory is the maintained source for verifying DebateLab's user-facing be - `bin/verify.sh up` has printed `READY http://127.0.0.1:18081`, and `bin/verify.sh doctor` exits 0. - The browser client is the `up` instance at `http://127.0.0.1:18081`. Never use the `debatelab` container on `:8080` or a plain `npm run dev`, because both reach production. - Each scripted test signs in with its own fresh demo-code user (a Pro account with a daily limit of 999) and deletes it afterwards. The fixture marks the onboarding tour as seen, and the user starts with no Current Debate. -- Most tools work inside a Current Debate (see [current-debate](./current-debate.md)). Start one with `startDebate(page, motion)` first, or answer the motion dialog that opens after submit with `chooseForMotion(page, "create" | "once")`. The Library's `Add Source` stays disabled until a debate exists. +- Most tools work inside a Current Debate (see [current-debate](./current-debate.md)). Start one with `startDebate(page, motion)` first, or answer the motion dialog that opens after submit with `chooseForMotion(page, "create" | "once")`. The Library's `Add source` stays disabled until a debate exists. - In fake mode, model answers come from the fake provider and contain the marker `FAKE-PROVIDER`. In live mode (`up --live`), every chat call goes to real OpenRouter pinned to `anthropic/claude-haiku-5.5` on the Anthropic BYOK key, research uses real Exa, and answers are real model text. - The feature files quote fake-mode text such as `FAKE-PROVIDER case thesis`. Recipes never assert it: they read the `ai` response body with `aiResponse()` and check that its fields are on screen, so the same recipe passes in both modes. @@ -49,7 +49,7 @@ Each feature file starts with an H1 title and one paragraph describing the user- - [Practice](./practice.md): the Sparring setup's whole-round cost and Start's pending state, a full Lincoln-Douglas sparring round with verdict (and its Hungarian chart labels), and Crossfire in both directions with debrief, each filed in its debate (`recipes/practice.spec.ts`). - [Voice rounds](./voice-rounds.md): spoken Sparring and Crossfire on the browser voice, with no ElevenLabs key (`recipes/voice-rounds.spec.ts`). - [Workspace autosave](./workspace-autosave.md): a slow Crossfire or Sparring save from a Workspace never overwrites newer text (`recipes/workspace-autosave.spec.ts`). Needs `up --collab`; skips without it. -- [Account data](./account-data.md): Download my data and Delete account, each proven against a bystander (`recipes/account-data.spec.ts`); Delete All My Data scoped against a bystander (`e2e/full-stack/account-data.spec.ts`). +- [Account data](./account-data.md): Download my data and Delete account, each proven against a bystander (`recipes/account-data.spec.ts`); Delete all my data scoped against a bystander (`e2e/full-stack/account-data.spec.ts`). - Account: output language reaching the model from a new device, flowchart node delete (at once, with an `Undo` toast; no confirm dialog), save and load, and the speech timer (`R` resets only while paused) (`recipes/account.spec.ts`). - [Usage limits](./usage-limits.md): Opus refused when its weight does not fit today while Haiku still runs, and the weekly AI budget dialog (`recipes/usage-limits.spec.ts`). - Billing: expired account blocked, promo code restores access, and a discount code claimed once (a second Apply does not count again) stays in the pricing dialog and is sent with the upgrade (`recipes/billing.spec.ts`); each billing state (trial, extended access, active Pro, Pro set to cancel, promo Pro without Stripe, payment failed, subscription ended) shown the same way by Settings, the pricing dialog (its one plan button and Current badge) and a refused AI request, in English and Hungarian, light and dark, at 360 and 375 px (mobile project) and 768 and 1280 px (desktop project); the usage line on every tool page matching Settings and updating after a run; the trial banner; and `/app/billing` landing on the Settings subscription card (`recipes/billing-states.spec.ts`). The pricing dialog's Manage path stubs `create-portal` with its own no-subscription answer, since the portal needs Stripe; signed, forged, and replayed Stripe webhooks, including a failed renewal that keeps Pro (`e2e/full-stack/billing.spec.ts`). diff --git a/.claude/skills/verify-debatelab/features/account-data.md b/.claude/skills/verify-debatelab/features/account-data.md index c58a3e1ce..846bd68e3 100644 --- a/.claude/skills/verify-debatelab/features/account-data.md +++ b/.claude/skills/verify-debatelab/features/account-data.md @@ -1,16 +1,16 @@ # Account data: export and deletion -From Settings, a signed-in user can download everything saved to the account as one JSON file, or delete the account permanently. Both go through the `account` Edge Function, which acts only on the caller's own session and ignores any user id in the request. Deletion first checks Stripe for the user's customers and cancels any subscription, then removes every row that references the user. `Delete All My Data` is the older, narrower erase: it keeps the account and removes its saved work. +From Settings, a signed-in user can download everything saved to the account as one JSON file, or delete the account permanently. Both go through the `account` Edge Function, which acts only on the caller's own session and ignores any user id in the request. Deletion first checks Stripe for the user's customers and cancels any subscription, then removes every row that references the user. `Delete all my data` is the older, narrower erase: it keeps the account and removes its saved work. ## Sub-features - `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 Danger Zone 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 95c37da3e..155e4db3d 100644 --- a/.claude/skills/verify-debatelab/features/ai-tools.md +++ b/.claude/skills/verify-debatelab/features/ai-tools.md @@ -22,8 +22,8 @@ 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. +- **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. diff --git a/.claude/skills/verify-debatelab/features/auth-demo-login.md b/.claude/skills/verify-debatelab/features/auth-demo-login.md index 437abcdd0..5fc35d217 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/features/case-generation.md b/.claude/skills/verify-debatelab/features/case-generation.md index c2dfbdfea..d0faa56c3 100644 --- a/.claude/skills/verify-debatelab/features/case-generation.md +++ b/.claude/skills/verify-debatelab/features/case-generation.md @@ -25,7 +25,7 @@ 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. -- **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`. +- **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. @@ -39,7 +39,7 @@ Preconditions: ## 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. - `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. diff --git a/.claude/skills/verify-debatelab/features/current-debate.md b/.claude/skills/verify-debatelab/features/current-debate.md index 77128c8eb..286b11e52 100644 --- a/.claude/skills/verify-debatelab/features/current-debate.md +++ b/.claude/skills/verify-debatelab/features/current-debate.md @@ -32,13 +32,13 @@ 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`. -- The Library's `Add Source` button is disabled until a Current Debate exists. Start one first. +- 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. - Debate context review (the `debate-context-control` line and its `Review` sheet with pins) is covered by `e2e/full-stack/debate-context.spec.ts`, not by a recipe. diff --git a/.claude/skills/verify-debatelab/features/library-sources.md b/.claude/skills/verify-debatelab/features/library-sources.md index 8595436c9..c19210bee 100644 --- a/.claude/skills/verify-debatelab/features/library-sources.md +++ b/.claude/skills/verify-debatelab/features/library-sources.md @@ -14,20 +14,20 @@ Users save reference texts to their Library. The Library belongs to the Current - `lib-delete` deletes a source. It stays gone after a reload and in the `sources` table. - `lib-counts` the Current Debate summary on `/app` shows ` sources ( active)`, and Ask's `Using sources` counts the same active sources. - `lib-import` imports a web page by URL through the `import-source` Edge Function, which fetches the page itself (no Exa), so it works in both modes when the VPS has outbound internet. -- `lib-erase` has Settings → `Delete All My Data` remove this account's sources and nobody else's. +- `lib-erase` has Settings → `Delete all my data` remove this account's sources and nobody else's. ## How to get to it (user POV) - Desktop: `More` → `Library`. Mobile: `Open menu` → `Library`. Or go to `/app/library`. -- Settings (`/app/settings`) → `Delete All My Data`, for the erase path. +- Settings (`/app/settings`) → `Delete all my data`, for the erase path. ## Driving it with verify.sh + Playwright Preconditions: -- Doctor passes. The user is signed in (`signedIn` fixture). A Current Debate exists: `await startDebate(page, motion)`. Without one, `Add Source` stays disabled. +- 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 ` 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 5bc39bcc2..110fca620 100644 --- a/.claude/skills/verify-debatelab/features/live-generation.md +++ b/.claude/skills/verify-debatelab/features/live-generation.md @@ -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/recipes/account-data.spec.ts b/.claude/skills/verify-debatelab/recipes/account-data.spec.ts index 982f93e4f..0de92a80b 100644 --- a/.claude/skills/verify-debatelab/recipes/account-data.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/account-data.spec.ts @@ -5,13 +5,13 @@ import { admin, createDemoUser, deleteDemoUser, expect, fake, signIn, startDebat // Feature map: features/account-data.md. Mirrors e2e/full-stack/account-deletion.spec.ts. // Each test seeds a bystander account and proves the action touched only the -// recipe's own demo user. Delete All My Data is e2e/full-stack/account-data.spec.ts. +// recipe's own demo user. Delete all my data is e2e/full-stack/account-data.spec.ts. const accountUrl = `${process.env.LOCAL_SUPABASE_URL}/functions/v1/account`; async function addSource(page: Page, title: string, content: string) { await page.goto("/app/library"); - await page.getByRole("button", { name: "Add Source" }).click(); + 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(); diff --git a/.claude/skills/verify-debatelab/recipes/account.spec.ts b/.claude/skills/verify-debatelab/recipes/account.spec.ts index ae9c62616..ef5400afb 100644 --- a/.claude/skills/verify-debatelab/recipes/account.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/account.spec.ts @@ -23,7 +23,7 @@ test("acct-language: the output language follows the account to a new device and await other.goto("/app/attack"); 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(); + await other.getByRole("button", { name: "Find weaknesses" }).click(); const body = await response; await evidence.json("02-attack-response", body); const call = (await expectProviderCalls(["attack"])).find((item) => item.feature === "attack")!; diff --git a/.claude/skills/verify-debatelab/recipes/ai-tools.spec.ts b/.claude/skills/verify-debatelab/recipes/ai-tools.spec.ts index 8c98c2b07..42f4693ca 100644 --- a/.claude/skills/verify-debatelab/recipes/ai-tools.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/ai-tools.spec.ts @@ -1,4 +1,4 @@ -import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, expect, expectProviderCalls, fake, LIVE, providerCalls, test } from "./fixtures"; +import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, expect, expectProviderCalls, fake, followSystemTheme, LIVE, providerCalls, test } from "./fixtures"; // Feature map: features/ai-tools.md. @@ -6,9 +6,9 @@ test("attack-run: Attack analyses a pasted argument", async ({ signedIn: page, e 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 evidence.step("Attack: pasted an argument and clicked Find Weaknesses"); + await evidence.step("Attack: pasted an argument and clicked Find weaknesses"); const response = aiResponse(page, "attack"); - await page.getByRole("button", { name: "Find Weaknesses" }).click(); + await page.getByRole("button", { name: "Find weaknesses" }).click(); const body = await response; await evidence.json("01-attack-response", body); const weaknesses = body.output.weaknesses as Array<{ description: string }>; @@ -24,7 +24,7 @@ test("brainstorm-run: Brainstorm returns ranked angles", async ({ signedIn: page await page.goto("/app/brainstorm"); await page.getByLabel("Motion / Topic *").fill(motion); const response = aiResponse(page, "brainstorm"); - await page.getByRole("button", { name: "Start Brainstorming" }).click(); + await page.getByRole("button", { name: "Start brainstorming" }).click(); // A new account has no Current Debate, so Brainstorm first offers one for this motion. await chooseForMotion(page, "create"); await evidence.step(`Brainstorm: "${motion}", accepted the offer to start a debate for it`); @@ -124,6 +124,7 @@ async function textContrast(page: import("@playwright/test").Page, text: string) // Hungarian placeholder was cut off at 375 px. test("ask-bubble: the student's message meets WCAG AA in light and dark, and the placeholder fits at 375 px", async ({ signedIn: page, evidence }, testInfo) => { if (testInfo.project.name === "mobile-chromium") await page.setViewportSize({ width: 375, height: 812 }); + 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); diff --git a/.claude/skills/verify-debatelab/recipes/billing-states.spec.ts b/.claude/skills/verify-debatelab/recipes/billing-states.spec.ts index a47411de5..02cd236b2 100644 --- a/.claude/skills/verify-debatelab/recipes/billing-states.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/billing-states.spec.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { join } from "node:path"; import type { Locator, Page, TestInfo } from "@playwright/test"; -import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, expect, test, type Evidence } from "./fixtures"; +import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, expect, followSystemTheme, test, type Evidence } from "./fixtures"; // Feature map: the Billing entry in features/README.md. Each state is written // straight into user_settings and subscriptions (what the Stripe webhook @@ -29,7 +29,7 @@ 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 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 OK: Text = { en: "OK", hu: "Rendben" }; @@ -85,7 +85,7 @@ const STATES: State[] = [ settings: { subscription_tier: "pro", grace_period_ends_at: at(365) }, badge: { en: "Pro", hu: "Pro" }, text: { en: /comes from a promo code and runs until .+\. Billing is optional until then/, hu: /promóciós kódból származik \(lejárat: .+\)\. Addig a számlázás beállítása nem kötelező/ }, - button: { en: "Set Up Billing", hu: "Számlázás beállítása" }, + button: { en: "Set up billing", hu: "Számlázás beállítása" }, plan: "plan-pro", dailyLimit: 50, refusal: { title: PAUSED, button: OK }, @@ -152,7 +152,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.getByRole("button", { name: language === "en" ? "Start Brainstorming" : "Ötletelés indítása" }).click(); + 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)$/ }); await dialog.getByRole("button", { name: /^(Most nem, csak egyszer használom|Egyszeri használat vitába mentés nélkül)$/ }).click(); @@ -188,6 +188,7 @@ for (const language of ["en", "hu"] as const) { test(`bill-states-${language}: Settings, the pricing dialog and a refused request agree on each billing state`, async ({ signedIn: page, user, evidence }, testInfo) => { test.setTimeout(600_000); const userId = await user.userId(); + await followSystemTheme(page); if (language === "hu") await useHungarian(page); // The portal needs Stripe. Answer as it does for an account Stripe does // not know, which sends Settings to the pricing dialog: the one way a @@ -258,6 +259,7 @@ for (const language of ["en", "hu"] as const) { // VS-06: the Spanish card at phone widths too (the matrix above covers EN and HU). test("bill-states-es: the Spanish subscription card wraps instead of squeezing", async ({ signedIn: page, user, evidence }, testInfo) => { const userId = await user.userId(); + await followSystemTheme(page); await page.goto("/app/settings"); await page.getByLabel("Interface Language").click(); await page.getByRole("option", { name: "Español" }).click(); diff --git a/.claude/skills/verify-debatelab/recipes/billing.spec.ts b/.claude/skills/verify-debatelab/recipes/billing.spec.ts index ab11a3d45..99d8df5b4 100644 --- a/.claude/skills/verify-debatelab/recipes/billing.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/billing.spec.ts @@ -16,7 +16,7 @@ test("bill-promo: an expired account is blocked, redeems a promo code, and gets const brainstorm = async () => { await page.goto("/app/brainstorm"); await page.getByLabel("Motion / Topic *").fill("Public transport should be free"); - await page.getByRole("button", { name: "Start Brainstorming" }).click(); + 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"); }; diff --git a/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts b/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts index e09c0baf8..0d9aa3233 100644 --- a/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts @@ -20,7 +20,7 @@ async function openGenerate(page: import("@playwright/test").Page) { async function generate(page: import("@playwright/test").Page) { await openGenerate(page); const response = aiResponse(page, "generate"); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await page.getByRole("button", { name: "Generate debate case" }).click(); const body = await response; const output = body.output as unknown as CaseOutput; // Standard length asks for exactly three contentions. @@ -95,7 +95,7 @@ test("gen-save: a one-off case is saved under a title and reopens from History o await expect(page.getByRole("heading", { name: "Ready to Generate" })).toBeVisible(); await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); const response = aiResponse(page, "generate"); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await page.getByRole("button", { name: "Generate debate case" }).click(); // No Current Debate: decline the offer, so the case belongs to no debate until saved. await chooseForMotion(page, "once"); await evidence.step(`Generate: motion "${motion}", chose Not now, use it once`); @@ -221,12 +221,12 @@ test("gen-regenerate: regenerating one section keeps the rest of the case and it test("gen-error: a provider failure shows an error and the retry succeeds", async ({ signedIn: page, evidence }) => { await fake.fail("generate", 500); await openGenerate(page); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + 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" }); await expect(failure).toBeVisible({ timeout: AI_TIMEOUT }); await evidence.shot("01-failed"); - await expect(page.getByRole("button", { name: "Generate Debate Case" })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Generate debate case" })).toBeEnabled(); const response = aiResponse(page, "generate"); await failure.getByRole("button", { name: "Retry" }).click(); @@ -241,7 +241,7 @@ test("gen-cutoff: a case cut off at its length cap is retried once with a larger await fake.truncate("generate"); await openGenerate(page); const response = aiResponse(page, "generate"); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + 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); @@ -255,7 +255,7 @@ test("gen-cutoff: a case cut off at its length cap is retried once with a larger test("gen-quota: an account without quota is told why and the provider is never called", async ({ signedIn: page, user, evidence }) => { 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 page.getByRole("button", { name: "Generate debate case" }).click(); await expect(page.getByRole("dialog").getByText("Daily Access Paused")).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 fadeb463a..4d800217b 100644 --- a/.claude/skills/verify-debatelab/recipes/current-debate.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/current-debate.spec.ts @@ -13,7 +13,7 @@ const LIVE_AWARE_TIMEOUT = LIVE ? 900_000 : 150_000; async function addSource(page: Page, title: string, content: string) { await page.goto("/app/library"); - await page.getByRole("button", { name: "Add Source" }).click(); + 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(); @@ -59,7 +59,7 @@ test("debate-start, debate-scope, debate-once, debate-switch: two debates keep t 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(); + await page.getByRole("button", { name: "Start brainstorming" }).click(); const ideas = (await homeworkRun).output.topFive as Array<{ idea: { title: string } }>; await expect(page.getByText(ideas[0].idea.title).first()).toBeVisible(); await expect(page.getByRole("dialog")).toHaveCount(0); @@ -83,7 +83,7 @@ test("debate-start, debate-scope, debate-once, debate-switch: two debates keep t 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(); + await page.getByRole("button", { name: "Generate debate case" }).click(); const zooCase = (await zooRun).output as { caseStructure: { thesis: string } }; await expect(page.getByText(zooCase.caseStructure.thesis).first()).toBeVisible(); await expect.poll(async () => (await admin.from("cases").select("id").eq("user_id", userId)).data?.length).toBe(1); @@ -97,7 +97,7 @@ test("debate-start, debate-scope, debate-once, debate-switch: two debates keep t 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(); + await page.getByRole("button", { name: "Start brainstorming" }).click(); await expect(page.getByRole("dialog")).toContainText("This is a different motion"); await evidence.shot("04-different-motion-choice"); await evidence.aria("04-different-motion-choice", page.getByRole("dialog")); @@ -157,7 +157,7 @@ test("debate-offer: with no Current Debate, a tool offers to start one for the m await page.goto("/app/generate"); await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); const run = aiResponse(page, "generate"); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await page.getByRole("button", { name: "Generate debate case" }).click(); const offer = page.getByRole("dialog", { name: "Start a debate with this motion?" }); await expect(offer).toContainText(motion); @@ -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); @@ -229,7 +229,7 @@ test("debate-tour: a brand-new account starts its first debate from the onboardi await expect(page.getByTestId("current-debate-strip")).toContainText(`Working in: ${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 page.getByRole("button", { name: "Generate debate case" }).click(); await run; await expect(page.getByRole("dialog")).toHaveCount(0); await evidence.shot("02-first-case-in-tour-debate"); diff --git a/.claude/skills/verify-debatelab/recipes/fixtures.ts b/.claude/skills/verify-debatelab/recipes/fixtures.ts index e11c6411c..49bf24c38 100644 --- a/.claude/skills/verify-debatelab/recipes/fixtures.ts +++ b/.claude/skills/verify-debatelab/recipes/fixtures.ts @@ -70,6 +70,17 @@ export async function expectStreamed(features: string[]) { } } +/** + * New accounts start on the light theme. Recipes that compare light and dark + * switch the account to System in Settings first, so emulateMedia drives the theme. + */ +export async function followSystemTheme(page: Page) { + await page.goto("/app/settings"); + await page.locator("#settings-theme").click(); + await page.getByRole("option").nth(2).click(); + await expect(page.locator("#settings-theme")).toHaveText(/System|Rendszer|Sistema/); +} + /** The provider was called for each feature, and every call succeeded. */ export async function expectProviderCalls(features: string[]) { const calls = await providerCalls(); diff --git a/.claude/skills/verify-debatelab/recipes/library.spec.ts b/.claude/skills/verify-debatelab/recipes/library.spec.ts index 35b0cb361..6815367a9 100644 --- a/.claude/skills/verify-debatelab/recipes/library.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/library.spec.ts @@ -2,11 +2,11 @@ import type { Page } from "@playwright/test"; import { admin, AI_TIMEOUT, aiResponse, expect, expectProviderCalls, startDebate, test } from "./fixtures"; // Feature map: features/library-sources.md. The Library belongs to the Current -// Debate: `Add Source` stays disabled until one exists, so each test starts one. +// Debate: `Add source` stays disabled until one exists, so each test starts one. const motion = "Public transport should be free"; async function addSource(page: Page, title: string, content: string) { - await page.getByRole("button", { name: "Add Source" }).click(); + 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(); @@ -32,7 +32,7 @@ test("lib-add, lib-toggle, lib-ground, lib-delete: only active sources ground AI await page.goto("/app/brainstorm"); await expect(page.getByLabel("Motion / Topic *")).toHaveValue(motion); const response = aiResponse(page, "brainstorm"); - await page.getByRole("button", { name: "Start Brainstorming" }).click(); + await page.getByRole("button", { name: "Start brainstorming" }).click(); await response; const call = (await expectProviderCalls(["brainstorm"])).find((item) => item.feature === "brainstorm")!; expect(call.system).toContain("ACTIVE-SOURCE-MARKER"); @@ -73,7 +73,7 @@ test("lib-import: a web page imports into the Library", async ({ signedIn: page, const url = "https://en.wikipedia.org/wiki/Free_public_transport"; await startDebate(page, motion); await page.goto("/app/library"); - await page.getByRole("button", { name: "Add Source" }).click(); + await page.getByRole("button", { name: "Add source" }).click(); await page.getByRole("textbox", { name: "Import from URL" }).fill(url); await page.getByRole("button", { name: "Import URL" }).click(); await expect(page.getByText("Import preview", { exact: true })).toBeVisible({ timeout: AI_TIMEOUT }); @@ -182,7 +182,7 @@ test("lib-dedupe and lib-layout: a page saved once per debate, and long titles k // The same page with tracking and a fragment is refused before any fetch. let imported = false; page.on("request", (request) => { if (request.url().includes("/functions/v1/import-source")) imported = true; }); - await page.getByRole("button", { name: "Add Source" }).click(); + 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(); diff --git a/.claude/skills/verify-debatelab/recipes/live-generation.spec.ts b/.claude/skills/verify-debatelab/recipes/live-generation.spec.ts index f63093977..6affc1e96 100644 --- a/.claude/skills/verify-debatelab/recipes/live-generation.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/live-generation.spec.ts @@ -38,7 +38,7 @@ test("live-generate: a case fills in section by section while the model writes i await page.goto("/app/generate"); await expect(page.getByRole("textbox", { name: "Motion / Topic *" })).toHaveValue(motion); const request = aiRequest(page, "generate"); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + 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"); @@ -63,7 +63,7 @@ test("live-generate: a case fills in section by section while the model writes i test("live-cancel: Cancel stops a live run and leaves no draft or result", async ({ signedIn: page, evidence }) => { await startDebate(page, motion); await page.goto("/app/generate"); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await page.getByRole("button", { name: "Generate debate case" }).click(); const draft = page.getByTestId("live-draft"); await expect(draft).toBeVisible({ timeout: AI_TIMEOUT }); await draft.getByRole("button", { name: "Cancel" }).click(); @@ -79,13 +79,13 @@ test("live-tools: Attack, Brainstorm, Factoids, and cited Ask stream their answe await page.goto("/app/attack"); 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 page.getByRole("button", { name: "Find weaknesses" }).click(); await expectLiveStream(await request); await evidence.shot("01-attack"); await page.goto("/app/brainstorm"); request = aiRequest(page, "brainstorm"); - await page.getByRole("button", { name: "Start Brainstorming" }).click(); + await page.getByRole("button", { name: "Start brainstorming" }).click(); await expectLiveStream(await request); await evidence.shot("02-brainstorm"); diff --git a/.claude/skills/verify-debatelab/recipes/model-defaults.spec.ts b/.claude/skills/verify-debatelab/recipes/model-defaults.spec.ts index 4f0063c41..5f78a3255 100644 --- a/.claude/skills/verify-debatelab/recipes/model-defaults.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/model-defaults.spec.ts @@ -20,7 +20,7 @@ async function updateSettings(userId: string, values: Record<string, unknown>) { async function generateOnce(page: Page) { await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); const response = aiResponse(page, "generate"); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await page.getByRole("button", { name: "Generate debate case" }).click(); await chooseForMotion(page, "once"); return response; } diff --git a/.claude/skills/verify-debatelab/recipes/usage-limits.spec.ts b/.claude/skills/verify-debatelab/recipes/usage-limits.spec.ts index 7e8650fab..a2e9e8e87 100644 --- a/.claude/skills/verify-debatelab/recipes/usage-limits.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/usage-limits.spec.ts @@ -37,7 +37,7 @@ test("usage-weighted: Opus counts as 5 requests, a lighter model still runs, and await selector.click(); await page.getByRole("option", { name: /Claude Opus 5\.5/ }).click(); await expect(selector).toContainText("Claude Opus 5.5"); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + 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("This model counts as 5 uses, and you have 4 left today.", { exact: false })).toBeVisible(); @@ -52,7 +52,7 @@ test("usage-weighted: Opus counts as 5 requests, a lighter model still runs, and await selector.click(); await page.getByRole("option", { name: /Claude Haiku 5\.5/ }).click(); const response = aiResponse(page, "generate"); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await page.getByRole("button", { name: "Generate debate case" }).click(); await response; expect(await generateCalls()).toBe(1); expect(await aiCounter(userId)).toBe(4); @@ -64,7 +64,7 @@ test("usage-weighted: Opus counts as 5 requests, a lighter model still runs, and user_id: userId, feature: "generate", provider: "openrouter", model: "anthropic/claude-opus-5.5", status: "success", cost_usd: 2, cap_cost_usd: 2, }); if (spendError) throw spendError; - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + 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("It refreshes on Monday at 00:00 UTC.", { exact: false })).toBeVisible(); @@ -185,7 +185,7 @@ test("usage-refund-reload: reloading while Brainstorm streams costs nothing", as await fake.streamDelay(1_500); await page.goto("/app/brainstorm"); await page.getByLabel("Motion / Topic *").fill(motion); - await page.getByRole("button", { name: "Start Brainstorming" }).click(); + await page.getByRole("button", { name: "Start brainstorming" }).click(); await chooseForMotion(page, "create"); // The run is reserved and streaming: the counter shows it. await expect.poll(() => aiCounter(userId), { timeout: AI_TIMEOUT }).toBeGreaterThan(0); diff --git a/agentic/replay/126b78cf76461974e31c525473531fea764059779def23ffec087cecc00a0950.json b/agentic/replay/126b78cf76461974e31c525473531fea764059779def23ffec087cecc00a0950.json index 4bbcaa666..9ca170ecc 100644 --- a/agentic/replay/126b78cf76461974e31c525473531fea764059779def23ffec087cecc00a0950.json +++ b/agentic/replay/126b78cf76461974e31c525473531fea764059779def23ffec087cecc00a0950.json @@ -15,10 +15,10 @@ }, { "name": "tap", - "summary": "tap button \"Find Weaknesses\"", + "summary": "tap button \"Find weaknesses\"", "target": { "role": "button", - "name": "Find Weaknesses" + "name": "Find weaknesses" } } ], @@ -34,7 +34,7 @@ "callIndex": 0, "agent": "default" }, - "summary": "The argument was entered into the 'Your Argument' field and 'Find Weaknesses' was tapped. The screen now shows the full critique: an overall assessment (62/100), one major weakness (no comparative evidence for the congestion claim), detected fallacies (hasty generalization), cross-examination quest…", + "summary": "The argument was entered into the 'Your Argument' field and 'Find weaknesses' was tapped. The screen now shows the full critique: an overall assessment (62/100), one major weakness (no comparative evidence for the congestion claim), detected fallacies (hasty generalization), cross-examination quest…", "startPath": "/app/attack", "endPath": "/app/attack", "endAnchors": [ diff --git a/agentic/replay/23755b48030e2ba88ae7593c0e65506ecd8b0b7d98a4006cfda221eda5edb068.json b/agentic/replay/23755b48030e2ba88ae7593c0e65506ecd8b0b7d98a4006cfda221eda5edb068.json index bdc7cbbf2..4abe87714 100644 --- a/agentic/replay/23755b48030e2ba88ae7593c0e65506ecd8b0b7d98a4006cfda221eda5edb068.json +++ b/agentic/replay/23755b48030e2ba88ae7593c0e65506ecd8b0b7d98a4006cfda221eda5edb068.json @@ -45,10 +45,10 @@ }, { "name": "tap", - "summary": "tap button \"Generate Debate Case\"", + "summary": "tap button \"Generate debate case\"", "target": { "role": "button", - "name": "Generate Debate Case" + "name": "Generate debate case" } } ], diff --git a/agentic/replay/729490fb7619dcddf35c0c60cedc58d2bc05aff8f07aad051c9ef2cd457f9799.json b/agentic/replay/729490fb7619dcddf35c0c60cedc58d2bc05aff8f07aad051c9ef2cd457f9799.json index a996208a5..e58758516 100644 --- a/agentic/replay/729490fb7619dcddf35c0c60cedc58d2bc05aff8f07aad051c9ef2cd457f9799.json +++ b/agentic/replay/729490fb7619dcddf35c0c60cedc58d2bc05aff8f07aad051c9ef2cd457f9799.json @@ -45,10 +45,10 @@ }, { "name": "tap", - "summary": "tap button \"Generate Debate Case\"", + "summary": "tap button \"Generate debate case\"", "target": { "role": "button", - "name": "Generate Debate Case" + "name": "Generate debate case" } } ], diff --git a/agentic/replay/a7ab22475ff220f06530d1b208935a31518cb6002dad033bbe5549c162f9df6e.json b/agentic/replay/a7ab22475ff220f06530d1b208935a31518cb6002dad033bbe5549c162f9df6e.json index efb7b374b..21e936b24 100644 --- a/agentic/replay/a7ab22475ff220f06530d1b208935a31518cb6002dad033bbe5549c162f9df6e.json +++ b/agentic/replay/a7ab22475ff220f06530d1b208935a31518cb6002dad033bbe5549c162f9df6e.json @@ -53,10 +53,10 @@ }, { "name": "tap", - "summary": "tap button \"Get Started!\"", + "summary": "tap button \"Get started!\"", "target": { "role": "button", - "name": "Get Started!" + "name": "Get started!" } } ], @@ -72,7 +72,7 @@ "callIndex": 0, "agent": "default" }, - "summary": "Started a debate on “This House would make debate compulsory in secondary schools,” then advanced through the Library and Generate tour steps and selected “Get Started!” on the final step. The tour dialog closed, confirming completion.", + "summary": "Started a debate on “This House would make debate compulsory in secondary schools,” then advanced through the Library and Generate tour steps and selected “Get started!” on the final step. The tour dialog closed, confirming completion.", "startPath": "/app", "endPath": "/app", "endAnchors": [ @@ -120,7 +120,7 @@ }, { "role": "button", - "name": "Skip Tour" + "name": "Skip tour" }, { "role": "button", diff --git a/agentic/replay/c6cf102fb2172d14552e85bf19abb2d80a6075794717cc7c0c2cd0610bb9fe37.json b/agentic/replay/c6cf102fb2172d14552e85bf19abb2d80a6075794717cc7c0c2cd0610bb9fe37.json index fc0f7acb7..edf7bc8f7 100644 --- a/agentic/replay/c6cf102fb2172d14552e85bf19abb2d80a6075794717cc7c0c2cd0610bb9fe37.json +++ b/agentic/replay/c6cf102fb2172d14552e85bf19abb2d80a6075794717cc7c0c2cd0610bb9fe37.json @@ -46,10 +46,10 @@ }, { "name": "tap", - "summary": "tap button \"Add Source\"", + "summary": "tap button \"Add source\"", "target": { "role": "button", - "name": "Add Source" + "name": "Add source" } }, { diff --git a/agentic/replay/cec2ee4bacb9750f2fa98e3124caf500077ddc18df231c58d9c29724467af6ac.json b/agentic/replay/cec2ee4bacb9750f2fa98e3124caf500077ddc18df231c58d9c29724467af6ac.json index 8c206c45f..99d066293 100644 --- a/agentic/replay/cec2ee4bacb9750f2fa98e3124caf500077ddc18df231c58d9c29724467af6ac.json +++ b/agentic/replay/cec2ee4bacb9750f2fa98e3124caf500077ddc18df231c58d9c29724467af6ac.json @@ -53,10 +53,10 @@ }, { "name": "tap", - "summary": "tap button \"Get Started!\"", + "summary": "tap button \"Get started!\"", "target": { "role": "button", - "name": "Get Started!" + "name": "Get started!" } } ], @@ -72,7 +72,7 @@ "callIndex": 0, "agent": "default" }, - "summary": "Started a debate on “This House would make debate compulsory in secondary schools” at the tour’s first step, advanced through Library and Generate, and selected Get Started! on the final “You’re Ready!” step. The tour dialog closed.", + "summary": "Started a debate on “This House would make debate compulsory in secondary schools” at the tour’s first step, advanced through Library and Generate, and selected Get started! on the final “You’re Ready!” step. The tour dialog closed.", "startPath": "/app", "endPath": "/app", "endAnchors": [ @@ -120,7 +120,7 @@ }, { "role": "button", - "name": "Skip Tour" + "name": "Skip tour" }, { "role": "button", diff --git a/agentic/replay/d837eeb2b0665a23180f65fb205e2ea67f8b44f1a1a6a400067d474f1b21cb5f.json b/agentic/replay/d837eeb2b0665a23180f65fb205e2ea67f8b44f1a1a6a400067d474f1b21cb5f.json index 8a541bf7b..66a840aff 100644 --- a/agentic/replay/d837eeb2b0665a23180f65fb205e2ea67f8b44f1a1a6a400067d474f1b21cb5f.json +++ b/agentic/replay/d837eeb2b0665a23180f65fb205e2ea67f8b44f1a1a6a400067d474f1b21cb5f.json @@ -15,10 +15,10 @@ }, { "name": "tap", - "summary": "tap button \"Start Brainstorming\"", + "summary": "tap button \"Start brainstorming\"", "target": { "role": "button", - "name": "Start Brainstorming" + "name": "Start brainstorming" } }, { @@ -43,7 +43,7 @@ "callIndex": 0, "agent": "default" }, - "summary": "Brainstorming on 'This House would ban zoos' completed — motion entered, Start Brainstorming tapped, and the app offered to start a debate which was confirmed. The current screen shows generated brainstorm results (Top 5 Argument Angles), a 'Working in: This House would ban zoos' strip, and 'Curren…", + "summary": "Brainstorming on 'This House would ban zoos' completed — motion entered, Start brainstorming tapped, and the app offered to start a debate which was confirmed. The current screen shows generated brainstorm results (Top 5 Argument Angles), a 'Working in: This House would ban zoos' strip, and 'Curren…", "startPath": "/app/brainstorm", "endPath": "/app/brainstorm", "endAnchors": [ diff --git a/agentic/replay/e0c912b52a6de1dadb5287bb54346d1fac813a9d7ac70a1742898dc3abdf4a99.json b/agentic/replay/e0c912b52a6de1dadb5287bb54346d1fac813a9d7ac70a1742898dc3abdf4a99.json index 5f01ae9e7..3a1b50143 100644 --- a/agentic/replay/e0c912b52a6de1dadb5287bb54346d1fac813a9d7ac70a1742898dc3abdf4a99.json +++ b/agentic/replay/e0c912b52a6de1dadb5287bb54346d1fac813a9d7ac70a1742898dc3abdf4a99.json @@ -47,10 +47,10 @@ }, { "name": "tap", - "summary": "tap button \"Start Brainstorming\"", + "summary": "tap button \"Start brainstorming\"", "target": { "role": "button", - "name": "Start Brainstorming" + "name": "Start brainstorming" } } ], @@ -66,7 +66,7 @@ "callIndex": 0, "agent": "default" }, - "summary": "The brainstorm step was completed successfully: (1) navigated to /app/brainstorm, (2) entered motion 'This House would ban zoos', (3) when the app offered to start a new debate with this motion, clicked 'Start debate' to confirm, (4) clicked 'Start Brainstorming' which generated 5 argument angles (…", + "summary": "The brainstorm step was completed successfully: (1) navigated to /app/brainstorm, (2) entered motion 'This House would ban zoos', (3) when the app offered to start a new debate with this motion, clicked 'Start debate' to confirm, (4) clicked 'Start brainstorming' which generated 5 argument angles (…", "startPath": "/app/brainstorm", "endPath": "/app/brainstorm", "endAnchors": [ diff --git a/agentic/replay/e753f1e71c4d4ed90310f668e44ebdc847f876ca5ec6d2ade3d672c29aedcaf9.json b/agentic/replay/e753f1e71c4d4ed90310f668e44ebdc847f876ca5ec6d2ade3d672c29aedcaf9.json index ac59f14df..70aa3a2f1 100644 --- a/agentic/replay/e753f1e71c4d4ed90310f668e44ebdc847f876ca5ec6d2ade3d672c29aedcaf9.json +++ b/agentic/replay/e753f1e71c4d4ed90310f668e44ebdc847f876ca5ec6d2ade3d672c29aedcaf9.json @@ -15,10 +15,10 @@ }, { "name": "tap", - "summary": "tap button \"Find Weaknesses\"", + "summary": "tap button \"Find weaknesses\"", "target": { "role": "button", - "name": "Find Weaknesses" + "name": "Find weaknesses" } } ], @@ -34,7 +34,7 @@ "callIndex": 0, "agent": "default" }, - "summary": "Argument entered and weaknesses found successfully. The critique shows 1 major weakness (evidential gap on congestion claim), a hasty generalization fallacy, cross-examination questions, weighing analysis, and improvement suggestions. The Find Weaknesses action produced the expected analysis output.", + "summary": "Argument entered and weaknesses found successfully. The critique shows 1 major weakness (evidential gap on congestion claim), a hasty generalization fallacy, cross-examination questions, weighing analysis, and improvement suggestions. The Find weaknesses action produced the expected analysis output.", "startPath": "/app/attack", "endPath": "/app/attack", "endAnchors": [ diff --git a/agentic/replay/f6d6a30f85648dfe2446aa2888ca7fba9df68b2746461d556a193ac4ed89d357.json b/agentic/replay/f6d6a30f85648dfe2446aa2888ca7fba9df68b2746461d556a193ac4ed89d357.json index 3dae399b0..df600f8dc 100644 --- a/agentic/replay/f6d6a30f85648dfe2446aa2888ca7fba9df68b2746461d556a193ac4ed89d357.json +++ b/agentic/replay/f6d6a30f85648dfe2446aa2888ca7fba9df68b2746461d556a193ac4ed89d357.json @@ -46,10 +46,10 @@ }, { "name": "tap", - "summary": "tap button \"Add Source\"", + "summary": "tap button \"Add source\"", "target": { "role": "button", - "name": "Add Source" + "name": "Add source" } }, { diff --git a/docs/architecture.md b/docs/architecture.md index 7f4a0175a..27f4e4bb6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,6 +25,8 @@ Collaborative Workspace (`/app/workspaces/*`, the `workspace-invites` function, Styles are Tailwind CSS 4, configured in [`src/index.css`](../src/index.css) (`@theme`, `@custom-variant`, `@utility`) and built by `@tailwindcss/vite`; there is no `tailwind.config`. Tailwind 4 needs Safari 16.4, Chrome 111 or Firefox 128. Older browsers lose cascade layers, and translucent colours such as `bg-primary/10` render solid where `color-mix()` is missing. +The visual design (the DebateOwl style) comes from the design tokens in [`src/styles/tokens.css`](../src/styles/tokens.css): plume blue, beak gold, mint for success (always with a check mark), coral, and blue-tinted ink, with dark values on `.dark`. [`src/index.css`](../src/index.css) maps the shadcn roles and the palette families the app names directly onto them, and defines the tactile button (`.btn`, a ledge that presses down) and pressable tiles (`.tile`). Headings and buttons use Fredoka, body text Nunito, both self-hosted through Fontsource with metric-matched fallbacks. The owl's poses are hand-traced SVGs in [`src/assets/owl/`](../src/assets/owl/), one per screen that shows an owl (see the pose list in `Owl.tsx`), drawn by [`src/components/brand/Owl.tsx`](../src/components/brand/Owl.tsx) (`Owl`, `OwlAvatar`, `OwlMark`), which crossfade between poses and respect reduced motion. The theme defaults to light; Dark and System are a choice in Settings and the header. + ## Workspace layout The repository is an npm workspace. The web app is the root package and still lives in `src/`. [`packages/core/`](../packages/core/README.md) (`@debatelab/core`) holds platform-neutral TypeScript the iOS app will share: languages, input validation, the Zod schemas for AI requests and results, Crossfire and Sparring logic, and the Edge Function error reader. Its code may not use DOM globals, React, `import.meta`, or Deno specifiers; its tsconfig has no DOM library and ESLint enforces the rest. Each moved module leaves a one-line re-export at its old `src/lib/` path, so web imports and test mocks are unchanged. The stores, the AI client and streaming, the Supabase client, and the i18n catalogs stay in `src/` for now. @@ -122,7 +124,7 @@ Do not infer current schema from old README examples or store query names. Repla ## Account export and deletion -Settings offers three controls. "Download my data" calls the `account` Edge Function, which returns `export_account_data`, which holds the auth profile, every row whose ownership column (`user_id`, `owner_id`, `author_id`, `created_by`, or `recipient_id`) references the user, chat messages, published announcements addressed to the user, and announcements they wrote. Columns that only mention the user, such as `actor_id` or `resolved_by`, never select a row, and workspace rows are exported only while the user is still a member, as their read policies require. It leaves out credential and integrity columns and admin catalog work (promo codes, curated sources, global prompts, demo codes). Admin grants appear as grant type and dates only, because RLS hides their notes and author from the user. "Delete All My Data" calls `clear_current_user_data` and keeps the account. "Delete account" asks the user to type their email (or DELETE for an account without one), then calls the `account` function. +Settings offers three controls. "Download my data" calls the `account` Edge Function, which returns `export_account_data`, which holds the auth profile, every row whose ownership column (`user_id`, `owner_id`, `author_id`, `created_by`, or `recipient_id`) references the user, chat messages, published announcements addressed to the user, and announcements they wrote. Columns that only mention the user, such as `actor_id` or `resolved_by`, never select a row, and workspace rows are exported only while the user is still a member, as their read policies require. It leaves out credential and integrity columns and admin catalog work (promo codes, curated sources, global prompts, demo codes). Admin grants appear as grant type and dates only, because RLS hides their notes and author from the user. "Delete all my data" calls `clear_current_user_data` and keeps the account. "Delete account" asks the user to type their email (or DELETE for an account without one), then calls the `account` function. Deletion runs in a fixed order, and a failure at any step leaves the account in place: @@ -139,7 +141,7 @@ Supabase Auth owns user sessions. Protected routes provide a client navigation b - Google OAuth, six-digit email OTP, and demo-code token-hash handoffs all finish as ordinary Supabase sessions and use the same store hydration path. - Google and any emailed sign-in link return to `/auth/callback` (with `?next=/app/...` for a deep link; only `/app` paths are accepted). [`AuthProvider`](../src/lib/supabase/AuthProvider.tsx) exchanges the code there and keeps `loading` true until the exchange settles. The client sets `experimental.appendPkceFlowIdToRedirects`, so each sign-in keeps its own PKCE verifier and its redirect carries `sb_flow_id`; another tab's sign-in or a failed resend no longer breaks a pending link. An `error`/`error_code` from Auth, or a failed exchange, opens `/login` with a localized message. Links that still point at `/?code=` or `/app?code=` are moved onto the callback route before the first render ([`src/lib/auth-callback.ts`](../src/lib/auth-callback.ts)). Each project's Auth redirect allowlist needs `<site origin>/auth/callback**`; a redirect to the Site URL's own host passes without it. -- Sign-out ends only this device's session (`scope: 'local'`). After a sign-out, `/login` returns only the same account to the page it left; another account starts on `/app`. The onboarding tour is tracked per account on each browser. +- Sign-out ends only this device's session (`scope: 'local'`). After a sign-out, `/login` returns only the same account to the page it left; another account starts on `/app`. The onboarding tour is tracked per account on each browser. The one-time DebateOwl welcome ([`src/lib/debateowl-intro.ts`](../src/lib/debateowl-intro.ts)) opens once per browser inside the signed-in app, is remembered in localStorage (`debateowl_intro_seen_v1`), and runs before the tour. - RLS owns row visibility and mutation rights. - Shared backend helpers validate bearer tokens and admin roles. - Admin endpoints verify server-side roles. @@ -172,7 +174,7 @@ CI and staging must not upload authenticated browser captures. Logging must not ## PWA and offline behavior -The service worker ([`public/sw.js`](../public/sw.js)) and manifest provide installable web assets and an offline app. At install it precaches the landing page, `app-shell.html`, the static identity assets, and a list the build writes into the worker (`serviceWorkerPrecache` in [`vite.config.ts`](../vite.config.ts)): the shell's scripts and styles, the signed-in layout, the Home, Library, History, Timer and Settings pages, the Spanish and Hungarian strings, and Inter's Latin fonts. After one online visit those pages open offline and the language can be switched there. Other route chunks enter the runtime cache when a user visits them or hovers their link; on first registration the client also reports same-origin `/assets/` resources that loaded before the worker took control. Offline, app routes (`/app`, `/login`, `/share`, the paths `vercel.json` rewrites to the shell) get the precached shell, and a public page gets only its own HTML, stored under its own path when visited. `/auth/callback`, Supabase paths and `/_vercel/` never touch a cache. Each build's caches carry the build's id and the next build's worker deletes them on activation. Updates wait for the normal service-worker lifecycle instead of reloading an active page. Offline support does not make other unvisited routes, authenticated Supabase, or AI operations available without a network. +The service worker ([`public/sw.js`](../public/sw.js)) and manifest provide installable web assets and an offline app. At install it precaches the landing page, `app-shell.html`, the static identity assets, and a list the build writes into the worker (`serviceWorkerPrecache` in [`vite.config.ts`](../vite.config.ts)): the shell's scripts and styles, the signed-in layout, the Home, Library, History, Timer and Settings pages, the Spanish and Hungarian strings, the Latin subsets of Nunito and Fredoka, and the owl poses those pages show. After one online visit those pages open offline and the language can be switched there. Other route chunks enter the runtime cache when a user visits them or hovers their link; on first registration the client also reports same-origin `/assets/` resources that loaded before the worker took control. Offline, app routes (`/app`, `/login`, `/share`, the paths `vercel.json` rewrites to the shell) get the precached shell, and a public page gets only its own HTML, stored under its own path when visited. `/auth/callback`, Supabase paths and `/_vercel/` never touch a cache. Each build's caches carry the build's id and the next build's worker deletes them on activation. Updates wait for the normal service-worker lifecycle instead of reloading an active page. Offline support does not make other unvisited routes, authenticated Supabase, or AI operations available without a network. Only English strings ship in the entry. [`src/i18n.ts`](../src/i18n.ts) fetches the Spanish or Hungarian catalogs when that language is chosen, and the first render waits for them. diff --git a/docs/operations.md b/docs/operations.md index 307f804a9..430a10098 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -96,7 +96,7 @@ Do not enable the staging repository variable before every prerequisite is prese `STAGING_E2E_ENABLED` has been `true` since 2026-09-18. The job runs after every successful `Quality` run from a push to `main`, and it first waits up to 20 minutes for a successful `staging-sync` status on that commit. Quality runs from pull requests also trigger it, and those show as skipped by design. Check the latest real result with `gh run list --workflow staging-e2e.yml` and `npm run ci:failures -- <run-id>`, not this page. The run uploads no artifact, so the job log is the only evidence. On failure the log names the failed step and the URL path, for example `Staging step "add a source to the debate" failed at path /app/library`. -No staging run passed after 2026-09-23. Until 2026-10-01 the job did not run, because the `Quality` run it waits for never started. Once Actions came back, it failed on every `main` push through 2026-10-02. The cause was the spec, not staging: since the Current Debate change (#72), Library disables **Add Source** until a debate is current, and the spec never started one. The spec now starts a uniquely named debate and deletes it afterwards. The suite drives the real UI, so a change to a flow it covers can break it with no backend drift; run it locally before blaming staging. +No staging run passed after 2026-09-23. Until 2026-10-01 the job did not run, because the `Quality` run it waits for never started. Once Actions came back, it failed on every `main` push through 2026-10-02. The cause was the spec, not staging: since the Current Debate change (#72), Library disables **Add source** until a debate is current, and the spec never started one. The spec now starts a uniquely named debate and deletes it afterwards. The suite drives the real UI, so a change to a flow it covers can break it with no backend drift; run it locally before blaming staging. ## Staging sync diff --git a/docs/screenshots/menu-layout/after-ask-empty-en-light.jpg b/docs/screenshots/menu-layout/after-ask-empty-en-light.jpg new file mode 100644 index 000000000..f66a8f9e4 Binary files /dev/null and b/docs/screenshots/menu-layout/after-ask-empty-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/after-ask-error-en-dark.jpg b/docs/screenshots/menu-layout/after-ask-error-en-dark.jpg new file mode 100644 index 000000000..b2fae34d2 Binary files /dev/null and b/docs/screenshots/menu-layout/after-ask-error-en-dark.jpg differ diff --git a/docs/screenshots/menu-layout/after-ask-error-en-light.jpg b/docs/screenshots/menu-layout/after-ask-error-en-light.jpg new file mode 100644 index 000000000..fb183dc5a Binary files /dev/null and b/docs/screenshots/menu-layout/after-ask-error-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/after-ask-filled-en-light.jpg b/docs/screenshots/menu-layout/after-ask-filled-en-light.jpg new file mode 100644 index 000000000..8df4edef8 Binary files /dev/null and b/docs/screenshots/menu-layout/after-ask-filled-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/after-ask-filled-es-light.jpg b/docs/screenshots/menu-layout/after-ask-filled-es-light.jpg new file mode 100644 index 000000000..b6e39bddf Binary files /dev/null and b/docs/screenshots/menu-layout/after-ask-filled-es-light.jpg differ diff --git a/docs/screenshots/menu-layout/after-ask-filled-hu-dark.jpg b/docs/screenshots/menu-layout/after-ask-filled-hu-dark.jpg new file mode 100644 index 000000000..19d6bab69 Binary files /dev/null and b/docs/screenshots/menu-layout/after-ask-filled-hu-dark.jpg differ diff --git a/docs/screenshots/menu-layout/after-ask-filled-hu-light.jpg b/docs/screenshots/menu-layout/after-ask-filled-hu-light.jpg new file mode 100644 index 000000000..d5cc2f060 Binary files /dev/null and b/docs/screenshots/menu-layout/after-ask-filled-hu-light.jpg differ diff --git a/docs/screenshots/menu-layout/after-ask-loading-en-light.jpg b/docs/screenshots/menu-layout/after-ask-loading-en-light.jpg new file mode 100644 index 000000000..875abd2a4 Binary files /dev/null and b/docs/screenshots/menu-layout/after-ask-loading-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/after-ask-options-en-light.jpg b/docs/screenshots/menu-layout/after-ask-options-en-light.jpg new file mode 100644 index 000000000..840403c4f Binary files /dev/null and b/docs/screenshots/menu-layout/after-ask-options-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/after-ask-options-hu-light.jpg b/docs/screenshots/menu-layout/after-ask-options-hu-light.jpg new file mode 100644 index 000000000..d3d5ac919 Binary files /dev/null and b/docs/screenshots/menu-layout/after-ask-options-hu-light.jpg differ diff --git a/docs/screenshots/menu-layout/after-ask-sidebar-hu-dark.jpg b/docs/screenshots/menu-layout/after-ask-sidebar-hu-dark.jpg new file mode 100644 index 000000000..2819bfe07 Binary files /dev/null and b/docs/screenshots/menu-layout/after-ask-sidebar-hu-dark.jpg differ diff --git a/docs/screenshots/menu-layout/after-gen-empty-en-light.jpg b/docs/screenshots/menu-layout/after-gen-empty-en-light.jpg new file mode 100644 index 000000000..96024c2b9 Binary files /dev/null and b/docs/screenshots/menu-layout/after-gen-empty-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/after-gen-error-en-light.jpg b/docs/screenshots/menu-layout/after-gen-error-en-light.jpg new file mode 100644 index 000000000..7e5659ee2 Binary files /dev/null and b/docs/screenshots/menu-layout/after-gen-error-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/after-gen-filled-en-dark.jpg b/docs/screenshots/menu-layout/after-gen-filled-en-dark.jpg new file mode 100644 index 000000000..c5d424a66 Binary files /dev/null and b/docs/screenshots/menu-layout/after-gen-filled-en-dark.jpg differ diff --git a/docs/screenshots/menu-layout/after-gen-filled-en-light.jpg b/docs/screenshots/menu-layout/after-gen-filled-en-light.jpg new file mode 100644 index 000000000..5f6d0455b Binary files /dev/null and b/docs/screenshots/menu-layout/after-gen-filled-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/after-gen-filled-es-light.jpg b/docs/screenshots/menu-layout/after-gen-filled-es-light.jpg new file mode 100644 index 000000000..4d2a366bf Binary files /dev/null and b/docs/screenshots/menu-layout/after-gen-filled-es-light.jpg differ diff --git a/docs/screenshots/menu-layout/after-gen-filled-hu-dark.jpg b/docs/screenshots/menu-layout/after-gen-filled-hu-dark.jpg new file mode 100644 index 000000000..8460b4d58 Binary files /dev/null and b/docs/screenshots/menu-layout/after-gen-filled-hu-dark.jpg differ diff --git a/docs/screenshots/menu-layout/after-gen-filled-hu-light.jpg b/docs/screenshots/menu-layout/after-gen-filled-hu-light.jpg new file mode 100644 index 000000000..64c3b82f2 Binary files /dev/null and b/docs/screenshots/menu-layout/after-gen-filled-hu-light.jpg differ diff --git a/docs/screenshots/menu-layout/after-gen-loading-en-light.jpg b/docs/screenshots/menu-layout/after-gen-loading-en-light.jpg new file mode 100644 index 000000000..930538a6d Binary files /dev/null and b/docs/screenshots/menu-layout/after-gen-loading-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/after-gen-regenerating-en-light.jpg b/docs/screenshots/menu-layout/after-gen-regenerating-en-light.jpg new file mode 100644 index 000000000..1622abd74 Binary files /dev/null and b/docs/screenshots/menu-layout/after-gen-regenerating-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/after-gen-saved-en-dark.jpg b/docs/screenshots/menu-layout/after-gen-saved-en-dark.jpg new file mode 100644 index 000000000..c12c35dbc Binary files /dev/null and b/docs/screenshots/menu-layout/after-gen-saved-en-dark.jpg differ diff --git a/docs/screenshots/menu-layout/after-gen-saved-en-light.jpg b/docs/screenshots/menu-layout/after-gen-saved-en-light.jpg new file mode 100644 index 000000000..8b7a7a23a Binary files /dev/null and b/docs/screenshots/menu-layout/after-gen-saved-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/after-gen-saved-hu-dark.jpg b/docs/screenshots/menu-layout/after-gen-saved-hu-dark.jpg new file mode 100644 index 000000000..bb1d2ce31 Binary files /dev/null and b/docs/screenshots/menu-layout/after-gen-saved-hu-dark.jpg differ diff --git a/docs/screenshots/menu-layout/after-gen-saved-hu-light.jpg b/docs/screenshots/menu-layout/after-gen-saved-hu-light.jpg new file mode 100644 index 000000000..d009cab7d Binary files /dev/null and b/docs/screenshots/menu-layout/after-gen-saved-hu-light.jpg differ diff --git a/docs/screenshots/menu-layout/after-share-en-light.jpg b/docs/screenshots/menu-layout/after-share-en-light.jpg new file mode 100644 index 000000000..e79e27bc2 Binary files /dev/null and b/docs/screenshots/menu-layout/after-share-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/after-share-hu-dark.jpg b/docs/screenshots/menu-layout/after-share-hu-dark.jpg new file mode 100644 index 000000000..db75c4f29 Binary files /dev/null and b/docs/screenshots/menu-layout/after-share-hu-dark.jpg differ diff --git a/docs/screenshots/menu-layout/before-ask-empty-en-light.jpg b/docs/screenshots/menu-layout/before-ask-empty-en-light.jpg new file mode 100644 index 000000000..530d3acc5 Binary files /dev/null and b/docs/screenshots/menu-layout/before-ask-empty-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/before-ask-error-en-dark.jpg b/docs/screenshots/menu-layout/before-ask-error-en-dark.jpg new file mode 100644 index 000000000..1ab3ce8b0 Binary files /dev/null and b/docs/screenshots/menu-layout/before-ask-error-en-dark.jpg differ diff --git a/docs/screenshots/menu-layout/before-ask-error-en-light.jpg b/docs/screenshots/menu-layout/before-ask-error-en-light.jpg new file mode 100644 index 000000000..30181be01 Binary files /dev/null and b/docs/screenshots/menu-layout/before-ask-error-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/before-ask-filled-en-light.jpg b/docs/screenshots/menu-layout/before-ask-filled-en-light.jpg new file mode 100644 index 000000000..caddd0dbd Binary files /dev/null and b/docs/screenshots/menu-layout/before-ask-filled-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/before-ask-filled-es-light.jpg b/docs/screenshots/menu-layout/before-ask-filled-es-light.jpg new file mode 100644 index 000000000..5595e76da Binary files /dev/null and b/docs/screenshots/menu-layout/before-ask-filled-es-light.jpg differ diff --git a/docs/screenshots/menu-layout/before-ask-filled-hu-dark.jpg b/docs/screenshots/menu-layout/before-ask-filled-hu-dark.jpg new file mode 100644 index 000000000..4e9ac1476 Binary files /dev/null and b/docs/screenshots/menu-layout/before-ask-filled-hu-dark.jpg differ diff --git a/docs/screenshots/menu-layout/before-ask-filled-hu-light.jpg b/docs/screenshots/menu-layout/before-ask-filled-hu-light.jpg new file mode 100644 index 000000000..71fdb29f9 Binary files /dev/null and b/docs/screenshots/menu-layout/before-ask-filled-hu-light.jpg differ diff --git a/docs/screenshots/menu-layout/before-ask-loading-en-light.jpg b/docs/screenshots/menu-layout/before-ask-loading-en-light.jpg new file mode 100644 index 000000000..e458fbe29 Binary files /dev/null and b/docs/screenshots/menu-layout/before-ask-loading-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/before-ask-options-en-light.jpg b/docs/screenshots/menu-layout/before-ask-options-en-light.jpg new file mode 100644 index 000000000..4ca4e1bd6 Binary files /dev/null and b/docs/screenshots/menu-layout/before-ask-options-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/before-ask-options-hu-light.jpg b/docs/screenshots/menu-layout/before-ask-options-hu-light.jpg new file mode 100644 index 000000000..7c7729a39 Binary files /dev/null and b/docs/screenshots/menu-layout/before-ask-options-hu-light.jpg differ diff --git a/docs/screenshots/menu-layout/before-ask-sidebar-hu-dark.jpg b/docs/screenshots/menu-layout/before-ask-sidebar-hu-dark.jpg new file mode 100644 index 000000000..4d4c3d743 Binary files /dev/null and b/docs/screenshots/menu-layout/before-ask-sidebar-hu-dark.jpg differ diff --git a/docs/screenshots/menu-layout/before-gen-empty-en-light.jpg b/docs/screenshots/menu-layout/before-gen-empty-en-light.jpg new file mode 100644 index 000000000..bdf8833b7 Binary files /dev/null and b/docs/screenshots/menu-layout/before-gen-empty-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/before-gen-error-en-light.jpg b/docs/screenshots/menu-layout/before-gen-error-en-light.jpg new file mode 100644 index 000000000..4549919d0 Binary files /dev/null and b/docs/screenshots/menu-layout/before-gen-error-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/before-gen-filled-en-dark.jpg b/docs/screenshots/menu-layout/before-gen-filled-en-dark.jpg new file mode 100644 index 000000000..ae14a902e Binary files /dev/null and b/docs/screenshots/menu-layout/before-gen-filled-en-dark.jpg differ diff --git a/docs/screenshots/menu-layout/before-gen-filled-en-light.jpg b/docs/screenshots/menu-layout/before-gen-filled-en-light.jpg new file mode 100644 index 000000000..dfe234358 Binary files /dev/null and b/docs/screenshots/menu-layout/before-gen-filled-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/before-gen-filled-es-light.jpg b/docs/screenshots/menu-layout/before-gen-filled-es-light.jpg new file mode 100644 index 000000000..d8bfb1306 Binary files /dev/null and b/docs/screenshots/menu-layout/before-gen-filled-es-light.jpg differ diff --git a/docs/screenshots/menu-layout/before-gen-filled-hu-dark.jpg b/docs/screenshots/menu-layout/before-gen-filled-hu-dark.jpg new file mode 100644 index 000000000..9b50e4c98 Binary files /dev/null and b/docs/screenshots/menu-layout/before-gen-filled-hu-dark.jpg differ diff --git a/docs/screenshots/menu-layout/before-gen-filled-hu-light.jpg b/docs/screenshots/menu-layout/before-gen-filled-hu-light.jpg new file mode 100644 index 000000000..bed3f81de Binary files /dev/null and b/docs/screenshots/menu-layout/before-gen-filled-hu-light.jpg differ diff --git a/docs/screenshots/menu-layout/before-gen-loading-en-light.jpg b/docs/screenshots/menu-layout/before-gen-loading-en-light.jpg new file mode 100644 index 000000000..404930541 Binary files /dev/null and b/docs/screenshots/menu-layout/before-gen-loading-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/before-gen-regenerating-en-light.jpg b/docs/screenshots/menu-layout/before-gen-regenerating-en-light.jpg new file mode 100644 index 000000000..a9457a22d Binary files /dev/null and b/docs/screenshots/menu-layout/before-gen-regenerating-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/before-gen-saved-en-dark.jpg b/docs/screenshots/menu-layout/before-gen-saved-en-dark.jpg new file mode 100644 index 000000000..5ab6b3051 Binary files /dev/null and b/docs/screenshots/menu-layout/before-gen-saved-en-dark.jpg differ diff --git a/docs/screenshots/menu-layout/before-gen-saved-en-light.jpg b/docs/screenshots/menu-layout/before-gen-saved-en-light.jpg new file mode 100644 index 000000000..d9d8bc96a Binary files /dev/null and b/docs/screenshots/menu-layout/before-gen-saved-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/before-gen-saved-hu-dark.jpg b/docs/screenshots/menu-layout/before-gen-saved-hu-dark.jpg new file mode 100644 index 000000000..23733eefc Binary files /dev/null and b/docs/screenshots/menu-layout/before-gen-saved-hu-dark.jpg differ diff --git a/docs/screenshots/menu-layout/before-gen-saved-hu-light.jpg b/docs/screenshots/menu-layout/before-gen-saved-hu-light.jpg new file mode 100644 index 000000000..fb170838a Binary files /dev/null and b/docs/screenshots/menu-layout/before-gen-saved-hu-light.jpg differ diff --git a/docs/screenshots/menu-layout/before-share-en-light.jpg b/docs/screenshots/menu-layout/before-share-en-light.jpg new file mode 100644 index 000000000..de910d818 Binary files /dev/null and b/docs/screenshots/menu-layout/before-share-en-light.jpg differ diff --git a/docs/screenshots/menu-layout/before-share-hu-dark.jpg b/docs/screenshots/menu-layout/before-share-hu-dark.jpg new file mode 100644 index 000000000..0c3b4ac4d Binary files /dev/null and b/docs/screenshots/menu-layout/before-share-hu-dark.jpg differ diff --git a/docs/standards/async-and-state.md b/docs/standards/async-and-state.md index 85e8f9806..d80f49ab2 100644 --- a/docs/standards/async-and-state.md +++ b/docs/standards/async-and-state.md @@ -6,7 +6,7 @@ Part of the [coding standards](../../CODING_STANDARDS.md). Race conditions were Read the debate, user, or record ID at the moment of the click, and pass it through the async chain. Do not read a store again after an `await`, because the user may have switched debates or accounts while you waited. -- Clear All read the Current Debate after awaiting auth, so a switch during the wait deleted the other debate's sources ([#72](https://github.com/Programcode/DebateLab/pull/72#discussion_r4112813743)). +- Clear all read the Current Debate after awaiting auth, so a switch during the wait deleted the other debate's sources ([#72](https://github.com/Programcode/DebateLab/pull/72#discussion_r4112813743)). - Workspace research saved its sources into whichever personal debate was active when the response arrived ([#72](https://github.com/Programcode/DebateLab/pull/72#discussion_r4112813741)). - A fallback that reads "the current debate" must respect one-off mode; use `activeDebateId()`, not `currentDebateId` ([#72](https://github.com/Programcode/DebateLab/pull/72#discussion_r4112813737)). diff --git a/docs/testing.md b/docs/testing.md index a97ff3554..9217469d6 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -129,6 +129,8 @@ The server is never reused. CI uses retries and two workers. On failure, traces, Playwright starts that server with `VITE_E2E=1`. The client then keeps the environment switcher, but the production fallback is `http://e2e.invalid` instead of the production Supabase project, and a request that still names that project is refused in the browser. `playwright.config.ts` also refuses to start when `VITE_SUPABASE_ENV` or `SUPABASE_URL` already points at production. `E2E_ALLOW_PROD=1` is the explicit override. The production canary uses `playwright.production.config.ts` and does not set `VITE_E2E`. Plain `npm run dev` is unchanged and still defaults to production. +The one-time DebateOwl welcome stays closed whenever `VITE_E2E` or `VITE_LOCAL_PREVIEW` is set, so Playwright, the verify stack and agentic tests never meet it; `?intro=show` opens it anyway and `?intro=skip` keeps it closed in any build. The production canary and the service worker offline test (`npm run test:sw`), which drive production builds, seed `debateowl_intro_seen_v1` instead. + `e2e/hungarian-parity.spec.ts` is the focused Hungarian browser contract. On desktop and mobile Chromium it checks persisted `<html lang="hu">`, public and authenticated page chrome, route titles, onboarding, saved-chat empty state, Library controls, Timer semantics, localized close labels, and runtime errors. It deliberately does not reject arbitrary English text because source titles, user content, model names, and citations may legitimately remain in their source language. `e2e/first-run.spec.ts` drives the same first run against stubbed Supabase routes in English and Hungarian, so it proves the client flow only. @@ -200,7 +202,7 @@ Each test signs in through the Login page with a fresh demo code and deletes tha - Current Debate context: every request carries the debate's motion, instructions, and sources; Review drops a source and pins a saved case for exactly one request; the pin is stored; Crossfire leaves instructions out; and one-off work sends nothing; - a judged Lincoln-Douglas sparring round and a Crossfire debrief after a Jev signal attaches to an answer; - spoken Sparring and Crossfire rounds against the real `ai` and `voice` functions with no ElevenLabs key: the opponent prompt carries the voice rules, the reply comes back clean with signed segments, `voice` answers `voice_unavailable`, the browser voice reads the clean text, the judge gets the measured facts, and the saved round holds no audio tag (the recogniser and the browser voice are scripted); -- Library activation and deletion, output-language sync across devices, flowchart saves, and Delete All My Data (it must leave another account's rows intact); +- Library activation and deletion, output-language sync across devices, flowchart saves, and Delete all my data (it must leave another account's rows intact); - Download my data (only the caller's rows, even when the request names another user) and Delete account (no row in any `public` or `private` table mentions the user afterwards, another account is untouched, and a call without a session or with another user's confirmation deletes nothing); - a promo code redeemed in Settings that restores AI access, and signed Stripe webhooks that grant and revoke Pro while a forged or replayed one changes nothing; - admin announcements seen by another user, denied admin access, and a public share link. diff --git a/e2e/animation-polish.spec.ts b/e2e/animation-polish.spec.ts index dbd6990e9..b2c6c2906 100644 --- a/e2e/animation-polish.spec.ts +++ b/e2e/animation-polish.spec.ts @@ -52,7 +52,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.getByRole('button', { name: 'Generate Debate Case' }).click(); + 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 }); await expect(page.getByText(/We support the motion/)).toBeVisible({ timeout: STATE_TIMEOUT_MS }); @@ -126,7 +126,7 @@ test('audits applicable empty, loading, result, and option states across feature 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.getByRole('button', { name: 'Find Weaknesses' }).click(); + 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 page.screenshot({ path: testInfo.outputPath('attack-result.png'), fullPage: true }); @@ -134,7 +134,7 @@ test('audits applicable empty, loading, result, and option states across feature 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 page.getByRole('button', { name: 'Start Brainstorming' }).click(); + 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 }); diff --git a/e2e/first-run.spec.ts b/e2e/first-run.spec.ts index bb0bd5bd2..27e765436 100644 --- a/e2e/first-run.spec.ts +++ b/e2e/first-run.spec.ts @@ -15,9 +15,9 @@ const copy = { library: 'Library', generate: 'Generate', ready: "You're Ready!", - finish: 'Get Started!', + finish: 'Get started!', generateMotion: 'Motion / Topic *', - generateSubmit: 'Generate Debate Case', + generateSubmit: 'Generate debate case', offerTitle: 'Start a debate with this motion?', workingIn: 'Working in:', tourMotion: 'This House would ban homework', diff --git a/e2e/full-stack/account-data.spec.ts b/e2e/full-stack/account-data.spec.ts index de6b9efa5..b252684cd 100644 --- a/e2e/full-stack/account-data.spec.ts +++ b/e2e/full-stack/account-data.spec.ts @@ -4,7 +4,7 @@ import type { Page } from "@playwright/test"; test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)"); async function addSource(page: Page, title: string, content: string) { - await page.getByRole("button", { name: "Add Source" }).click(); + 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(); @@ -21,7 +21,7 @@ test("only active Library sources ground AI requests, and deletions persist", as await page.goto("/app/brainstorm"); await page.getByLabel("Motion / Topic *").fill("Public transport should be free"); - await page.getByRole("button", { name: "Start Brainstorming" }).click(); + await page.getByRole("button", { name: "Start brainstorming" }).click(); await expectResult(page, `${MARK} idea 1`); const call = await providerCall("brainstorm"); expect(call.system).toContain("ACTIVE-SOURCE-MARKER"); @@ -38,7 +38,7 @@ test("only active Library sources ground AI requests, and deletions persist", as expect(data?.map((row) => row.title)).toEqual(["Active study"]); }); -test("Delete All My Data erases the account's saved work and nobody else's", async ({ signedIn: page, user }) => { +test("Delete all my data erases the account's saved work and nobody else's", async ({ signedIn: page, user }) => { const other = await createDemoUser("bystander"); try { // A signed-in session creates the bystander's rows, so this also proves the erase is scoped by user. @@ -57,10 +57,10 @@ test("Delete All My Data erases the account's saved work and nobody else's", asy const otherId = await other.userId(); await page.goto("/app/settings"); - await page.getByRole("button", { name: "Delete All My Data" }).click(); + await page.getByRole("button", { name: "Delete all my data" }).click(); 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 dialog.getByRole("button", { name: "Delete all my data" }).click(); await expect(page.getByText("Data Deleted").first()).toBeVisible(); const { data: mine } = await admin.from("sources").select("id").eq("user_id", userId); @@ -94,7 +94,7 @@ test("the output language setting follows the account and reaches the model", as await signIn(other, user); await other.goto("/app/attack"); await other.getByLabel("Your Argument *").fill("Free transit cuts congestion."); - await other.getByRole("button", { name: "Find Weaknesses" }).click(); + 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)"); } finally { diff --git a/e2e/full-stack/account-deletion.spec.ts b/e2e/full-stack/account-deletion.spec.ts index 759f5d2ee..9e83ed9ba 100644 --- a/e2e/full-stack/account-deletion.spec.ts +++ b/e2e/full-stack/account-deletion.spec.ts @@ -9,7 +9,7 @@ const accountUrl = `${process.env.LOCAL_SUPABASE_URL}/functions/v1/account`; async function addSource(page: Page, title: string, content: string) { await page.goto("/app/library"); - await page.getByRole("button", { name: "Add Source" }).click(); + 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(); diff --git a/e2e/full-stack/ai-tools.spec.ts b/e2e/full-stack/ai-tools.spec.ts index 2f59125dd..83202f32c 100644 --- a/e2e/full-stack/ai-tools.spec.ts +++ b/e2e/full-stack/ai-tools.spec.ts @@ -5,7 +5,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.getByRole("button", { name: "Find Weaknesses" }).click(); + await page.getByRole("button", { name: "Find weaknesses" }).click(); await expectResult(page, `${MARK} weakness`); expect((await providerCall("attack")).user).toContain("riders leave their cars at home"); @@ -14,7 +14,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.getByRole("button", { name: "Start Brainstorming" }).click(); + await page.getByRole("button", { name: "Start brainstorming" }).click(); await chooseForMotion(page, "create"); await expectResult(page, `${MARK} idea 1`); diff --git a/e2e/full-stack/auth.spec.ts b/e2e/full-stack/auth.spec.ts index d82825b71..df4c409fe 100644 --- a/e2e/full-stack/auth.spec.ts +++ b/e2e/full-stack/auth.spec.ts @@ -39,7 +39,7 @@ test("an expired access token refreshes before an AI request", async ({ signedIn }); await page.goto("/app/generate"); 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 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 252fc4c21..a01425bf6 100644 --- a/e2e/full-stack/billing.spec.ts +++ b/e2e/full-stack/billing.spec.ts @@ -23,7 +23,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.getByRole("button", { name: "Start Brainstorming" }).click(); + await page.getByRole("button", { name: "Start brainstorming" }).click(); await chooseForMotion(page, "once"); } @@ -126,7 +126,7 @@ test("signed Stripe webhooks grant and revoke Pro, and forged ones change nothin await page.goto("/app/settings"); await expect(page.getByText("Pro", { exact: true })).toBeVisible(); - await expect(page.getByRole("button", { name: "Manage Subscription" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Manage subscription" })).toBeVisible(); await expect(page.getByTestId("billing-date")).toContainText("Renews on"); // A failed renewal keeps Pro while Stripe retries, and Settings asks for a @@ -143,7 +143,7 @@ test("signed Stripe webhooks grant and revoke Pro, and forged ones change nothin await page.reload(); await expect(page.getByText("Payment failed", { exact: true })).toBeVisible(); await expect(page.getByText(/Pro access continues until/)).toBeVisible(); - await expect(page.getByRole("button", { name: "Manage Subscription" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Manage subscription" })).toBeVisible(); await expect(page.getByRole("button", { name: /Upgrade to Pro/ })).toHaveCount(0); await fake.stripeSubscription(subscriptionObject(subscriptionId, "canceled")); diff --git a/e2e/full-stack/case-generation.spec.ts b/e2e/full-stack/case-generation.spec.ts index e1d85a3a2..e6ded5922 100644 --- a/e2e/full-stack/case-generation.spec.ts +++ b/e2e/full-stack/case-generation.spec.ts @@ -9,7 +9,7 @@ test("a signed-in user generates a case grounded in a saved Library source", asy const marker = `SOURCE-${Date.now()}`; await startDebate(page, motion); await page.goto("/app/library"); - await page.getByRole("button", { name: "Add Source" }).click(); + await page.getByRole("button", { name: "Add source" }).click(); await page.getByRole("textbox", { name: "Title", exact: true }).fill("Tallinn fare study"); // Imported articles are often this long. Inlining that text in the request // exceeded the Edge Function's 200 KB body limit before sources loaded server-side. @@ -24,7 +24,7 @@ test("a signed-in user generates a case grounded in a saved Library source", asy await page.goto("/app/generate"); await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await page.getByRole("button", { name: "Generate debate case" }).click(); // A hang (as in the auth relock regression) fails here instead of spinning forever. await expectResult(page, `${MARK} case thesis`); @@ -44,12 +44,12 @@ test("a provider failure shows an error and lets the user retry", async ({ signe await startDebate(page, motion); await page.goto("/app/generate"); await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await page.getByRole("button", { name: "Generate debate case" }).click(); await expect(page.getByText("Generation Failed", { exact: true })).toBeVisible({ timeout: 30_000 }); - await expect(page.getByRole("button", { name: "Generate Debate Case" })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Generate debate case" })).toBeEnabled(); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await page.getByRole("button", { name: "Generate debate case" }).click(); await expectResult(page, `${MARK} case thesis`); }); @@ -58,7 +58,7 @@ test("an account without quota is told why generation is blocked", async ({ sign await startDebate(page, motion); await page.goto("/app/generate"); await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await page.getByRole("button", { name: "Generate debate case" }).click(); await expect(page.getByRole("dialog").getByText("Daily Access Paused")).toBeVisible({ timeout: 30_000 }); expect((await fake.requests()).filter((request) => request.feature === "generate")).toEqual([]); @@ -67,7 +67,7 @@ test("an account without quota is told why generation is blocked", async ({ sign 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("button", { name: "Generate Debate Case" }).click(); + await page.getByRole("button", { name: "Generate debate case" }).click(); await chooseForMotion(page, "once"); await expectResult(page, `${MARK} case thesis`); @@ -94,7 +94,7 @@ 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("button", { name: "Generate Debate Case" }).click(); + await page.getByRole("button", { name: "Generate debate case" }).click(); await expectResult(page, `${MARK} case thesis`); // PDF files start with "%PDF"; DOCX files are ZIP archives starting with "PK". diff --git a/e2e/full-stack/current-debate.spec.ts b/e2e/full-stack/current-debate.spec.ts index 3b5b06c53..db8c29567 100644 --- a/e2e/full-stack/current-debate.spec.ts +++ b/e2e/full-stack/current-debate.spec.ts @@ -17,7 +17,7 @@ async function snap(page: Page, name: string) { async function addSource(page: Page, title: string, content: string) { await page.goto("/app/library"); - await page.getByRole("button", { name: "Add Source" }).click(); + 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(); @@ -53,7 +53,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 page.getByRole("button", { name: "Start Brainstorming" }).click(); + await page.getByRole("button", { name: "Start brainstorming" }).click(); await expectResult(page, `${MARK} idea 1`); const homeworkCall = await latestCall("brainstorm"); expect(homeworkCall.user).toContain(`Motion: ${HOMEWORK}`); @@ -70,7 +70,7 @@ test("two debates keep their motions, sources, and results apart", async ({ sign await page.goto("/app/generate"); await expect(page.getByRole("textbox", { name: "Motion / Topic *" })).toHaveValue(ZOOS); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await page.getByRole("button", { name: "Generate debate case" }).click(); await expectResult(page, `${MARK} case thesis`); const zooCall = await latestCall("generate"); expect(zooCall.user).toContain(`Motion: ${ZOOS}`); @@ -80,7 +80,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.getByRole("button", { name: "Start Brainstorming" }).click(); + 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"); await chooseForMotion(page, "once"); diff --git a/e2e/full-stack/debate-context.spec.ts b/e2e/full-stack/debate-context.spec.ts index 4d30de0b3..83d463dc9 100644 --- a/e2e/full-stack/debate-context.spec.ts +++ b/e2e/full-stack/debate-context.spec.ts @@ -17,7 +17,7 @@ async function snap(page: Page, name: string) { async function addSource(page: Page, title: string, content: string) { await page.goto("/app/library"); - await page.getByRole("button", { name: "Add Source" }).click(); + 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(); @@ -49,7 +49,7 @@ test("every request carries its debate's material, and Review changes exactly on const control = page.getByTestId("debate-context-control"); await expect(control).toContainText("Using 2 sources"); await snap(page, "1-generate-context-line"); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await page.getByRole("button", { name: "Generate debate case" }).click(); await expectResult(page, `${MARK} case thesis`); const generateCall = await latestCall("generate"); for (const expected of ["<current-debate>", MOTION, INSTRUCTION, "FARE-SOURCE", "RIDERSHIP-SOURCE", "STUDENT MATERIAL RULES"]) { @@ -69,7 +69,7 @@ test("every request carries its debate's material, and Review changes exactly on await expect(sheet).toBeHidden(); await expect(control).toContainText("Using 1 source and 1 saved result"); - await page.getByRole("button", { name: "Start Brainstorming" }).click(); + await page.getByRole("button", { name: "Start brainstorming" }).click(); await expectResult(page, `${MARK} idea 1`); const brainstormCall = await latestCall("brainstorm"); expect(brainstormCall.system).toContain("FARE-SOURCE"); @@ -97,7 +97,7 @@ test("every request carries its debate's material, and Review changes exactly on await page.goto("/app/brainstorm"); 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("button", { name: "Start brainstorming" }).click(); await page.getByRole("dialog").getByRole("button", { name: "Use once without saving to a debate" }).click(); await expectResult(page, `${MARK} idea 1`); const oneOffCall = await latestCall("brainstorm", before); diff --git a/e2e/full-stack/first-run.spec.ts b/e2e/full-stack/first-run.spec.ts index e7e54ff88..eabae851a 100644 --- a/e2e/full-stack/first-run.spec.ts +++ b/e2e/full-stack/first-run.spec.ts @@ -16,10 +16,10 @@ const copy = { motionField: "Motion", library: "Library", generate: "Generate", - finish: "Get Started!", - skip: "Skip Tour", + finish: "Get started!", + skip: "Skip tour", generateMotion: "Motion / Topic *", - generateSubmit: "Generate Debate Case", + generateSubmit: "Generate debate case", offerTitle: "Start a debate with this motion?", workingIn: "Working in:", tourMotion: "This House would ban homework", diff --git a/e2e/live/models.spec.ts b/e2e/live/models.spec.ts index 633f2fadd..beb2c2ee5 100644 --- a/e2e/live/models.spec.ts +++ b/e2e/live/models.spec.ts @@ -26,13 +26,13 @@ test("Attack and Brainstorm return valid structured analyses", async ({ signedIn 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."); const attack = aiResponse(page, "attack"); - await page.getByRole("button", { name: "Find Weaknesses" }).click(); + 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"); const brainstorm = aiResponse(page, "brainstorm"); - await page.getByRole("button", { name: "Start Brainstorming" }).click(); + await page.getByRole("button", { name: "Start brainstorming" }).click(); await chooseForMotion(page, "once"); expect(((await brainstorm).output.topFive as unknown[]).length).toBeGreaterThan(0); await expectProviderCallsHealthy(["attack", "brainstorm"]); diff --git a/e2e/live/research.spec.ts b/e2e/live/research.spec.ts index fdf7905ee..dbf1551a0 100644 --- a/e2e/live/research.spec.ts +++ b/e2e/live/research.spec.ts @@ -10,7 +10,7 @@ test("case generation researches the web and cites what it found", async ({ sign 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"); const response = aiResponse(page, "generate"); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await page.getByRole("button", { name: "Generate debate case" }).click(); await chooseForMotion(page, "once"); const body = await response; @@ -72,7 +72,7 @@ test("a web page imports into the Library", async ({ signedIn: page, user }) => // Sources belong to a debate, so the Library needs a Current Debate first. await startDebate(page, "This House would make public transport free"); await page.goto("/app/library"); - await page.getByRole("button", { name: "Add Source" }).click(); + await page.getByRole("button", { name: "Add source" }).click(); await page.getByRole("textbox", { name: "Import from URL" }).fill(url); await page.getByRole("button", { name: "Import URL" }).click(); await expect(page.getByText("Import preview", { exact: true })).toBeVisible({ timeout: 60_000 }); diff --git a/e2e/loading-performance.spec.ts b/e2e/loading-performance.spec.ts index 0392d00b5..16b6613dc 100644 --- a/e2e/loading-performance.spec.ts +++ b/e2e/loading-performance.spec.ts @@ -87,7 +87,7 @@ test('landing CTA enters the app without replacing the document', async ({ page await page.goto('/'); await page.evaluate(() => { document.documentElement.dataset.e2eDocument = 'original'; }); const initialDocumentRequests = documentRequests; - await page.getByRole('link', { name: 'Start Trial' }).first().click(); + await page.getByRole('link', { name: 'Start trial' }).first().click(); await expect(page).toHaveURL(/\/app$/); expect(documentRequests).toBe(initialDocumentRequests); diff --git a/e2e/model-matrix/generate.spec.ts b/e2e/model-matrix/generate.spec.ts index 87e63171c..cb09c10a8 100644 --- a/e2e/model-matrix/generate.spec.ts +++ b/e2e/model-matrix/generate.spec.ts @@ -15,7 +15,7 @@ for (const model of MODEL_CATALOG) { 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"); const response = aiResponse(page, "generate"); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await page.getByRole("button", { name: "Generate debate case" }).click(); await chooseForMotion(page, "once"); const body = await response; expect(body.output.contentions).toHaveLength(3); diff --git a/e2e/production/canary.spec.ts b/e2e/production/canary.spec.ts index 028917654..5d7d739a3 100644 --- a/e2e/production/canary.spec.ts +++ b/e2e/production/canary.spec.ts @@ -30,6 +30,7 @@ async function signInAsCanary(page: Page, { aiRequest }: { aiRequest: boolean }) await page.addInitScript((session) => { localStorage.setItem("debate-auth-production", JSON.stringify(session)); localStorage.setItem("debate-trainer-onboarding", JSON.stringify({ state: { hasCompletedOnboarding: true, isTourRunning: false }, version: 0 })); + localStorage.setItem("debateowl_intro_seen_v1", "canary"); }, data.session); } @@ -82,7 +83,7 @@ test("the AI function rejects unauthenticated calls instead of crashing", async test("the canary account signs in and loads its account", async ({ page }) => { await signInAsCanary(page, { aiRequest: false }); await page.goto("/app/generate"); - await expect(page.getByRole("button", { name: "Generate Debate Case" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Generate debate case" })).toBeVisible(); await page.goto("/app/settings"); await expect(page.getByText("Pro", { exact: true })).toBeVisible(); }); @@ -115,7 +116,7 @@ test("the canary account generates a case", async ({ page }) => { await pickBasicDefault(page, "generate"); 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(); + await page.getByRole("button", { name: "Generate debate case" }).click(); // Use the motion once so the canary account does not collect production debates. await page.getByRole("dialog", { name: /^(Start a debate with this motion\?|This is a different motion)$/ }) .getByRole("button", { name: /^(Not now, use it once|Use once without saving to a debate)$/ }) diff --git a/e2e/results-overflow.spec.ts b/e2e/results-overflow.spec.ts index 51e4424e5..6e3ea5242 100644 --- a/e2e/results-overflow.spec.ts +++ b/e2e/results-overflow.spec.ts @@ -47,7 +47,7 @@ test('a long URL in generated evidence does not widen the page', async ({ page } }); await page.goto('/app/generate'); - await page.getByRole('button', { name: 'Generate Debate Case' }).click(); + await page.getByRole('button', { name: 'Generate debate case' }).click(); await expect(page.getByText('Contention 3: fares block the riders who need transit most')).toBeVisible(); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); diff --git a/e2e/staging/authed-flows.spec.ts b/e2e/staging/authed-flows.spec.ts index 58b79e017..2cac4901e 100644 --- a/e2e/staging/authed-flows.spec.ts +++ b/e2e/staging/authed-flows.spec.ts @@ -94,7 +94,7 @@ test('real staging session persists, starts a debate, writes and removes a sourc await page.reload(); // Sources belong to a debate, so Library asks for one before it allows adding a source. await expect(page.getByTestId('library-debate')).toContainText('Select or create a debate', { timeout: 15_000 }); - await expect(page.getByRole('button', { name: 'Add Source' })).toBeDisabled(); + await expect(page.getByRole('button', { name: 'Add source' })).toBeDisabled(); await page.getByTestId('library-debate').getByRole('button', { name: 'Start debate' }).click(); const dialog = page.getByRole('dialog', { name: 'Start new debate' }); await dialog.getByLabel('Motion').fill(motion); @@ -114,7 +114,7 @@ test('real staging session persists, starts a debate, writes and removes a sourc }); await step(page, 'add a source to the debate', async () => { - await page.getByRole('button', { name: 'Add Source' }).click(); + await page.getByRole('button', { name: 'Add source' }).click(); await page.getByPlaceholder('Source title...').fill(sourceTitle); await page.getByPlaceholder('Paste source content...').fill(sourceContent); const sourceInsert = page.waitForResponse((response) => diff --git a/index.html b/index.html index 58ca1c192..5d6862a9c 100644 --- a/index.html +++ b/index.html @@ -14,8 +14,8 @@ <!-- The shell for /app, /login and /share. Prerender replaces this with the public index directive on pages that have a canonical URL. --> <meta name="robots" content="noindex, nofollow" /> - <meta name="theme-color" content="#f1f5f9" media="(prefers-color-scheme: light)" /> - <meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)" /> + <meta name="theme-color" content="#F7FAFE" media="(prefers-color-scheme: light)" /> + <meta name="theme-color" content="#0B1426" media="(prefers-color-scheme: dark)" /> <link rel="icon" href="/favicon.ico" sizes="48x48" /> <link rel="icon" type="image/png" href="/favicon.png" sizes="48x48" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" /> @@ -37,13 +37,13 @@ <style> body { margin: 0; - background: #f1f5f9; - color: #0f172a; + background: #F7FAFE; + color: #1F2B40; } html.dark body { - background: #0f172a; - color: #f8fafc; + background: #0B1426; + color: #EEF4FF; } #root > .app-startup-shell, @@ -54,36 +54,66 @@ min-height: 100dvh; padding: 24px; box-sizing: border-box; - font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-family: ui-rounded, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } .app-startup-shell__card { - width: min(100%, 28rem); + width: min(100%, 24rem); padding: 28px; - border: 1px solid #cbd5e1; - border-radius: 12px; - background: #f8fafc; - box-shadow: 0 1px 3px rgb(15 23 42 / 0.08); + border: 2px solid #E2E9F2; + border-radius: 24px; + background: #FFFFFF; + box-shadow: 0 1px 2px rgb(11 42 110 / 0.06), 0 2px 6px rgb(11 42 110 / 0.06); text-align: center; } html.dark .app-startup-shell__card { - border-color: #334155; - background: #1e293b; - box-shadow: 0 1px 3px rgb(0 0 0 / 0.24); + border-color: #24324D; + background: #131E33; + box-shadow: 0 2px 6px rgb(0 0 0 / 0.35); + } + + .app-startup-shell__mark { + display: block; + width: 56px; + height: 56px; + margin: 0 auto 12px; + border-radius: 16px; + animation: startup-bob 2.4s cubic-bezier(0.2, 0, 0, 1) infinite; } .app-startup-shell__brand { - color: #0284c7; - font-size: 0.75rem; - font-weight: 700; - letter-spacing: 0.16em; - text-transform: uppercase; + color: #0B2A6E; + font-size: 1.125rem; + font-weight: 800; + letter-spacing: -0.01em; + } + + html.dark .app-startup-shell__brand { + color: #FFFFFF; } .app-startup-shell__status { - margin: 12px 0 0; - font-size: 1rem; + margin: 6px 0 0; + color: #526380; + font-size: 0.9375rem; + font-weight: 600; + } + + html.dark .app-startup-shell__status { + color: #A9B7C9; + } + + @keyframes startup-bob { + 50% { + transform: translateY(-4px); + } + } + + @media (prefers-reduced-motion: reduce) { + .app-startup-shell__mark { + animation: none; + } } #root > .app-bootstrap-error { @@ -101,13 +131,19 @@ .app-bootstrap-error button { border: 0; - border-radius: 8px; - padding: 10px 16px; - background: #0284c7; - color: #f8fafc; + border-radius: 18px; + padding: 12px 20px; + background: #0A6FF0; + box-shadow: 0 4px 0 #0342A8; + color: #FFFFFF; cursor: pointer; font: inherit; - font-weight: 600; + font-weight: 700; + } + + .app-bootstrap-error button:active { + transform: translateY(4px); + box-shadow: 0 0 0 #0342A8; } </style> </head> @@ -116,6 +152,7 @@ <div id="root"> <main class="app-startup-shell" data-startup-shell aria-busy="true" aria-label="DebateLab is loading"> <div class="app-startup-shell__card"> + <img class="app-startup-shell__mark" src="/owl-mark.svg" alt="" width="56" height="56" /> <span class="app-startup-shell__brand">DebateLab</span> <p class="app-startup-shell__status" data-startup-status role="status">Loading DebateLab</p> </div> diff --git a/package-lock.json b/package-lock.json index 486535abe..4591a4077 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,9 @@ "packages/*" ], "dependencies": { - "@fontsource-variable/inter": "5.3.0", + "@fontsource-variable/fredoka": "5.3.0", + "@fontsource-variable/jetbrains-mono": "5.3.0", + "@fontsource-variable/nunito": "5.3.0", "@hocuspocus/provider": "4.7.0", "@hocuspocus/server": "4.7.0", "@hookform/resolvers": "5.4.0", @@ -1207,10 +1209,28 @@ "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, - "node_modules/@fontsource-variable/inter": { + "node_modules/@fontsource-variable/fredoka": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", - "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==", + "resolved": "https://registry.npmjs.org/@fontsource-variable/fredoka/-/fredoka-5.3.0.tgz", + "integrity": "sha512-13w98iw2RcmYfZQRZPLSu+0YJK3HHm5A2XrpU26moFIAePmfiKfvkmEJUgkc8r5GNR+pbDijOueS1KdGAMAWmQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/nunito": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/nunito/-/nunito-5.3.0.tgz", + "integrity": "sha512-tw0ch7evIDSwoeUy+0XzocYHfbRWLeCRPJy22KgbrLjDLAbNw/fErBiil10Ssrs3Sjr55zaZrr36a975j8cBkQ==", "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" diff --git a/package.json b/package.json index cb0a11eb3..e30b4bd99 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,9 @@ "verify:full": "npm run verify && P=$(bash .superset/e2e-port.sh) && CI=1 PORT=$P PLAYWRIGHT_BASE_URL=http://127.0.0.1:$P npm run test:e2e" }, "dependencies": { - "@fontsource-variable/inter": "5.3.0", + "@fontsource-variable/fredoka": "5.3.0", + "@fontsource-variable/jetbrains-mono": "5.3.0", + "@fontsource-variable/nunito": "5.3.0", "@hocuspocus/provider": "4.7.0", "@hocuspocus/server": "4.7.0", "@hookform/resolvers": "5.4.0", diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index 218c8f311..371dec1f9 100644 Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ diff --git a/public/favicon-72.png b/public/favicon-72.png index e6b54f6a6..4d983bda4 100644 Binary files a/public/favicon-72.png and b/public/favicon-72.png differ diff --git a/public/favicon.ico b/public/favicon.ico index 33b462e80..57fc1fb77 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/favicon.png b/public/favicon.png index 53dfdecfc..3c7bdfc45 100644 Binary files a/public/favicon.png and b/public/favicon.png differ diff --git a/public/icon-1024.png b/public/icon-1024.png index 984154c14..3af3b17aa 100644 Binary files a/public/icon-1024.png and b/public/icon-1024.png differ diff --git a/public/icon-192.png b/public/icon-192.png index 817965b81..50189af7e 100644 Binary files a/public/icon-192.png and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png index 30fc2d436..db08e01df 100644 Binary files a/public/icon-512.png and b/public/icon-512.png differ diff --git a/public/icon-maskable-512.png b/public/icon-maskable-512.png index f571bce29..843f8f2e8 100644 Binary files a/public/icon-maskable-512.png and b/public/icon-maskable-512.png differ diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest index 18300048d..ee702fec5 100644 --- a/public/manifest.webmanifest +++ b/public/manifest.webmanifest @@ -6,8 +6,8 @@ "start_url": "/app", "scope": "/", "display": "standalone", - "background_color": "#0f172a", - "theme_color": "#0f172a", + "background_color": "#F7FAFE", + "theme_color": "#F7FAFE", "icons": [ { "src": "/icon-192.png", diff --git a/public/owl-mark.svg b/public/owl-mark.svg new file mode 100644 index 000000000..f4bb0271a --- /dev/null +++ b/public/owl-mark.svg @@ -0,0 +1,26 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><title>DebateLab owl + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/seo-pages.css b/public/seo-pages.css index f639e4a08..fd3b0fb94 100644 --- a/public/seo-pages.css +++ b/public/seo-pages.css @@ -1 +1 @@ -:root{color-scheme:dark;--bg:#070a12;--panel:#101827;--text:#f8fafc;--muted:#b6c2d6;--brand:#7c5cff;--brand2:#22d3ee;--line:rgba(255,255,255,.14)}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 18% 8%,rgba(124,92,255,.30),transparent 34rem),radial-gradient(circle at 85% 18%,rgba(34,211,238,.18),transparent 28rem),var(--bg);color:var(--text);font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;line-height:1.65}a{color:inherit}.wrap{width:min(1120px,92vw);margin:auto}.nav{display:flex;justify-content:space-between;align-items:center;gap:18px;padding:24px 0}.brand{font-weight:850;letter-spacing:-.04em;font-size:1.25rem;text-decoration:none}.nav div{display:flex;gap:18px;flex-wrap:wrap;justify-content:flex-end}.nav a{color:var(--muted);text-decoration:none}.hero{padding:54px 0 34px}.eyebrow{display:inline-flex;border:1px solid var(--line);border-radius:999px;padding:8px 12px;color:#c4b5fd;background:rgba(124,92,255,.12);font-size:.92rem}.hero h1{max-width:940px;font-size:clamp(2.35rem,6vw,5.15rem);line-height:1;letter-spacing:-.07em;margin:22px 0}.lead{max-width:800px;color:var(--muted);font-size:clamp(1.08rem,1.8vw,1.28rem)}.cta{display:flex;gap:14px;flex-wrap:wrap;margin-top:28px}.btn{display:inline-flex;align-items:center;justify-content:center;border-radius:14px;padding:14px 20px;font-weight:750;text-decoration:none}.primary{background:linear-gradient(135deg,var(--brand),var(--brand2));color:white}.secondary{border:1px solid var(--line);background:rgba(255,255,255,.06)}.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:18px;margin:34px 0}.card{border:1px solid var(--line);border-radius:24px;background:rgba(16,24,39,.78);padding:24px;box-shadow:0 24px 80px rgba(0,0,0,.24)}.card h2,.card h3{letter-spacing:-.035em;line-height:1.12;margin-top:0}.muted{color:var(--muted)}.section{padding:34px 0}.article{max-width:840px}.article h2{font-size:clamp(1.65rem,3vw,2.35rem);letter-spacing:-.04em;line-height:1.14;margin-top:2rem}.article h3{font-size:1.2rem;letter-spacing:-.025em}.links{display:grid;grid-template-columns:repeat(2,1fr);gap:12px;margin:28px 0}.links a{border:1px solid var(--line);border-radius:16px;padding:14px;text-decoration:none;background:rgba(255,255,255,.05)}details{border:1px solid var(--line);border-radius:18px;padding:16px 18px;margin:12px 0;background:rgba(255,255,255,.05)}summary{cursor:pointer;font-weight:750}.footer{border-top:1px solid var(--line);color:var(--muted);padding:28px 0;margin-top:42px}.breadcrumb{color:var(--muted);font-size:.94rem;margin-top:8px}.dates{color:var(--muted);font-size:.94rem;margin:18px 0 0}.answer{max-width:840px;border:1px solid var(--line);border-left:4px solid var(--brand);border-radius:20px;background:rgba(16,24,39,.78);padding:22px 26px}.answer h2{font-size:clamp(1.35rem,2.4vw,1.8rem);letter-spacing:-.035em;line-height:1.18;margin:0 0 10px}.answer p{margin:0}.table{overflow-x:auto;margin:18px 0}table{width:100%;border-collapse:collapse;font-size:.96rem}caption{text-align:left;color:var(--muted);padding-bottom:8px}th,td{border-bottom:1px solid var(--line);padding:10px 12px;text-align:left;vertical-align:top}th{color:var(--muted);font-weight:700}@media(max-width:760px){.nav{align-items:flex-start;flex-direction:column}.nav div{justify-content:flex-start}.grid,.links{grid-template-columns:1fr}.table thead{display:none}.table tr{display:block;padding:10px 0;border-bottom:1px solid var(--line)}.table td{display:block;border:0;padding:3px 0}.table td::before{content:attr(data-label);display:block;color:var(--muted);font-size:.85rem;font-weight:700}.hero{padding-top:34px}.hero h1{letter-spacing:-.045em}} +:root{color-scheme:dark;--bg:#0B1426;--panel:#131E33;--text:#EEF4FF;--muted:#A9B7C9;--brand:#0A6FF0;--brand2:#FFC414;--line:rgba(255,255,255,.14)}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 18% 8%,rgba(30,141,251,.28),transparent 34rem),radial-gradient(circle at 85% 18%,rgba(255,196,20,.14),transparent 28rem),var(--bg);color:var(--text);font-family:ui-rounded,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;line-height:1.65}a{color:inherit}.wrap{width:min(1120px,92vw);margin:auto}.nav{display:flex;justify-content:space-between;align-items:center;gap:18px;padding:24px 0}.brand{font-weight:850;letter-spacing:-.04em;font-size:1.25rem;text-decoration:none}.nav div{display:flex;gap:18px;flex-wrap:wrap;justify-content:flex-end}.nav a{color:var(--muted);text-decoration:none}.hero{padding:54px 0 34px}.eyebrow{display:inline-flex;border:1px solid var(--line);border-radius:999px;padding:8px 12px;color:#7CC0FE;background:rgba(10,111,240,.14);font-size:.92rem}.hero h1{max-width:940px;font-size:clamp(2.35rem,6vw,5.15rem);line-height:1;letter-spacing:-.07em;margin:22px 0}.lead{max-width:800px;color:var(--muted);font-size:clamp(1.08rem,1.8vw,1.28rem)}.cta{display:flex;gap:14px;flex-wrap:wrap;margin-top:28px}.btn{display:inline-flex;align-items:center;justify-content:center;border-radius:14px;padding:14px 20px;font-weight:750;text-decoration:none}.primary{background:var(--brand);color:white;box-shadow:0 5px 0 #02307E}.primary:active{transform:translateY(5px);box-shadow:none}.secondary{border:1px solid var(--line);background:rgba(255,255,255,.06)}.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:18px;margin:34px 0}.card{border:2px solid var(--line);border-radius:24px;background:rgba(19,30,51,.85);padding:24px;box-shadow:0 24px 80px rgba(0,0,0,.24)}.card h2,.card h3{letter-spacing:-.035em;line-height:1.12;margin-top:0}.muted{color:var(--muted)}.section{padding:34px 0}.article{max-width:840px}.article h2{font-size:clamp(1.65rem,3vw,2.35rem);letter-spacing:-.04em;line-height:1.14;margin-top:2rem}.article h3{font-size:1.2rem;letter-spacing:-.025em}.links{display:grid;grid-template-columns:repeat(2,1fr);gap:12px;margin:28px 0}.links a{border:1px solid var(--line);border-radius:16px;padding:14px;text-decoration:none;background:rgba(255,255,255,.05)}details{border:1px solid var(--line);border-radius:18px;padding:16px 18px;margin:12px 0;background:rgba(255,255,255,.05)}summary{cursor:pointer;font-weight:750}.footer{border-top:1px solid var(--line);color:var(--muted);padding:28px 0;margin-top:42px}.breadcrumb{color:var(--muted);font-size:.94rem;margin-top:8px}.dates{color:var(--muted);font-size:.94rem;margin:18px 0 0}.answer{max-width:840px;border:1px solid var(--line);border-left:4px solid var(--brand);border-radius:20px;background:rgba(19,30,51,.85);padding:22px 26px}.answer h2{font-size:clamp(1.35rem,2.4vw,1.8rem);letter-spacing:-.035em;line-height:1.18;margin:0 0 10px}.answer p{margin:0}.table{overflow-x:auto;margin:18px 0}table{width:100%;border-collapse:collapse;font-size:.96rem}caption{text-align:left;color:var(--muted);padding-bottom:8px}th,td{border-bottom:1px solid var(--line);padding:10px 12px;text-align:left;vertical-align:top}th{color:var(--muted);font-weight:700}@media(max-width:760px){.nav{align-items:flex-start;flex-direction:column}.nav div{justify-content:flex-start}.grid,.links{grid-template-columns:1fr}.table thead{display:none}.table tr{display:block;padding:10px 0;border-bottom:1px solid var(--line)}.table td{display:block;border:0;padding:3px 0}.table td::before{content:attr(data-label);display:block;color:var(--muted);font-size:.85rem;font-weight:700}.hero{padding-top:34px}.hero h1{letter-spacing:-.045em}} diff --git a/public/sw.js b/public/sw.js index c73b926ba..48fdaa1ed 100644 --- a/public/sw.js +++ b/public/sw.js @@ -23,6 +23,7 @@ const APP_SHELL = [ '/icon-192.png', '/icon-512.png', '/icon-maskable-512.png', + '/owl-mark.svg', ]; // vercel.json serves app-shell.html at these paths, so offline the worker does too. diff --git a/public/theme-init.js b/public/theme-init.js index 049b005b9..2a9a346bd 100644 --- a/public/theme-init.js +++ b/public/theme-init.js @@ -40,9 +40,10 @@ try { var settings = JSON.parse(localStorage.getItem('debate-trainer-settings') || '{}'); var theme = settings && settings.state && settings.state.theme; + // Light is the default; only an explicit System choice follows the device. if (theme === 'dark') document.documentElement.classList.add('dark'); - else if (theme === 'light') document.documentElement.classList.add('light'); - else if (window.matchMedia('(prefers-color-scheme: dark)').matches) document.documentElement.classList.add('dark'); + else if (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) document.documentElement.classList.add('dark'); + else document.documentElement.classList.add('light'); } catch { // Ignore unavailable or malformed browser storage and use the default theme. } diff --git a/scripts/generate_seo_content.py b/scripts/generate_seo_content.py index 2dca7faa6..3e551facb 100644 --- a/scripts/generate_seo_content.py +++ b/scripts/generate_seo_content.py @@ -41,7 +41,7 @@ ANSWER_MAX_WORDS = 100 CSS = """ -:root{color-scheme:dark;--bg:#070a12;--panel:#101827;--text:#f8fafc;--muted:#b6c2d6;--brand:#7c5cff;--brand2:#22d3ee;--line:rgba(255,255,255,.14)}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 18% 8%,rgba(124,92,255,.30),transparent 34rem),radial-gradient(circle at 85% 18%,rgba(34,211,238,.18),transparent 28rem),var(--bg);color:var(--text);font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;line-height:1.65}a{color:inherit}.wrap{width:min(1120px,92vw);margin:auto}.nav{display:flex;justify-content:space-between;align-items:center;gap:18px;padding:24px 0}.brand{font-weight:850;letter-spacing:-.04em;font-size:1.25rem;text-decoration:none}.nav div{display:flex;gap:18px;flex-wrap:wrap;justify-content:flex-end}.nav a{color:var(--muted);text-decoration:none}.hero{padding:54px 0 34px}.eyebrow{display:inline-flex;border:1px solid var(--line);border-radius:999px;padding:8px 12px;color:#c4b5fd;background:rgba(124,92,255,.12);font-size:.92rem}.hero h1{max-width:940px;font-size:clamp(2.35rem,6vw,5.15rem);line-height:1;letter-spacing:-.07em;margin:22px 0}.lead{max-width:800px;color:var(--muted);font-size:clamp(1.08rem,1.8vw,1.28rem)}.cta{display:flex;gap:14px;flex-wrap:wrap;margin-top:28px}.btn{display:inline-flex;align-items:center;justify-content:center;border-radius:14px;padding:14px 20px;font-weight:750;text-decoration:none}.primary{background:linear-gradient(135deg,var(--brand),var(--brand2));color:white}.secondary{border:1px solid var(--line);background:rgba(255,255,255,.06)}.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:18px;margin:34px 0}.card{border:1px solid var(--line);border-radius:24px;background:rgba(16,24,39,.78);padding:24px;box-shadow:0 24px 80px rgba(0,0,0,.24)}.card h2,.card h3{letter-spacing:-.035em;line-height:1.12;margin-top:0}.muted{color:var(--muted)}.section{padding:34px 0}.article{max-width:840px}.article h2{font-size:clamp(1.65rem,3vw,2.35rem);letter-spacing:-.04em;line-height:1.14;margin-top:2rem}.article h3{font-size:1.2rem;letter-spacing:-.025em}.links{display:grid;grid-template-columns:repeat(2,1fr);gap:12px;margin:28px 0}.links a{border:1px solid var(--line);border-radius:16px;padding:14px;text-decoration:none;background:rgba(255,255,255,.05)}details{border:1px solid var(--line);border-radius:18px;padding:16px 18px;margin:12px 0;background:rgba(255,255,255,.05)}summary{cursor:pointer;font-weight:750}.footer{border-top:1px solid var(--line);color:var(--muted);padding:28px 0;margin-top:42px}.breadcrumb{color:var(--muted);font-size:.94rem;margin-top:8px}.dates{color:var(--muted);font-size:.94rem;margin:18px 0 0}.answer{max-width:840px;border:1px solid var(--line);border-left:4px solid var(--brand);border-radius:20px;background:rgba(16,24,39,.78);padding:22px 26px}.answer h2{font-size:clamp(1.35rem,2.4vw,1.8rem);letter-spacing:-.035em;line-height:1.18;margin:0 0 10px}.answer p{margin:0}.table{overflow-x:auto;margin:18px 0}table{width:100%;border-collapse:collapse;font-size:.96rem}caption{text-align:left;color:var(--muted);padding-bottom:8px}th,td{border-bottom:1px solid var(--line);padding:10px 12px;text-align:left;vertical-align:top}th{color:var(--muted);font-weight:700}@media(max-width:760px){.nav{align-items:flex-start;flex-direction:column}.nav div{justify-content:flex-start}.grid,.links{grid-template-columns:1fr}.table thead{display:none}.table tr{display:block;padding:10px 0;border-bottom:1px solid var(--line)}.table td{display:block;border:0;padding:3px 0}.table td::before{content:attr(data-label);display:block;color:var(--muted);font-size:.85rem;font-weight:700}.hero{padding-top:34px}.hero h1{letter-spacing:-.045em}} +:root{color-scheme:dark;--bg:#0B1426;--panel:#131E33;--text:#EEF4FF;--muted:#A9B7C9;--brand:#0A6FF0;--brand2:#FFC414;--line:rgba(255,255,255,.14)}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 18% 8%,rgba(30,141,251,.28),transparent 34rem),radial-gradient(circle at 85% 18%,rgba(255,196,20,.14),transparent 28rem),var(--bg);color:var(--text);font-family:ui-rounded,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;line-height:1.65}a{color:inherit}.wrap{width:min(1120px,92vw);margin:auto}.nav{display:flex;justify-content:space-between;align-items:center;gap:18px;padding:24px 0}.brand{font-weight:850;letter-spacing:-.04em;font-size:1.25rem;text-decoration:none}.nav div{display:flex;gap:18px;flex-wrap:wrap;justify-content:flex-end}.nav a{color:var(--muted);text-decoration:none}.hero{padding:54px 0 34px}.eyebrow{display:inline-flex;border:1px solid var(--line);border-radius:999px;padding:8px 12px;color:#7CC0FE;background:rgba(10,111,240,.14);font-size:.92rem}.hero h1{max-width:940px;font-size:clamp(2.35rem,6vw,5.15rem);line-height:1;letter-spacing:-.07em;margin:22px 0}.lead{max-width:800px;color:var(--muted);font-size:clamp(1.08rem,1.8vw,1.28rem)}.cta{display:flex;gap:14px;flex-wrap:wrap;margin-top:28px}.btn{display:inline-flex;align-items:center;justify-content:center;border-radius:14px;padding:14px 20px;font-weight:750;text-decoration:none}.primary{background:var(--brand);color:white;box-shadow:0 5px 0 #02307E}.primary:active{transform:translateY(5px);box-shadow:none}.secondary{border:1px solid var(--line);background:rgba(255,255,255,.06)}.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:18px;margin:34px 0}.card{border:2px solid var(--line);border-radius:24px;background:rgba(19,30,51,.85);padding:24px;box-shadow:0 24px 80px rgba(0,0,0,.24)}.card h2,.card h3{letter-spacing:-.035em;line-height:1.12;margin-top:0}.muted{color:var(--muted)}.section{padding:34px 0}.article{max-width:840px}.article h2{font-size:clamp(1.65rem,3vw,2.35rem);letter-spacing:-.04em;line-height:1.14;margin-top:2rem}.article h3{font-size:1.2rem;letter-spacing:-.025em}.links{display:grid;grid-template-columns:repeat(2,1fr);gap:12px;margin:28px 0}.links a{border:1px solid var(--line);border-radius:16px;padding:14px;text-decoration:none;background:rgba(255,255,255,.05)}details{border:1px solid var(--line);border-radius:18px;padding:16px 18px;margin:12px 0;background:rgba(255,255,255,.05)}summary{cursor:pointer;font-weight:750}.footer{border-top:1px solid var(--line);color:var(--muted);padding:28px 0;margin-top:42px}.breadcrumb{color:var(--muted);font-size:.94rem;margin-top:8px}.dates{color:var(--muted);font-size:.94rem;margin:18px 0 0}.answer{max-width:840px;border:1px solid var(--line);border-left:4px solid var(--brand);border-radius:20px;background:rgba(19,30,51,.85);padding:22px 26px}.answer h2{font-size:clamp(1.35rem,2.4vw,1.8rem);letter-spacing:-.035em;line-height:1.18;margin:0 0 10px}.answer p{margin:0}.table{overflow-x:auto;margin:18px 0}table{width:100%;border-collapse:collapse;font-size:.96rem}caption{text-align:left;color:var(--muted);padding-bottom:8px}th,td{border-bottom:1px solid var(--line);padding:10px 12px;text-align:left;vertical-align:top}th{color:var(--muted);font-weight:700}@media(max-width:760px){.nav{align-items:flex-start;flex-direction:column}.nav div{justify-content:flex-start}.grid,.links{grid-template-columns:1fr}.table thead{display:none}.table tr{display:block;padding:10px 0;border-bottom:1px solid var(--line)}.table td{display:block;border:0;padding:3px 0}.table td::before{content:attr(data-label);display:block;color:var(--muted);font-size:.85rem;font-weight:700}.hero{padding-top:34px}.hero h1{letter-spacing:-.045em}} """.strip() PAGES = [ diff --git a/scripts/test-service-worker-offline.mjs b/scripts/test-service-worker-offline.mjs index dc9ca6ff6..d540f663a 100644 --- a/scripts/test-service-worker-offline.mjs +++ b/scripts/test-service-worker-offline.mjs @@ -150,6 +150,8 @@ async function main() { state: { hasCompletedOnboarding: true, isTourRunning: false }, version: 0, })); + // A production build opens the one-time DebateOwl welcome; this test is not about it. + localStorage.setItem('debateowl_intro_seen_v1', 'test-sw'); }, stubSession()); return nextContext; }; @@ -222,7 +224,7 @@ async function main() { (await (await caches.open(key)).keys()).map((request) => new URL(request.url).pathname) ))).flat(); return paths.every((path) => urls.includes(path)); - }, [entryPath, ...precachedPaths, '/app-shell.html', '/terms', '/favicon-72.png'], 'the precache and the visited Terms page'); + }, [entryPath, ...precachedPaths, '/app-shell.html', '/terms', '/owl-mark.svg'], 'the precache and the visited Terms page'); // Installed up front, not picked up by browsing: they sit in this build's shell cache. const shellCache = await page.evaluate(async () => { const key = (await caches.keys()).find((name) => name.endsWith('-shell')); @@ -241,7 +243,7 @@ async function main() { await homeHeading(page).waitFor({ timeout: CONTENT_TIMEOUT_MS }); await page.getByRole('status').filter({ hasText: en.offlineIndicator.label }).waitFor({ timeout: CONTENT_TIMEOUT_MS }); const logo = page.locator('header a[href="/app"] img'); - assert.equal(await logo.getAttribute('src'), '/favicon-72.png'); + assert.equal(await logo.getAttribute('src'), '/owl-mark.svg'); assert.ok(await logo.evaluate((image) => image.naturalWidth > 0), 'offline logo did not load'); await page.locator('main [data-tour="mode-attack"]').click(); diff --git a/src/App.tsx b/src/App.tsx index 7716d118f..1c3e0dd2c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,6 @@ // src/App.tsx +import { Owl } from "@/components/brand/Owl"; +import { buttonVariants } from "@/components/ui/button"; import { Component, Suspense, lazy, useEffect, useRef, useState, type ComponentType, type ErrorInfo, type ReactNode } from "react"; import { Toaster } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -12,6 +14,7 @@ import { AccountQueryCache } from "@/components/auth/AccountQueryCache"; import { ThemeProvider } from "@/components/ThemeProvider"; import { useSessionStorageSync } from "@/hooks/use-session-storage-sync"; import { hasCompletedOnboarding, useOnboardingStore } from "@/hooks/use-onboarding"; +import { browserStorage, markIntroSeen, shouldShowIntro } from "@/lib/debateowl-intro"; import { OfflineIndicator } from "@/components/OfflineIndicator"; import { StagingBanner } from "@/components/StagingBanner"; import { captureException, captureProductEvent, toTelemetrySafeError } from "@/lib/observability"; @@ -86,6 +89,8 @@ const AppLayout = lazy(() => { }); const OnboardingTour = lazy(() => import("@/components/features/onboarding/OnboardingTour") .then(({ OnboardingTour: Tour }) => ({ default: Tour }))); +const DebateOwlIntro = lazy(() => import("@/components/features/onboarding/DebateOwlIntro") + .then(({ DebateOwlIntro: Intro }) => ({ default: Intro }))); // TanStack Query refetches after an `offline` then `online` pair, but a page // opened offline never sees `offline`. Retry whatever failed once the @@ -199,14 +204,15 @@ export class RouteErrorBoundary extends Component -

{this.props.heading ?? 'Unable to load this page'}

-
- {/* A full navigation, so a broken route can't trap the user. */} - - {this.props.homeLabel ?? 'Go to Home'} + + {this.props.homeLabel ?? 'Go to home'}
@@ -278,6 +284,37 @@ export function RouteContent({ children }: { children: ReactNode }) { ); } +/** + * The one-time DebateOwl welcome opens over the signed-in app once its layout has loaded. + * The onboarding tour waits for it to close; without the welcome the tour mounts as before. + */ +function AppWelcome() { + const [introOpen, setIntroOpen] = useState(() => shouldShowIntro({ + search: window.location.search, + storage: browserStorage(), + env: import.meta.env, + })); + const [layoutReady, setLayoutReady] = useState(false); + + useEffect(() => { + if (!introOpen) return; + let live = true; + appLayoutLoader().then(() => { if (live) setLayoutReady(true); }, (): void => undefined); + return () => { live = false; }; + }, [introOpen]); + + if (!introOpen) return ; + if (!layoutReady) return null; + return ( + + { + markIntroSeen(browserStorage()); + setIntroOpen(false); + }} /> + + ); +} + function OptionalOnboardingTour() { const location = useLocation(); const userId = useAuth().user?.id; @@ -367,7 +404,7 @@ function AppRoutes() { element={ }> - + }> diff --git a/src/assets/owl/owl-arguing.svg b/src/assets/owl/owl-arguing.svg new file mode 100644 index 000000000..946cdcea7 --- /dev/null +++ b/src/assets/owl/owl-arguing.svg @@ -0,0 +1,26 @@ +DebateOwl, arguing + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/owl/owl-celebrating.svg b/src/assets/owl/owl-celebrating.svg new file mode 100644 index 000000000..b965080d9 --- /dev/null +++ b/src/assets/owl/owl-celebrating.svg @@ -0,0 +1,26 @@ +DebateOwl, celebrating + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/owl/owl-coach.svg b/src/assets/owl/owl-coach.svg new file mode 100644 index 000000000..846253269 --- /dev/null +++ b/src/assets/owl/owl-coach.svg @@ -0,0 +1,31 @@ +DebateOwl, coach + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/owl/owl-idea.svg b/src/assets/owl/owl-idea.svg new file mode 100644 index 000000000..590f6d3f5 --- /dev/null +++ b/src/assets/owl/owl-idea.svg @@ -0,0 +1,26 @@ +DebateOwl, idea, pointing up + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/owl/owl-point.svg b/src/assets/owl/owl-point.svg new file mode 100644 index 000000000..d41ab76d5 --- /dev/null +++ b/src/assets/owl/owl-point.svg @@ -0,0 +1,26 @@ +DebateOwl, pointing + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/owl/owl-reading.svg b/src/assets/owl/owl-reading.svg new file mode 100644 index 000000000..ada112eaf --- /dev/null +++ b/src/assets/owl/owl-reading.svg @@ -0,0 +1,26 @@ +DebateOwl, reading + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/owl/owl-skeptical.svg b/src/assets/owl/owl-skeptical.svg new file mode 100644 index 000000000..62f8d3d85 --- /dev/null +++ b/src/assets/owl/owl-skeptical.svg @@ -0,0 +1,26 @@ +DebateOwl, skeptical, arms crossed + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/owl/owl-sleepy.svg b/src/assets/owl/owl-sleepy.svg new file mode 100644 index 000000000..784417c4b --- /dev/null +++ b/src/assets/owl/owl-sleepy.svg @@ -0,0 +1,26 @@ +DebateOwl, sleepy + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/owl/owl-surprised.svg b/src/assets/owl/owl-surprised.svg new file mode 100644 index 000000000..d6dc9c1dc --- /dev/null +++ b/src/assets/owl/owl-surprised.svg @@ -0,0 +1,26 @@ +DebateOwl, surprised, pointing at you + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/owl/owl-thinking.svg b/src/assets/owl/owl-thinking.svg new file mode 100644 index 000000000..ada1ea62d --- /dev/null +++ b/src/assets/owl/owl-thinking.svg @@ -0,0 +1,26 @@ +DebateOwl, thinking + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/owl/owl-timekeeper.svg b/src/assets/owl/owl-timekeeper.svg new file mode 100644 index 000000000..d2b58af21 --- /dev/null +++ b/src/assets/owl/owl-timekeeper.svg @@ -0,0 +1,26 @@ +DebateOwl, timekeeper + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/LanguageSwitcher.tsx b/src/components/LanguageSwitcher.tsx index 9785a9f91..7216c9d5f 100644 --- a/src/components/LanguageSwitcher.tsx +++ b/src/components/LanguageSwitcher.tsx @@ -13,7 +13,7 @@ const LANGUAGE_ENTRIES = Object.entries(APP_LANGUAGES) as [AppLanguage, (typeof function LanguageChoices({ label, value, onChange }: { label: string; value: AppLanguage; onChange: (language: AppLanguage) => void }) { return (
-

{label}

+

{label}

{LANGUAGE_ENTRIES.map(([code, language]) => { const selected = code === value; return ( @@ -25,13 +25,13 @@ function LanguageChoices({ label, value, onChange }: { label: string; value: App lang={code} onClick={() => onChange(code)} className={cn( - 'flex w-full items-center justify-between rounded-md px-2 py-2 text-left text-sm transition-colors pointer-coarse:min-h-11', - 'hover:bg-accent focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring', - selected && 'font-medium text-primary', + 'flex w-full items-center justify-between rounded-sm px-2.5 py-2 text-left text-[0.9375rem] transition-colors pointer-coarse:min-h-11', + 'hover:bg-accent focus-visible:outline-hidden focus-visible:ring-[3px] focus-visible:ring-ring', + selected && 'bg-brand-tint font-extrabold text-primary', )} > {language.label} - {selected &&