diff --git a/.claude/skills/verify-debatelab/SKILL.md b/.claude/skills/verify-debatelab/SKILL.md index 59197bbef..b231a9b37 100644 --- a/.claude/skills/verify-debatelab/SKILL.md +++ b/.claude/skills/verify-debatelab/SKILL.md @@ -1,14 +1,14 @@ --- name: verify-debatelab -description: Launch and drive the real DebateLab web app (React client + local Supabase Auth/Postgres/Edge Functions, with either a fake model provider or real OpenRouter pinned to the cheap meta/muse-spark-1.3-contributor model plus real Exa research) to prove a user-facing feature works, with screenshots, ARIA snapshots, provider calls, and database rows as evidence. Use when asked to verify, demo, reproduce, or screenshot DebateLab behavior in a browser, or before calling a DebateLab UI/Edge Function change done. +description: Launch and drive the real DebateOwl web app (React client + local Supabase Auth/Postgres/Edge Functions, with either a fake model provider or real OpenRouter pinned to Claude Haiku 5.5 on the Anthropic BYOK key plus real Exa research) to prove a user-facing feature works, with screenshots, ARIA snapshots, provider calls, and database rows as evidence. Use when asked to verify, demo, reproduce, or screenshot DebateOwl behavior in a browser, or before calling a DebateOwl UI/Edge Function change done. --- -# Verify DebateLab +# Verify DebateOwl -DebateLab is a web app. The surface a user touches is the browser client at `/login` and `/app/*`, backed by Supabase Auth, Postgres/RLS, and Deno Edge Functions. This skill runs all of that locally and drives it with Playwright. It has two modes: +DebateOwl is a web app. The surface a user touches is the browser client at `/login` and `/app/*`, backed by Supabase Auth, Postgres/RLS, and Deno Edge Functions. This skill runs all of that locally and drives it with Playwright. It has two modes: - **Fake (default, free).** `e2e/full-stack/fake-openrouter.mjs` answers every model call with canned text. Proves wiring, rows, and UI, not model behavior. -- **Live (`up --live`, costs money).** The same container becomes a recording proxy to real OpenRouter. It rewrites every chat call to `meta/muse-spark-1.3-contributor` (override with `VERIFY_LIVE_MODEL`), records OpenRouter's reply model as proof, and stops forwarding after `--max-calls` (default 400). Edge Functions also get a real Exa key, so web research and verified citations are real. +- **Live (`up --live`, costs money).** The same container becomes a recording proxy to real OpenRouter. It rewrites every chat call to `anthropic/claude-haiku-5.5` pinned to the Anthropic provider with fallbacks off, so it bills the Anthropic BYOK key and not OpenRouter credits (override with `VERIFY_LIVE_MODEL`, which must be an `anthropic/` model), records OpenRouter's reply model as proof, and stops forwarding after `--max-calls` (default 400). Edge Functions also get a real Exa key, so web research and verified citations are real. Everything goes through one helper, run from the repository root (`git rev-parse --show-toplevel`): @@ -42,7 +42,7 @@ STRIPE_TEST_SECRET_KEY=sk_test_... # optional; live keys are refused STRIPE_TEST_PRICE_ID=price_... # required with the Stripe key ``` -It prints nothing about the values. Ask Adam before the first live run: it spends money and sends prompts to a contributor model that may use them for training, so never put real user data in a live run. Costs: Muse Spark 1.3 contributor is $0.10 per million prompt tokens and $0.20 per million completion tokens, so a full `drive all` is cents of model spend. Exa bills per search and is usually the larger part. Jev decisions go to `/api/alpha/decisions` with Jev's own model and are not pinned. `doctor` shows `live calls used N/max`; once the cap is hit the proxy answers 429 and tests fail rather than spend more. +It prints nothing about the values. Ask Adam before the first live run: it spends money (Exa, Jev, and the Anthropic key), so never put real user data in a live run. Model calls bill the Anthropic BYOK key; Exa bills per search. Jev decisions go to `/api/alpha/decisions` with Jev's own model and are not pinned. `doctor` shows `live calls used N/max`; once the cap is hit the proxy answers 429 and tests fail rather than spend more. ## Launch @@ -81,13 +81,15 @@ It also prints the commit the stack started at and whether there are uncommitted - The Current Debate helpers: `startDebate(page, motion)` starts one from the header, `chooseForMotion(page, "create" | "once")` answers the motion dialog, and `debateControl(page)` is the visible header control on either viewport. - The voice helpers: `stubSpeech(page)` installs a scripted microphone, recogniser and browser voice; `say(page, words)` speaks; `spokenByBrowser(page)` reads back what the browser voice said. - `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. -- `aiResponse(page, feature, operation?)`: the parsed body of the next `ai` Edge Function 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. +- `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 @@ -104,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. Closing it continues straight into the tour's Start debate step when that account has not finished the tour. 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 @@ -122,7 +124,7 @@ $H e2e cache ls # recorded agent flo - `e2e` runs `doctor` first and refuses a broken stack, like `drive`. It turns e2e telemetry off and prints `EVIDENCE `, which holds `summary.md`, `report.json`, and per-attempt screenshots, traces, and failure videos. - The suite is tool journeys (tag `journey`) and a page sweep (tag `pages`) of every student and public page in English, Spanish and Hungarian (not `/app/admin` or `/share/:code`). Every test fails on an uncaught error, `console.error` (React warnings included), a Supabase 5xx, horizontal overflow, or a request to a hosted project: `agentic/support/capture-errors.js` records them in the page. A failure that names `errors the page logged` is a real app error to fix, not test noise. -- The testing agent has its own OpenRouter models: `inclusionai/ling-3.0-flash-vl` acts (test steps on a cache miss, and explorations) and `meta/muse-spark-1.3-contributor` judges. `E2E_AGENT_MODEL` and `E2E_JUDGE_MODEL` override them; the model is not part of the replay key. The key comes from `OPENROUTER_API_KEY`, `LIVE_OPENROUTER_API_KEY`, or the live keys file, and is needed in fake mode too. A recorded `agent.act` replays with no model call; every judgment and every exploration step is a call. Without a key, agent tests skip. An OpenRouter error such as `[Meta] The requested model was not found` is infrastructure: rerun with `--last-failed`. The Muse Spark judge cannot act. +- The testing agent has its own OpenRouter models: `anthropic/claude-haiku-5.5` acts (test steps on a cache miss, and explorations) and `anthropic/claude-sonnet-5.5` judges, both pinned to the Anthropic provider (BYOK) with fallbacks off. Haiku is not reliable enough to judge screens: it reads content below the fold as cut off. `E2E_AGENT_MODEL` and `E2E_JUDGE_MODEL` override them but must be `anthropic/` models; the model is not part of the replay key. The key comes from `OPENROUTER_API_KEY`, `LIVE_OPENROUTER_API_KEY`, or the live keys file, and is needed in fake mode too. A recorded `agent.act` replays with no model call; every judgment and every exploration step is a call. Without a key, agent tests skip. An OpenRouter error naming Anthropic (an outage, or the key out of credit) is infrastructure: rerun with `--last-failed`. - `--session student` starts an exploration signed in as a fresh demo user, minted by `agentic/sessions.e2e.ts`. `--agent` picks a persona: `first-timer`, `coach`, `edge-input`, or `hungarian`. `--target mobile` explores the phone-sized viewport. - Explorations report `issue` (functional) and `warning` (cosmetic) findings, and exit 1 when there is an issue. Treat a finding as a lead: the stack serves Vite dev, so a route that is still compiling can look dead, and fake mode answers every motion with free-transport text. Prove a finding with a repro test tagged `repro` before you report it as a bug. - Several explorations can run at once (a bug bash), because each gets its own evidence directory. They share this one stack, so give each a different area. @@ -172,6 +174,8 @@ It never touches `.verify/artifacts/` or `.verify/logs/`, and it is safe to run ## Gotchas - Live runs are slow: sparring and crossfire take several minutes each because the model must reason. Use `-g` to run one test while iterating. +- Tailwind v4 is configured in `src/index.css` (`@theme`, `@custom-variant`, `@utility`); there is no `tailwind.config.ts`. Edits to `src/` and `src/index.css` apply on reload. +- The fake provider streams a structured answer in 24-character chunks 10 ms apart when the request asks to stream, so a Generate draft is on screen for about a second in fake mode; `fake.streamDelay(ms)` slows it for one test (the next reset restores it). Short replies (a Crossfire turn) finish in well under that, so prove those with `expectStreamed` rather than by catching the draft. - The fake provider rejects prompts it doesn't recognize. If a prompt change makes a feature fail with a provider error, update `e2e/full-stack/fake-openrouter.mjs`. Don't mock around it. - Once, the fake-provider container was SIGKILLed about 20 seconds after `up`, and the cause was never found. Doctor catches this, and `drive` refuses to run until you `down && up`. - Demo-code users are Pro with a daily limit of 999. To test quota or tier paths, change `user_settings` through `admin` inside the recipe, as `e2e/full-stack/case-generation.spec.ts` does. diff --git a/.claude/skills/verify-debatelab/bin/verify.sh b/.claude/skills/verify-debatelab/bin/verify.sh index 661b1c272..f755e2ebe 100755 --- a/.claude/skills/verify-debatelab/bin/verify.sh +++ b/.claude/skills/verify-debatelab/bin/verify.sh @@ -25,6 +25,8 @@ SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REPO="$(git -C "$SKILL_DIR" rev-parse --show-toplevel)" cd "$REPO" source "$REPO/.superset/lib/lock.sh" +source "$REPO/.superset/lib/supabase-cli.sh" +use_pinned_supabase_cli "$REPO" DB_LOCK="$HOME/.superset/debatelab-db.lock" FAKE_PROVIDER=debatelab-fake-openrouter @@ -38,8 +40,11 @@ STATE="$STATE_DIR/stack.env" USERS="$STATE_DIR/users.tsv" ARTIFACTS="$VERIFY_ROOT/artifacts" # evidence; never removed by this script PROJECT_ID="$(basename "$REPO")" # supabase/config.toml has no project_id -# Adam's standing choice for live verification: the cheap contributor tier. -LIVE_MODEL="${VERIFY_LIVE_MODEL:-meta/muse-spark-1.3-contributor}" +# Live verification runs on Claude Haiku through DebateLab's Anthropic key +# (BYOK in OpenRouter). Other families bill OpenRouter credits, so they are +# refused; the proxy pins the rewritten call to the Anthropic provider. +LIVE_MODEL="${VERIFY_LIVE_MODEL:-anthropic/claude-haiku-5.5}" +case "$LIVE_MODEL" in anthropic/*) ;; *) printf 'FAIL VERIFY_LIVE_MODEL must be an anthropic/ model (Anthropic BYOK), not %s\n' "$LIVE_MODEL" >&2; exit 1 ;; esac LIVE_KEYS_FILE="${VERIFY_LIVE_KEYS_FILE:-$HOME/.config/debatelab/live-verify.env}" say() { printf '%s\n' "$*"; } @@ -297,7 +302,7 @@ cmd_e2e() { [ -n "$key" ] && export OPENROUTER_API_KEY="$key" fi if [ -n "${OPENROUTER_API_KEY:-}" ]; then - say "info testing agent: OpenRouter actor ${E2E_AGENT_MODEL:-inclusionai/ling-3.0-flash-vl}, judge ${E2E_JUDGE_MODEL:-meta/muse-spark-1.3-contributor} (replayed steps make no model call)" + say "info testing agent: OpenRouter actor ${E2E_AGENT_MODEL:-anthropic/claude-haiku-5.5}, judge ${E2E_JUDGE_MODEL:-anthropic/claude-sonnet-5.5}, Anthropic BYOK only (replayed steps make no model call)" else say "info no OpenRouter key: tests with agent steps will skip" fi diff --git a/.claude/skills/verify-debatelab/features/README.md b/.claude/skills/verify-debatelab/features/README.md index 89c97d1a2..3bcb4d850 100644 --- a/.claude/skills/verify-debatelab/features/README.md +++ b/.claude/skills/verify-debatelab/features/README.md @@ -7,8 +7,8 @@ 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. -- 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 `meta/muse-spark-1.3-contributor`, research uses real Exa, and answers are real model text. +- 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. ## Driving conventions @@ -39,17 +39,21 @@ Each feature file starts with an H1 title and one paragraph describing the user- ## Features -- [Demo-code sign-in](./auth-demo-login.md): sign-in, reload, rejected code, sign-out (`e2e/full-stack/auth.spec.ts`), and email one-time-code sign-in through Mailpit (`recipes/auth.spec.ts`). -- [Case generation](./case-generation.md): generate, web research, save and reopen from History on a fresh device, PDF and DOCX export, single-section regeneration, provider failure and retry, quota block (`recipes/case-generation.spec.ts`). +- [Demo-code sign-in](./auth-demo-login.md): sign-in, reload, rejected code, sign-out (`e2e/full-stack/auth.spec.ts`); email one-time-code sign-in through Mailpit, the emailed sign-in link through `/auth/callback` (including after a failed resend, and a used link), a signed-out deep link through a wrong code, the resend countdown and back, a second account on the same browser, and the same account after Sign out on Settings landing on `/app` (`recipes/auth.spec.ts`). +- [Case generation](./case-generation.md): generate, web research, save and reopen from History on a fresh device, PDF and DOCX export, single-section regeneration, building a flowchart from the case, provider failure and retry, a reply cut off at its length cap, quota block (`recipes/case-generation.spec.ts`). - [Library sources](./library-sources.md): add, pause, delete, grounding, and URL import (`recipes/library.spec.ts`). -- [AI tools](./ai-tools.md): Attack, Brainstorm, Factoids, and Ask streamed, cited, and with web search (`recipes/ai-tools.spec.ts`). +- [AI tools](./ai-tools.md): Attack, Brainstorm, Factoids, and Ask streamed, cut off at its length cap, cited, with web search, and the student's bubble contrast with the Hungarian layout at 375 px (`recipes/ai-tools.spec.ts`). +- [Live generation](./live-generation.md): every AI tool shows its answer while the model writes it, Cancel mid-stream, and the event stream's shape (`recipes/live-generation.spec.ts`); Crossfire and Sparring requests stream too (`recipes/practice.spec.ts`). - [Current Debate](./current-debate.md): start, the first-debate offer and the different-motion choice, sources and results scoped to a debate, switching, and the first onboarding step (`recipes/current-debate.spec.ts`); debate context review and pins (`e2e/full-stack/debate-context.spec.ts`). -- [Practice](./practice.md): a full Lincoln-Douglas sparring round with verdict, and Crossfire in both directions with debrief, each filed in its debate (`recipes/practice.spec.ts`). +- [Model defaults](./model-defaults.md): Pro accounts start on Claude Sonnet 5.5 with Opus 5.5 as an opt-in, Basic accounts on Claude Haiku 5.5 with Pro models locked, retired model IDs move to their successor, and an Auto choice fits the picker at 375 px (`recipes/model-defaults.spec.ts`). +- [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: output language reaching the model from a new device, flowchart node delete, save and load, and the speech timer (`recipes/account.spec.ts`). -- Billing: expired account blocked, promo code restores access (`recipes/billing.spec.ts`); signed, forged, and replayed Stripe webhooks (`e2e/full-stack/billing.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`). +- [Localization](./localization.md): tab titles per route and language, translated form validation and server refusals, the language switcher on sign-in and 404, and `?lang=` with the `/es` and `/hu` pages (`recipes/localization.spec.ts`). - Admin announcements and the non-admin block (`e2e/full-stack/admin.spec.ts`), public share links (`e2e/full-stack/public.spec.ts`), and Workspaces with live collaboration and invites (`e2e/workspace-collaboration.spec.ts`). -Not yet mapped in recipes: theme and UI language, the public landing and SEO pages, and share-link creation (the app has no client caller for the `share` function). The agentic suite covers UI language switching (`agentic/workspace.e2e.ts`) and renders the public pages in every language (`agentic/pages.e2e.ts`). +Not yet mapped in recipes: theme, the public landing and SEO pages beyond their titles, the free no-login tools under `/tools` and `/embed/debate-timer` (no backend; `e2e/free-tools.spec.ts` covers them), and share-link creation (the app has no client caller for the `share` function). The agentic suite covers UI language switching (`agentic/workspace.e2e.ts`) and renders the public pages in every language (`agentic/pages.e2e.ts`). diff --git a/.claude/skills/verify-debatelab/features/account-data.md b/.claude/skills/verify-debatelab/features/account-data.md index c58a3e1ce..47656e47b 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 Delete data card. ## Driving it with verify.sh + Playwright diff --git a/.claude/skills/verify-debatelab/features/ai-tools.md b/.claude/skills/verify-debatelab/features/ai-tools.md index 7620bed5a..ac37bd377 100644 --- a/.claude/skills/verify-debatelab/features/ai-tools.md +++ b/.claude/skills/verify-debatelab/features/ai-tools.md @@ -8,11 +8,12 @@ The single-shot coaching tools: Attack finds weaknesses in a pasted argument, Br - `brainstorm-run` returns ranked ideas for a motion. Provider feature: `brainstorm`. - `factoids-run` finishes research and shows search suggestions. It drops unverified sources because there is no web key locally. Provider feature: `factoids`. - `ask-stream` streams a coaching answer through `ai-stream`. Provider feature: `ask-stream`, recorded as an `ask` usage row. +- `ask-bubble` measures the student's own message (body, bold, and its time line, test id `message-meta`) against the blue bubble in light and dark: each is at least 4.5:1 (about 5.1 light, 6.7 dark). At 375 px in Hungarian the placeholder `Mit kérdeznél?` fits the field and the chat header wraps instead of overflowing (`recipes/ai-tools.spec.ts`). - `ask-cited` uses the structured endpoint when citation mode is on. Provider feature: `ask`. ## How to get to it (user POV) -- Header links: `Attack` and `Ask`. `More` menu (desktop) or `Open menu` (mobile): `Brainstorm` and `Factoids`. +- Home cards under Tools. The phone tab bar lists Tools, not each tool. - Routes: `/app/attack`, `/app/brainstorm`, `/app/factoids`, `/app/ask`. ## Driving it with verify.sh + Playwright @@ -21,15 +22,20 @@ Preconditions: - Doctor passes. The user is signed in. -- **Attack.** On `/app/attack`, fill the field labelled `Your Argument *` and click `Find Weaknesses`. The text `FAKE-PROVIDER weakness` appears. `providerCall("attack").user` contains the argument. -- **Brainstorm.** On `/app/brainstorm`, fill the field labelled `Motion / Topic *` and click `Start Brainstorming`. With no Current Debate, the dialog `Start a debate with this motion?` opens first: `chooseForMotion(page, "create")` (or `"once"`). The text `FAKE-PROVIDER idea 1` appears. `providerCall("brainstorm").user` contains `Motion: `, and the `brainstorms` row is filed in the new debate. With a Current Debate, the field is prefilled and no dialog opens unless the motion differs. -- **Factoids.** On `/app/factoids`, fill the field labelled `Statement *` and click `Find Factoids`. The text `FAKE-PROVIDER search suggestion` appears. -- **Ask, streamed.** On `/app/ask`, fill the placeholder `Ask your debate coach...` and click the button named `Send message`. The text `FAKE-PROVIDER streamed answer: ...` appears. `fake.requests()` features contain `ask-stream` and not `ask`. `ai_requests` gets `{feature: "ask", status: "success"}`. -- **Ask, cited.** Click `#citation-mode` first, then send. The text `FAKE-PROVIDER answer` appears, and `providerCall("ask")` fired. +- **Attack.** On `/app/attack`, fill the field labelled `Your argument *` and click `Find weaknesses`. The text `FAKE-PROVIDER weakness` appears. `providerCall("attack").user` contains the argument. +- **Brainstorm.** On `/app/brainstorm`, fill the field labelled `Motion / topic *` and click `Start brainstorming`. With no Current Debate, the dialog `Start a debate with this motion?` opens first: `chooseForMotion(page, "create")` (or `"once"`). The text `FAKE-PROVIDER idea 1` appears. `providerCall("brainstorm").user` contains `Motion: `, and the `brainstorms` row is filed in the new debate. With a Current Debate, the field is prefilled and no dialog opens unless the motion differs. +- **Factoids.** On `/app/factoids`, fill the field labelled `Statement *` and click `Find factoids`. The text `FAKE-PROVIDER search suggestion` appears. +- **Ask, streamed.** On `/app/ask`, fill the placeholder `Ask your debate coach…` and click the button named `Send message`. The text `FAKE-PROVIDER streamed answer: ...` appears. `fake.requests()` features contain `ask-stream` and not `ask`. `ai_requests` gets `{feature: "ask", status: "success"}`. +- **Ask, cut off.** Call `await fake.truncate("ask-stream")` and send. The stream ends with `finish_reason: "length"`, its `final` event's output carries `replyTruncated: true`, and the partial answer stays with `This answer hit its length limit and stops early...` under `Uncertainties:`. No `Response was interrupted` toast. Fake mode only. +- **Ask, cited.** Open the `Options` button above the message box, click `#citation-mode`, close it with Escape, then send. The text `FAKE-PROVIDER answer` appears, and `providerCall("ask")` fired. + +- **Stop and errors.** While Ask answers, the send button becomes `Stop answering`; Stop keeps the partial reply and returns focus to the box, which stays editable throughout. A failed request leaves an `alert` above the box with `Retry`, which asks again without adding the question twice. The four form tools show one status line (`Reviewing your case…`, `Finding angles…`, `Searching for evidence…`, `Writing your case…`, or `Researching sources…` while web research runs) with a `Cancel` button until the model starts writing, then the live draft (see [live-generation](./live-generation.md)). Failures stay on the page with `Retry`. +- **Ask, cited and with web search.** These modes answer through the structured endpoint with live generation: the reply bubble fills with the answer's text as the model writes it, and citations, sources, and follow-ups appear when it finishes. +- **Discuss.** A result's `Discuss` button opens Ask, which sends the summary as the user's message through the streaming path. ## Gotchas - Ask silently falls back to the non-streaming path if streaming fails, so a visible answer alone does not prove streaming. Check the provider feature list. - Factoids evidence locally shows only that the pipeline completes without inventing citations. It does not show real sources. - Attack, Factoids, and Ask take no motion, so they open no motion dialog. Brainstorm does (see [current-debate](./current-debate.md)). -- `ask-web` (`#web-search-mode`) proves web sources only in live mode; fake mode records an `unverified` annotation. +- `ask-web` (`#web-search-mode`, inside `Options`) proves web sources only in live mode; fake mode records an `unverified` annotation. diff --git a/.claude/skills/verify-debatelab/features/auth-demo-login.md b/.claude/skills/verify-debatelab/features/auth-demo-login.md index e0d727f56..0cda7d9ea 100644 --- a/.claude/skills/verify-debatelab/features/auth-demo-login.md +++ b/.claude/skills/verify-debatelab/features/auth-demo-login.md @@ -9,6 +9,11 @@ A visitor signs in with a demo access code on the Login page. The `demo-code-log - `auth-reject` shows the alert `Demo code not found.` for an unknown code and stays on `/login`. - `auth-signout` signs out from Settings, after which `/app/generate` redirects to `/login`. - `auth-refresh` refreshes an expired access token before an AI request. +- `auth-session-expired` signs out a session the server revoked: the stored tokens are refused, so the app goes to `/login` and shows `Your session expired. Sign in again.` once (`recipes/auth.spec.ts`). +- `auth-email-link` opens the sign-in link from the email after a resend failed with 429. It lands on `/app` through `/auth/callback` without showing `/login` on the way, and the same link opened again shows `This sign-in link has expired or was already used. Send a new code.` on `/login` (`recipes/auth.spec.ts`). +- `auth-deep-link-email-code` opens `/app/generate` signed out, lands on `/login` (one `main`, an `h1`), sends a code, and sees `Resend in 0:NN` disabled. A wrong code shows `That code is wrong or has expired. Check it or send a new one.`. After the countdown it resends (`New code sent`, the old error gone), and the new code returns to `/app/generate` (`recipes/auth.spec.ts`). +- `auth-same-account-after-sign-out` signs out from Settings and signs the same account back in by email code: it lands on `/app`, not Settings. A session that ends by itself still returns the account to the page it left (`recipes/auth.spec.ts`). +- `auth-switch-account` signs out from Settings (the logout request has `scope=local`) and signs a second account in on the same `/login` page. It lands on `/app`, not on Settings, and gets its own onboarding tour. On phones the menu still opens and navigates while the tour shows (`recipes/auth.spec.ts`). ## How to get to it (user POV) @@ -26,9 +31,14 @@ Preconditions: - **Persist.** Reload the page. The URL stays `/app`. On `/app/settings`, the page shows `user.email`. - **Reject.** On a signed-out page, submit `DEMO-ZZZZ-ZZZZ`. The element with role `alert` has the text `Demo code not found.`, and the URL still matches `/login`. - **Sign out.** On `/app/settings`, click the button named `Sign out`, then go to `/app/generate`. The URL matches `/login`. +- **Email link.** Local email templates are code-only, so the recipe builds the link prod's email carries: `${LOCAL_SUPABASE_URL}/auth/v1/verify?token=pkce_&type=magiclink&redirect_to=`. The `redirect_to` is `http://127.0.0.1:18081/auth/callback?sb_flow_id=<32 hex>`. Route `**/auth/v1/otp**` to a 429 before clicking `Resend code` to reproduce the failed resend. +- **Resend countdown.** After each send, `Resend code` is disabled and named `Resend in 0:NN` for a minute (or as long as a 429 asked). The deadline is in sessionStorage under `debatelab:otp-resend-until:`; set it to `0` to end the wait, and the button turns back within a second (`expireResendCooldown` in `recipes/auth.spec.ts`). Sending again to the same address before then opens the code step without a request. +- **Switch account.** After `Sign out`, sign the second account in on the same `/login` page. `page.goto("/login")` drops the history state that the bug depended on. Create the second user with `createDemoUser` and delete it in `finally`. +- **Revoked session.** Signed in on `/app`, replace `access_token` in the `debate-auth-local` localStorage entry with a JWT the server refuses, `refresh_token` with `x`, and `expires_at` with now + 3000 s, then reload. Within a few seconds the URL matches `/login` and a `status` element says `Your session expired. Sign in again.`. A second reload no longer shows it. ## 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. 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 DebateOwl`, `Start your first debate here.`) offers `Start debate`. When the one-time DebateOwl welcome is due, closing it (`Got it` or Esc) continues straight into that step; the verify stack keeps the welcome closed unless the URL is `?intro=show`. The fixtures pre-seed `debate-trainer-onboarding` to skip the tour. 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. Only demo codes work here. +- 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 6c83cee79..943db2124 100644 --- a/.claude/skills/verify-debatelab/features/case-generation.md +++ b/.claude/skills/verify-debatelab/features/case-generation.md @@ -6,16 +6,17 @@ A signed-in user enters a motion on Generate and receives a structured debate ca - `gen-create` generates a case from the Current Debate's motion. The `ai` Edge Function calls the provider and records `ai_requests` with `feature: generate, status: success`. The case is filed in the debate automatically (`cases.debate_id`, `side: pro`); there is no Save button in a debate. - `gen-grounded` includes active Library sources in the system prompt (see [library-sources](./library-sources.md)). -- `gen-error` shows `Generation Failed` on a provider error, and the button becomes enabled again for a retry. -- `gen-quota` shows the dialog `Daily Access Paused` when the account has no quota, without calling the provider. +- `gen-error` shows `Generation failed` on a provider error, and the button becomes enabled again for a retry. +- `gen-quota` shows the dialog `Daily AI uses used up` when the account has no quota, without calling the provider. - `gen-save` saves a one-off case (the motion dialog answered with `Not now, use it once`) under a title into `cases` with `debate_id: null`. It appears in History under `One-off and unsorted work`, including from a fresh browser context. - `gen-regenerate` regenerates one section. Only that section shows the overlay `Regenerating
…`; the rest of the case and the result toolbar stay on screen, and every regenerate button and the form's submit button are disabled until it finishes. - `gen-export` downloads a `.pdf` (starting with `%PDF`) and a `.docx` (starting with `PK`). +- `gen-flowchart` builds a flowchart from the Current Debate's case. `/app/flowchart` opens the template; the button `Build from current case` replaces it with the thesis, one node per contention (claim and evidence), the predicted attacks, and the counters, fitted to the canvas. Node titles the app wrote (`Thesis`, `Argument 1`) follow the UI language. ## How to get to it (user POV) - The header link `Generate`, or the Home card with `data-tour="mode-generate"`, or `/app/generate`. -- History: `More` → `History` on desktop, `Open menu` → `History` on mobile, or `/app/history`. +- History: Home utilities tile `History`, header nav `History` from 768px. Phones have no History tab. Or `/app/history`. ## Driving it with verify.sh + Playwright @@ -23,23 +24,26 @@ 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`. +- **Start the debate.** `await startDebate(page, motion)`. On `/app/generate`, the textbox named `Motion / topic *` already holds the motion. +- **Generate.** Click the button named `Generate debate case`. The text `FAKE-PROVIDER case thesis` and `FAKE-PROVIDER contention 3` appear, along with a toast `Generate complete`. Without a Current Debate, fill the motion yourself and answer the dialog `Start a debate with this motion?` with `chooseForMotion`. - **Provider side effect.** `providerCall("generate")`'s `user` contains `Motion: `. `ai_requests` for the user contains `{feature: "generate", status: "success"}`. - **Filed in the debate.** `cases` for the user is one row whose `debates(motion)` is the motion. On a fresh browser context, `/app/history` shows it as a heading in the `Cases` tab panel, and `Load` reopens it. - **Save (one-off).** Generate with no debate and choose `chooseForMotion(page, "once")`. Click the button named `Save` (use `exact`), fill the dialog field labelled `Case title`, and click the dialog's `Save` button. `cases` for the user is `[{title, debate_id: null}]`. On a fresh browser context, `/app/history` shows `One-off and unsorted work` with a list item holding the title. -- **Error.** Call `await fake.fail("generate", 500)`, start the debate, and generate. The text `Generation Failed` (use `exact`) appears. Clicking again then succeeds. -- **Quota.** Update `user_settings` to set `daily_limit_override: 0` through `admin`, start the debate, then generate. The dialog shows `Daily Access Paused`. `fake.requests()` has no `generate` entry. -- **Regenerate a section.** After generating, click the button named `Regenerate Arguments` (the section titles are `Case Structure`, `Arguments`, `Predicted Opponent Case`, `Prepared Counters`). Hold the request with `page.route("**/functions/v1/ai", ...)` to check the in-flight state: a `status` with `Regenerating Arguments` is visible and `Regenerate Case Structure` is disabled. The toast `Section regenerated` follows. +- **Error.** Call `await fake.fail("generate", 500)`, start the debate, and generate. An `alert` holding `Generation failed` stays above the results with a `Retry` button; clicking `Retry` succeeds and removes it. +- **Cut off.** Call `await fake.truncate("generate")`, start the debate, and generate. The first reply stops halfway with `finish_reason: "length"`; the Edge Function retries once with a larger cap, and the case appears with no error. `fake.requests()` has two `generate` entries, the second with a larger `maxTokens`. Fake mode only. +- **Cancel.** While a generation runs, the results column shows `Writing your case…` and a `Cancel` button. Cancel stops the request and shows no error. +- **Quota.** Update `user_settings` to set `daily_limit_override: 0` through `admin`, start the debate, then generate. The dialog shows `Daily AI uses used up`. `fake.requests()` has no `generate` entry. +- **Regenerate a section.** After generating, click the button named `Regenerate Arguments` (the section titles are `Case structure`, `Arguments`, `Predicted opponent case`, `Prepared counters`). Hold the request with `page.route("**/functions/v1/ai", ...)` to check the in-flight state: a `status` with `Regenerating Arguments` is visible and `Regenerate Case structure` is disabled. The toast `Section regenerated` follows. A full generation shows no toast: the result appears in place (and scrolls into view on phones). +- **Flowchart.** After generating in the debate, open `/app/flowchart` and click the button named `Build from current case` (Hungarian: `Létrehozás a jelenlegi esetből`). It appears only when the case belongs to the Current Debate, or to one-off work while no debate is chosen. The contention claims are visible, and an edge is a button named `From Thesis to Argument 1`. - **Export.** Wait for `page.waitForEvent("download")` while clicking the button named `PDF` or `DOCX` (use `exact`). Check the suggested filename's extension and the file's magic bytes. ## Gotchas -- While any generation is running, the form's submit button reads `Generating Case...`, not `Generate Debate Case`. Find it by `[data-tour="generate-button"]` then. +- While any generation is running, the form's submit button reads `Generating case…`, not `Generate debate case`. Find it by `[data-tour="generate-button"]` then. -- The Generate page shows the heading `Ready to Generate` before the first run. Wait for it after navigating, because lazy route chunks load slowly on a cold Vite. +- The Generate page shows the heading `Your case will appear here` before the first run. Wait for it after navigating, because lazy route chunks load slowly on a cold Vite. - `Save` appears both on the result toolbar and in the dialog. Scope the second click to `getByRole("dialog")`. The toolbar `Save` exists only for one-off results. - A case filed in a debate is titled with the motion, which the header control also shows (truncated and hidden). Look for it inside the History panel, not with a page-wide `getByText`. - The client files a result after it renders. Wait for the result on screen (and the row, if the next step reloads) before a `page.goto`, or the reload can drop it. -- The model label in the result header (for example `anthropic/claude-sonnet-5`) is the model the function requested. The answer itself came from the fake provider. +- The model label in the result header (for example `anthropic/claude-sonnet-5.5`) is the model the function requested. The answer itself came from the fake provider. - `Google Docs` export needs Google OAuth and cannot be proven locally. diff --git a/.claude/skills/verify-debatelab/features/current-debate.md b/.claude/skills/verify-debatelab/features/current-debate.md index 77128c8eb..fa7e63d70 100644 --- a/.claude/skills/verify-debatelab/features/current-debate.md +++ b/.claude/skills/verify-debatelab/features/current-debate.md @@ -17,7 +17,7 @@ Each student works in one Current Debate at a time: a motion with its own Librar - Home (`/app`): the card with test id `home-current-debate`, which says `No current debate` for a new account. - Every tool page shows the strip with test id `current-debate-strip` (`Working in: `), with a `Use once` button for one-off mode. - The debate dashboard is `/app/debate`. -- A new account's onboarding tour starts on `/app` with a `Start debate` button in its first step. +- A new account's onboarding tour starts on `/app` with a `Start debate` button in its first step. The one-time welcome, when it shows, closes straight into that step. This stack keeps the welcome closed unless the URL is `?intro=show`. ## Driving it with verify.sh + Playwright @@ -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. +- `chooseForMotion` scopes itself to the two motion dialogs by name, because the next step may open its own dialog, such as the paywall `Your free trial has ended`. +- The Library's `Add source` button is disabled until a Current Debate exists. Start one first. - `getByRole("button", { name: "Start" })` also matches the header's `Start debate`. Use `exact: true` for other Start buttons, such as the speech timer's. - In live mode, research can save web sources to the Library. Check the rows a recipe added by title rather than counting all of them. - 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 6630ba93c..7b0f8f945 100644 --- a/.claude/skills/verify-debatelab/features/library-sources.md +++ b/.claude/skills/verify-debatelab/features/library-sources.md @@ -4,30 +4,39 @@ Users save reference texts to their Library. The Library belongs to the Current ## Sub-features -- `lib-add` adds a source with a title and content. It appears with its toggle on, and the banner `Grounding Active` shows. +- `lib-add` adds a source with a title and content. It shows at once while it saves, then a toast `Saved to the Library`; its toggle is on, and the banner `Grounding active` shows. - `lib-toggle` pauses a source through its switch, which keeps it out of prompts. +- `lib-offline` offline, the switch does not move and a toast says `You are offline. Reconnect and try again.` A write that fails online puts the switch back with a toast. +- `lib-edit-while-switching` the source editor's untouched fields follow the stored source while it is open (a switch that rolls back shows as rolled back), and `Save` sends only the fields edited in the sheet. Text typed while a save runs stays an edit and goes out with the next `Save`. +- `lib-dedupe` keeps one row per web page per debate. URLs match after normalizing (host without `www.`, no scheme, fragment, tracking parameters or trailing slash). Importing a page the debate has warns `This page is already in this debate's Library.` before any fetch; research refreshes the existing row instead of adding one. +- `lib-layout` on a phone the row's controls take their own line, so a long title gets the row's width and two lines. - `lib-ground` includes only active sources' text in the `system` of later AI calls (Generate, Brainstorm, and others). - `lib-delete` deletes a source. It stays gone after a reload and in the `sources` table. +- `lib-counts` the Current Debate summary on `/app` shows `<total> sources (<active> active)`, and Ask's `Using <n> 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. +- Phone (below 768px): bottom tab `Library`. From 768px: header nav `Library`. Or go to `/app/library`. +- 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 `Source title...`, fill the textbox named `Paste source content...` with a unique marker, and click `Save to Library`. The switch named `Toggle <title>` is checked. `sources` has a row with `is_active: true` and the debate's `debate_id`. +- **Add.** Click the button named `Add source`. Fill the textbox named `Title` (exact), fill the textbox named `Content` (exact) with a unique marker, and click `Save to Library`. Leaving either empty shows an inline error under the field and moves focus to it. The switch named `Toggle <title>` is checked. `sources` has a row with `is_active: true` and the debate's `debate_id`. - **Toggle.** Click the switch named `Toggle <title>`. It becomes not checked. +- **Offline toggle.** `page.context().setOffline(true)`, click the switch: the offline toast shows, the switch stays checked, and the row's `is_active` is unchanged. Still offline, open the source, change its `Content` and click `Save`: the toast `Could not save this source.` shows and the typed text stays in the sheet. Set offline back to false and click `Save` again: `Source updated` shows and the row has the new content with `is_active` unchanged. +- **Duplicate import.** Insert a URL source through `admin`, then import the same page with `?utm_source=x#top`: the duplicate warning shows and no `import-source` request is made. +- **Phone layout.** At a 360px viewport the title button is wider than 200px and the page has no sideways scroll. - **Ground.** Go to `/app/brainstorm`; the motion is prefilled from the debate. Run it (see [ai-tools](./ai-tools.md)). `providerCall("brainstorm").system` contains the active marker and does not contain the paused marker. -- **Delete.** Click the button named `Delete <title>` (use `exact`). The switch count drops to 0, and stays 0 after `page.reload()`. `sources` no longer has the title. -- **Import.** In the add panel, fill `Import from URL` and click `Import URL`. `Source Ready` 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 `[]`. +- **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 `[]`. ## Gotchas diff --git a/.claude/skills/verify-debatelab/features/live-generation.md b/.claude/skills/verify-debatelab/features/live-generation.md new file mode 100644 index 000000000..2defe01ea --- /dev/null +++ b/.claude/skills/verify-debatelab/features/live-generation.md @@ -0,0 +1,33 @@ +# Live generation + +Every AI answer appears while the model writes it. Generate, Attack, Brainstorm, and Factoids show a live draft: the result's own sections fill in token by token with a caret where text is arriving, under a `Writing live` bar with `Cancel`. Ask fills its reply bubble, a Crossfire opponent's turn and a Sparring opponent's speech appear in place as they are written, and the Crossfire debrief and Sparring scorecard fill in section by section. When the answer is complete, the finished result replaces the draft. + +## Sub-features + +- `live-generate`: Generate's draft shows `Case structure` and later sections before the result, then the full result and its toolbar replace it. +- `live-cancel`: `Cancel` on the draft stops the request; no draft, result, or error stays on the page. +- `live-tools`: Attack, Brainstorm, Factoids, and cited Ask each ask for live generation and receive an event stream. +- `live-practice`: Crossfire turns and debriefs and Sparring speeches and scorecards ask for live generation (checked in `recipes/practice.spec.ts` with `expectStreamed`). + +## How to get to it (user POV) + +- Run any AI tool: `/app/generate`, `/app/attack`, `/app/brainstorm`, `/app/factoids`, `/app/ask` with `Options` turned on, `/app/crossfire`, `/app/sparring`. +- Plain Ask (no options) keeps its own streaming endpoint, `ai-stream` (see [ai-tools](./ai-tools.md)). + +## Driving it with verify.sh + Playwright + +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. +- **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. + +## Gotchas + +- In fake mode the Generate draft is on screen for about a second; short replies (Crossfire turns, Sparring speeches) finish too fast to catch reliably, so prove them with `expectStreamed`, not by waiting for the draft. +- A server from before live generation ignores `stream: true` and answers with JSON. The client accepts both, so a visible result alone does not prove streaming; check the content type or `expectStreamed`. +- Never call `response.json()` on an `ai` response; use `readAIResponse` or `aiResponse`. +- In voice rounds the live text has audio tags removed, as the finished reply does. diff --git a/.claude/skills/verify-debatelab/features/localization.md b/.claude/skills/verify-debatelab/features/localization.md new file mode 100644 index 000000000..00c72df2b --- /dev/null +++ b/.claude/skills/verify-debatelab/features/localization.md @@ -0,0 +1,33 @@ +# Localization + +The interface runs in English, Spanish and Hungarian. Every page names itself in the tab title in the interface language ("Generálás · DebateOwl" in the app, "Felhasználási feltételek | DebateOwl" on public pages), form validation and server refusals read in that language, the language switcher sits in every header, and `?lang=es|hu|en` links and the Hungarian and Spanish pages (`/hu`, `/es`, `/hu/tools/…`, `/es/tools/…`) open the app in that language. + +## Sub-features + +- loc-titles: `document.title` on app and public routes, per language, and a switch retitling the page without a reload. App routes read `<page> · DebateOwl` (`usePrivateRouteHead`); public pages use the `pageHead` catalog section (`usePageHead`), whose English text is also what the prerender writes. +- loc-validation: an empty Attack submit shows `validation.caseTextRequired` in the interface language, and nothing shows before the first submit. Generate, Brainstorm and Factoids use the same keys. +- loc-switcher: the switcher on `/login`, the 404 page, and the other public pages (`PublicPageShell`), besides the landing page and the app header. +- loc-links: `/?lang=hu` switches and saves the language, sets `<html lang>`, and drops the parameter; `/es` and `/hu` are prerendered pages of their own that keep their address, and Start from `/hu` leads into the app in Hungarian. +- loc-server-error: a refused promo code shows `serverErrors.promoInvalid` in the interface language while redeem-promo still answers in English beside its `code`. + +## How to get to it (user POV) + +- The globe button in any header, then a language under "Interface language". +- A link with `?lang=hu`, or the address `/hu` or `/es`. +- Settings, `Upgrade to Pro`, `Have a promo code?`, then an unknown code and `Apply`, for the translated refusal. + +## Driving it with verify.sh + Playwright + +```bash +$H drive recipes/localization --project desktop-chromium +$H drive recipes/localization --project mobile-chromium # 375 px +``` + +`01-document-titles.json` in the loc-titles evidence lists the title of every route in each language. + +## Gotchas + +- The switcher's own name is translated (`langSwitcher.label`); match it in all three languages. +- The recipe reads the catalogs from `src/locales/`, so its expectations follow the strings. Match on keys, never on copied text. +- The promo field shows only for an account that would start a Checkout, so the recipe marks its Pro demo user expired first. +- `/es` and `/hu` are files in `dist/` (`hu/index.html`), listed with their tool pages in `src/config/localized-routes.json`. Choosing another language with the switcher on one of them moves to that language's address. diff --git a/.claude/skills/verify-debatelab/features/model-defaults.md b/.claude/skills/verify-debatelab/features/model-defaults.md new file mode 100644 index 000000000..6636f518e --- /dev/null +++ b/.claude/skills/verify-debatelab/features/model-defaults.md @@ -0,0 +1,25 @@ +# Model defaults and the model selector + +Every AI tool has a model selector. Basic accounts can pick Claude Haiku 5.5, GPT-6 Luna, or Muse Spark 1.3 (`meta/muse-spark-1.3-contributor`); everything else is Pro. A new Pro account starts every tool on Claude Sonnet 5.5, and a Basic (trial) account on Claude Haiku 5.5. Claude Opus 5.5 is opt-in only: it runs when someone picks it, and Auto Pro never routes or falls back to it. Pro-only models show a `Pro` badge and are disabled for Basic accounts. A setting saved with a retired catalog ID is rewritten on the next load: GPT-5.6 Luna moves to Claude Haiku 5.5, Claude Sonnet 5 to Claude Sonnet 5.5, Claude Opus 5 to Claude Opus 5.5, GPT-5.6 Sol and Terra to GPT-6.1 Sol, DeepSeek V4 Flash to Claude Haiku 5.5, and Muse Spark 1.3 to its contributor tier. The Edge Functions resolve a retired ID from an older client the same way. A retired Basic ID never resolves to a Pro model. The catalog and defaults live in `supabase/functions/_shared/model-catalog.ts`. + +## Sub-features + +- `model-pro-default`: a new Pro account's `user_settings.feature_configs` hold `anthropic/claude-sonnet-5.5` for every tool, the selector shows `Claude Sonnet 5.5` with `Claude Opus 5.5` still enabled, and a Generate run records `model: anthropic/claude-sonnet-5.5` in `ai_requests`. +- `model-basic-default`: a trial account with no saved settings gets `anthropic/claude-haiku-5.5` everywhere. `Claude Opus 5.5` and `Claude Sonnet 5.5` are disabled options, and a Generate run records Haiku 5.5. +- `model-picker-narrow`: with `Auto Fast` picked, the field shows only the name and test id `model-auto-description` shows its description below, in English and Hungarian at 375 px, with neither cut off. +- `model-retired`: saved `anthropic/claude-sonnet-5` configs become `anthropic/claude-sonnet-5.5` on screen and in `user_settings`. + +## How to get to it (user POV) + +- Any tool page, for example `/app/generate`: the combobox named `Select AI model for generate` sits beside the tool's inputs. + +## Driving it with verify.sh + Playwright + +- `bin/verify.sh drive recipes/model-defaults` covers all three sub-features. +- Demo sign-in grants Pro. For the Basic path the recipe sets `subscription_tier: "trial"`, a future `trial_ends_at`, clears `grace_period_ends_at` and `daily_limit_override`, and empties `feature_configs`, then reloads so the client re-hydrates as a new trial account. +- End states are read from `user_settings.feature_configs` and `ai_requests.model` through `admin`, and from the fake provider's recorded `model`. + +## Gotchas + +- Fake mode records the requested model but answers with fake text, and live mode rewrites every chat call to the Claude Haiku 5.5 pin. Neither proves that OpenRouter accepts the Sonnet and Opus 5.5 IDs; that needs a direct OpenRouter call. +- Existing saved choices of a model still in the catalog are kept. Only retired IDs and unsaved settings move to the new defaults. Saved Opus 5.5 settings from the old Pro default are moved to Sonnet 5.5 once, by the `20261022120000_opus_default_to_sonnet.sql` data migration, not by the client. diff --git a/.claude/skills/verify-debatelab/features/practice.md b/.claude/skills/verify-debatelab/features/practice.md index 3e93dabea..68ca11121 100644 --- a/.claude/skills/verify-debatelab/features/practice.md +++ b/.claude/skills/verify-debatelab/features/practice.md @@ -5,10 +5,15 @@ Live practice modes. Sparring runs a multi-speech round against an AI opponent a ## Sub-features - `spar-round` starts a round with a chosen format, submits the user's speeches, and receives opponent speeches. +- `spar-start` the setup prices the whole round (test id `sparring-round-cost`, `N AI uses`, and `For the whole round: 4 replies + the judge` for the school format), in English and Hungarian at 375 px and 1280 px. While the pre-round quota check runs, Start reads `Opening room...`, is disabled with `aria-busy`, and test id `sparring-starting` says `Checking your AI uses for today…`; a second press sends no second check, and the round opens without a reload once the check answers. - `spar-verdict` judges the finished round. A `sparring_sessions` row reaches `status: complete`, with `side: pro` and the debate for its motion. - `cross-turn` answers a user question with an AI response and attaches a Jev `Direct answer` signal. - `cross-debrief` ends the session and shows the debrief. A `crossfires` row is filed in the debate for its resolution. - `cross-ai-questions` reverses the roles: the AI asks (`mode-ai-questions-user`), the user answers, and the AI follows up. +- `cross-history` after leaving the page, `/app/history?tab=crossfire` lists the round (test id `history-crossfire-row`). `Open Crossfire round: <resolution>` shows test id `saved-crossfire` with the debrief (`saved-crossfire-debrief`) and transcript (`saved-crossfire-transcript`); `Delete Crossfire round: <resolution>` asks first and removes the row. +- `cross-daily-limit` with no AI requests left, the first AI turn is refused: the daily-limit dialog opens and the page stays in setup with no clock and no `end-crossfire`. +- `cross-end-early` ending a round before any exchange returns to setup with no error. +- `spar-daily-limit` Sparring setup warns (test id `sparring-request-shortfall`) when today's remaining requests, as the server reports them, are fewer than the round costs. A round costs one use (by model weight) however many replies it takes; `session-cost` says so before Start. ## How to get to it (user POV) @@ -22,15 +27,18 @@ Preconditions: - Doctor passes. The user is signed in. The recipe is `recipes/practice.spec.ts`. - **Start a sparring round.** On `/app/sparring`, fill the field labelled `Motion`. Open the combobox named `Round format` and choose the option matching `Lincoln-Douglas · 5 speeches`. Click `Start round`. A new account has no Current Debate, so `Start a debate with this motion?` opens: `chooseForMotion(page, "create")`. -- **Speeches.** For each user turn, fill the textbox named `Draft for <turn>` (`Affirmative constructive`, `First affirmative rebuttal`, `Second affirmative rebuttal`) and click `Submit speech`. After each of the first two, the last `FAKE-PROVIDER opponent speech` is visible. -- **Verdict.** The heading `Here is what happened in the room.` and the text `FAKE-PROVIDER verdict: the user had the edge.` appear. `providerCall("sparring").user` contains `SPARRING ACTION:`. `sparring_sessions` statuses for the user equal `["complete"]`, and `select("side,debates(motion)")` gives `{side: "pro", debates: {motion}}`. -- **Start Crossfire.** On `/app/crossfire`, fill test id `crossfire-resolution` and `crossfire-argument`. Click `mode-question-ai` (or `mode-ai-questions-user`), then `start-crossfire`, then `chooseForMotion(page, "create")`. +- **Speeches.** For each user turn, fill the textbox named `Draft for <turn>` (`Affirmative constructive`, `First affirmative rebuttal`, `Second affirmative rebuttal`) and click `Submit speech`. Each turn opens with a stopped clock (test id `sparring-ready`, button `Start speaking`); filling the draft starts it. After each of the first two, the last `FAKE-PROVIDER opponent speech` is visible. +- **Verdict.** The heading `Here is what happened in the room.` and the text `FAKE-PROVIDER verdict: the user had the edge.` appear. `providerCall("sparring").user` contains `SPARRING ACTION:`. `sparring_sessions` statuses for the user equal `["complete"]`, and `select("side,debates(motion)")` gives `{side: "pro", debates: {motion}}`. In Hungarian the radar chart axis (`.recharts-polar-angle-axis-tick text`) reads `Logika`, `Bizonyíték`, `Előadás`, `Stratégia`. +- **Hold the quota check.** `page.route("**/functions/v1/ai")` and wait before `route.fallback()` when the body has `quotaCheck: true`; register it after the page loads, since setup asks once on load. +- **Start crossfire.** On `/app/crossfire`, fill test id `crossfire-resolution` and `crossfire-argument`. Click `mode-question-ai` (or `mode-ai-questions-user`), then `start-crossfire`, then `chooseForMotion(page, "create")`. - **Crossfire turn.** Fill `crossfire-input` and click `submit-turn`. The text `FAKE-PROVIDER crossfire answer` and `Direct answer` appear. `providerCall("crossfire")` and `providerCall("jev")` both fired. - **Debrief.** Click `end-crossfire`. Test id `crossfire-debrief` is visible. `crossfires` for the user equals `[{title: <resolution>, debates: {motion: <resolution>}}]`. +- **Out of requests.** Through `admin`, set `user_settings.daily_limit_override` to N, upsert today's `daily_usage_counters` row (`quota_key: "ai"`, `request_count: N`; what the `ai` function checks), and insert N `ai_requests` rows. The usage line and Crossfire and Sparring read only the counter (`ai_usage_today` and the quota check). Start crossfire in `mode-ai-questions-user`: a `dialog` containing `N of N` opens and `start-crossfire` is back. On `/app/sparring`, `sparring-request-shortfall` says the same `You've used N of N included AI uses today.` ## Gotchas - Opponent speeches carry a server-signed integrity token. Scoring fails if the client drops it, so a verdict proves the round-trip. - Jev is faked with a fixed confident answer. `Direct answer` proves the wiring, not the quality of the judgement. - Sparring AI calls happen once per opponent turn. Keep the per-step wait near 30 s rather than one long wait for the verdict. +- The browser's remaining-requests count leaves out `rate_limited` and `jev_*` rows and cannot see global `rate_limits` rows, so it warns and never blocks. The `ai` function's 402 decides. - In `mode-ai-questions-user` the opening question is requested as soon as the round starts, after the motion dialog. Create the `aiResponse(page, "crossfire", "next-turn")` promise before clicking `start-crossfire`. diff --git a/.claude/skills/verify-debatelab/features/usage-limits.md b/.claude/skills/verify-debatelab/features/usage-limits.md new file mode 100644 index 000000000..bba4aac2c --- /dev/null +++ b/.claude/skills/verify-debatelab/features/usage-limits.md @@ -0,0 +1,31 @@ +# Usage limits: weighted daily quota and weekly AI budget + +The daily AI allowance counts units, not requests. An expensive model uses several units per request (Claude Opus 5.5 counts as 5, Sonnet 5.5 as 2, cheap models as 1), so a request that does not fit in what is left today is refused with a message that names the model's weight and the units left, while a lighter model still runs. Separately, once a user's AI spend for the current week (from Monday 00:00 UTC) reaches the weekly budget ($2 by default), every AI request is refused with its own dialog until Monday. Only `ai_requests.cap_cost_usd` counts: OpenRouter credit charges plus Exa and ElevenLabs; calls billed to our own BYOK key add only OpenRouter's fee. The weights, the budget and the tier defaults live in `supabase/functions/_shared/usage-limits.ts`; the reservation is `consume_ai_quota` in `supabase/migrations/20261022130001_weighted_quota_weekly_budget.sql`. + +Crossfire and Sparring are charged per round, not per call: the round's first call reserves the model's units through `claim_ai_session_quota` (`supabase/migrations/20261022220000_ai_quota_sessions.sql`), and every later turn, reply, debrief and judge call in the same round is free. A round that passes its call cap or lifetime (`SESSION_QUOTA` in `usage-limits.ts`) pays again. Both setups ask the server what a round costs before Start (a `quotaCheck` request that reserves nothing) and show it in `session-cost`; a round the server would refuse never starts. A run that fails, is cancelled or is lost to a reload before the student saw `REFUND_MAX_DELIVERED_CHARS` of streamed output gets its units back, and the error says "You were not charged". + +## Sub-features + +- `usage-weighted`: with 4 units left, Opus is refused (`Daily AI uses used up`, "This model counts as 5 uses, and you have 4 left today.") without a provider call or a counter change; switching to Claude Haiku 5.5 runs and uses 1 unit. +- `usage-weekly`: with `ai_requests.cap_cost_usd` for this week at or above the budget, the next request opens `Weekly AI allowance used` with an `OK` button and no upgrade offer, makes no provider call, and leaves the daily counter alone. + +- `usage-weekly-sparring`: with the budget reached, the Sparring setup's `session-cost` shows the translated weekly message from the catalog (not the server's English reason) before Start, and Start opens `Weekly AI allowance used` without opening a round or calling the provider. +- `usage-session-sparring` (QB-01): with 1 request left of 5, a Lincoln-Douglas round on Claude Haiku 5.5 runs both opponent replies and the judge; the counter moves 4 to 5 once and `ai_quota_sessions` shows `calls_used: 3`. +- `usage-session-crossfire` (QB-05, MD-03): an AI-led Crossfire round with two answers and the debrief makes four calls and one charge; the setup hint is gone once the round runs. +- `usage-refund-reload` (QB-04, MD-13): reloading while Brainstorm streams logs the run `cancelled`/`aborted` and gives its units back. Fake mode only. +- `usage-ask-not-sent` (MD-01): an Ask question refused by the daily limit stays in the chat under a `Not sent: daily limit reached` alert with Retry; after the limit is raised, Retry sends it once. + +## How to get to it (user POV) + +- Any AI tool, for example `/app/generate`, once the day's allowance or the week's budget is used up. The dialog opens on submit. + +## Driving it with verify.sh + Playwright + +- `bin/verify.sh drive recipes/usage-limits` covers each sub-feature in its own test. +- The recipe sets `daily_limit_override: 7` on the demo user and inserts a `daily_usage_counters` row (`quota_key: "ai"`, today's UTC date, `request_count: 3`), then inserts an `ai_requests` row with `cap_cost_usd: 2` for the weekly step. +- End states are read from `daily_usage_counters.request_count`, `ai_quota_sessions`, `ai_requests` and the recorded provider calls. + +## Gotchas + +- Demo users are Pro with Claude Sonnet 5.5 as the default model, so every Generate in other recipes uses 2 units of their 999. The weighted step picks Opus itself. +- The weekly sum counts `cap_cost_usd` of every `ai_requests` row since Monday 00:00 UTC, including voice and Jev routing rows; NULL (unknown or logged before the column existed) counts as nothing, and `cost_usd` alone never counts. Cost is logged after the provider answers, so the cap can be passed by about one request. diff --git a/.claude/skills/verify-debatelab/features/workspace-autosave.md b/.claude/skills/verify-debatelab/features/workspace-autosave.md index d3e618600..ab87eeda8 100644 --- a/.claude/skills/verify-debatelab/features/workspace-autosave.md +++ b/.claude/skills/verify-debatelab/features/workspace-autosave.md @@ -21,7 +21,7 @@ Preconditions: - **Open the tool.** Create a workspace for the motion, click `R1`, and on a phone click `Working panel` first (one pane at a time). Open the `Practice` details if closed and click `Crossfire` or `Sparring` (`exact: true`). - **Hold one save.** `page.route("**/rest/v1/rpc/save_workspace_artifact")` holds the first request whose `p_progress` matches a partial value (Crossfire: a resolution ending in ` n`; Sparring: a draft of `Coding`). The recipe types up to that value and waits until the save is held before typing the rest: the queue keeps only the newest pending save, so a value typed straight through can be merged away and never sent. The page keeps one save in flight, so no other save may be sent while it is held (`sentWhileHeld()` is 0); newer changes wait in the queue. - **Release and check.** Let the held save through and wait for its answer. Polling `admin.from("workspace_artifacts")` for the workspace and tool must return one row with the full text, and it must still hold that text once every save the page sent has been answered. -- **Sparring.** Click `Start round` and wait for the textbox `Draft for <turn>` (`AI_TIMEOUT`). Click `Pause clock` before typing, because a running clock saves every tick and would hide a stale save. +- **Sparring.** Click `Start round` and wait for the textbox `Draft for <turn>` (`AI_TIMEOUT`). Each turn opens with a stopped clock and a `Start speaking` button; typing would start it. Click `Start speaking`, then `Pause clock`, before typing, because a running clock saves every tick and would hide a stale save. ## Gotchas diff --git a/.claude/skills/verify-debatelab/recipes/account-data.spec.ts b/.claude/skills/verify-debatelab/recipes/account-data.spec.ts index 48680fecc..0de92a80b 100644 --- a/.claude/skills/verify-debatelab/recipes/account-data.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/account-data.spec.ts @@ -5,15 +5,15 @@ 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("textbox", { name: "Source title..." }).fill(title); - await page.getByRole("textbox", { name: "Paste source content..." }).fill(content); + await page.getByRole("button", { name: "Add source" }).click(); + await page.getByRole("textbox", { name: "Title", exact: true }).fill(title); + await page.getByRole("textbox", { name: "Content", exact: true }).fill(content); await page.getByRole("button", { name: "Save to Library" }).click(); await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked(); } diff --git a/.claude/skills/verify-debatelab/recipes/account.spec.ts b/.claude/skills/verify-debatelab/recipes/account.spec.ts index 28e7cc2e8..10a92cf94 100644 --- a/.claude/skills/verify-debatelab/recipes/account.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/account.spec.ts @@ -7,7 +7,7 @@ import { admin, AI_TIMEOUT, aiResponse, expect, expectProviderCalls, signIn, tes test("acct-language: the output language follows the account to a new device and reaches the model", async ({ signedIn: page, user, browser, evidence }) => { await page.goto("/app/settings"); - await page.getByRole("combobox", { name: "AI Output Language" }).click(); + await page.getByRole("combobox", { name: "AI output language" }).click(); await page.getByRole("option", { name: "Magyar" }).click(); await expect.poll(async () => { const { data } = await admin.from("user_settings").select("output_language").eq("user_id", await user.userId()).single(); @@ -21,9 +21,9 @@ test("acct-language: the output language follows the account to a new device and await other.addInitScript(() => localStorage.setItem("debate-trainer-onboarding", JSON.stringify({ state: { hasCompletedOnboarding: true, isTourRunning: false }, version: 0 }))); await signIn(other, user); await other.goto("/app/attack"); - await other.getByLabel("Your Argument *").fill("Free transit cuts congestion because riders leave their cars at home."); + await other.getByLabel("Your argument *").fill("Free transit cuts congestion because riders leave their cars at home."); const response = aiResponse(other, "attack"); - await other.getByRole("button", { name: "Find Weaknesses" }).click(); + 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")!; @@ -38,19 +38,23 @@ test("flow-edit: a flowchart node is deleted, the chart is saved, and it loads b const title = `Verify flow ${Date.now()}`; await page.goto("/app/flowchart"); await page.getByText("Argument 1", { exact: true }).first().click(); + // A node can be put back exactly, so it deletes at once and the toast offers Undo. + await page.getByRole("button", { name: "Delete" }).last().click(); + await expect(page.getByRole("alertdialog")).toHaveCount(0); + await expect(page.getByText("Argument 1", { exact: true })).toHaveCount(0); + await page.getByRole("button", { name: "Undo" }).click(); + await expect(page.getByText("Argument 1", { exact: true })).toHaveCount(1); + await evidence.step("Deleted Argument 1, then restored it with Undo"); + await page.getByText("Argument 1", { exact: true }).first().click(); await page.getByRole("button", { name: "Delete" }).last().click(); - const confirm = page.getByRole("alertdialog"); - await expect(confirm.getByText("Delete node?")).toBeVisible(); - await confirm.getByRole("button", { name: "Delete node" }).click(); - await expect(page.getByText("Node deleted").first()).toBeVisible(); await expect(page.getByText("Argument 1", { exact: true })).toHaveCount(0); await evidence.shot("01-node-deleted"); await page.getByRole("button", { name: "Save", exact: true }).click(); const dialog = page.getByRole("dialog"); await dialog.getByRole("textbox").fill(title); - await dialog.getByRole("button", { name: "Save Flowchart" }).click(); - await expect(page.getByText("Flowchart saved successfully").first()).toBeVisible(); + await dialog.getByRole("button", { name: "Save flowchart" }).click(); + await expect(page.getByText("Flowchart saved").first()).toBeVisible(); const { data } = await admin.from("flowcharts").select("title,nodes").eq("user_id", await user.userId()); expect(data?.map((row) => row.title)).toEqual([title]); expect(JSON.stringify(data?.[0].nodes)).not.toContain("Argument 1"); @@ -78,6 +82,8 @@ test("timer-run: the speech timer starts, pauses, and resets", async ({ signedIn await page.waitForTimeout(1_500); expect(await display.textContent()).toBe(paused); await evidence.shot("01-paused"); + // Timer shortcuts ignore a focused button (#172), and Pause still has focus. + await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); await page.keyboard.press("r"); await expect(display).toHaveText(initial!); await evidence.shot("02-reset"); diff --git a/.claude/skills/verify-debatelab/recipes/ai-tools.spec.ts b/.claude/skills/verify-debatelab/recipes/ai-tools.spec.ts index 1fb85bf3a..4786099e4 100644 --- a/.claude/skills/verify-debatelab/recipes/ai-tools.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/ai-tools.spec.ts @@ -1,14 +1,14 @@ -import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, expect, expectProviderCalls, 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. test("attack-run: Attack analyses a pasted argument", async ({ signedIn: page, evidence }) => { const argument = "Free transit cuts congestion because riders leave their cars at home, so cities should abolish fares."; await page.goto("/app/attack"); - await page.getByLabel("Your Argument *").fill(argument); - await evidence.step("Attack: pasted an argument and clicked Find Weaknesses"); + await page.getByLabel("Your argument *").fill(argument); + await evidence.step("Attack: pasted an argument and clicked Find weaknesses"); const response = aiResponse(page, "attack"); - await page.getByRole("button", { name: "Find Weaknesses" }).click(); + 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 }>; @@ -22,9 +22,9 @@ test("attack-run: Attack analyses a pasted argument", async ({ signedIn: page, e test("brainstorm-run: Brainstorm returns ranked angles", async ({ signedIn: page, user, evidence }) => { const motion = "Universal basic income should replace targeted welfare"; await page.goto("/app/brainstorm"); - await page.getByLabel("Motion / Topic *").fill(motion); + await page.getByLabel("Motion / topic *").fill(motion); const response = aiResponse(page, "brainstorm"); - await page.getByRole("button", { name: "Start Brainstorming" }).click(); + 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`); @@ -47,7 +47,7 @@ test("factoids-run: Factoids researches a statement and keeps only verified cita await page.goto("/app/factoids"); await page.getByLabel("Statement *").fill("Fare-free public transport increases ridership"); const response = aiResponse(page, "factoids"); - await page.getByRole("button", { name: "Find Factoids" }).click(); + await page.getByRole("button", { name: "Find factoids" }).click(); const body = await response; await evidence.json("01-factoids-response", body); const factoids = body.output.factoids as Array<{ shortClaim: string; citation: { url?: string; verified: string } }>; @@ -69,7 +69,7 @@ test("factoids-run: Factoids researches a statement and keeps only verified cita test("ask-stream: Ask streams a coaching answer and records usage", async ({ signedIn: page, user, evidence }) => { await page.goto("/app/ask"); - await page.getByPlaceholder("Ask your debate coach...").fill("In two sentences, how should I weigh access against cost?"); + await page.getByPlaceholder("Ask your debate coach…").fill("In two sentences, how should I weigh access against cost?"); const stream = page.waitForResponse((response) => response.url().endsWith("/functions/v1/ai-stream"), { timeout: AI_TIMEOUT }); await page.getByRole("button", { name: "Send message" }).click(); const response = await stream; @@ -91,10 +91,102 @@ test("ask-stream: Ask streams a coaching answer and records usage", async ({ sig }, { timeout: AI_TIMEOUT }).toContainEqual(expect.objectContaining({ feature: "ask", status: "success" })); }); +/** WCAG contrast of an element's text against the first painted background behind it. */ +async function textContrast(page: import("@playwright/test").Page, text: string) { + return page.evaluate((needle) => { + // "meta" is the first message's time line, the student's own bubble. + const target = (needle === "meta" + ? document.querySelector("[data-testid=message-meta]") + : [...document.querySelectorAll("p, li, strong")].find((element) => element.textContent?.trim() === needle)) as HTMLElement | null | undefined; + if (!target) throw new Error(`no element with text ${needle}`); + const rgb = (value: string) => { + const ctx = document.createElement("canvas").getContext("2d")!; + ctx.fillStyle = value; + ctx.fillRect(0, 0, 1, 1); + return [...ctx.getImageData(0, 0, 1, 1).data].slice(0, 4); + }; + let node: HTMLElement | null = target; + let background = "rgba(0, 0, 0, 0)"; + while (node) { + const color = getComputedStyle(node).backgroundColor; + if (rgb(color)[3] > 0) { background = color; break; } + node = node.parentElement; + } + const luminance = ([r, g, b]: number[]) => [r, g, b] + .map((channel) => { const c = channel / 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }) + .reduce((sum, c, i) => sum + c * [0.2126, 0.7152, 0.0722][i], 0); + const [fg, bg] = [luminance(rgb(getComputedStyle(target).color)), luminance(rgb(background))]; + return { color: getComputedStyle(target).color, background, ratio: (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05) }; + }, text); +} + +// QA round 3: the student's own message was gray on blue (about 2:1), and the +// 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); + const stream = page.waitForResponse((response) => response.url().endsWith("/functions/v1/ai-stream"), { timeout: AI_TIMEOUT }); + await page.getByRole("button", { name: "Send message" }).click(); + await stream; + const ratios: Record<string, unknown> = {}; + for (const colorScheme of ["light", "dark"] as const) { + await page.emulateMedia({ colorScheme }); + await expect(page.locator("html")).toHaveClass(colorScheme === "dark" ? /\bdark\b/ : /^(?!.*\bdark\b)/); + for (const [part, text] of [["body", "How should I weigh access against cost?"], ["bold", "access"], ["time", "meta"]] as const) { + const measured = await textContrast(page, text); + ratios[`${colorScheme}-${part}`] = measured; + expect(measured.ratio, `${colorScheme} ${part} contrast`).toBeGreaterThanOrEqual(4.5); + } + await evidence.shot(`01-user-bubble-${colorScheme}`); + } + await evidence.json("02-contrast", ratios); + await page.emulateMedia({ colorScheme: "light" }); + + await page.getByRole("button", { name: "Language", exact: true }).click(); + await page.getByRole("radio", { name: "Magyar" }).first().click(); + const box = page.getByPlaceholder("Mit kérdeznél?"); + await expect(box).toBeVisible(); + // The whole placeholder fits the field's text area. + const fits = await box.evaluate((input: HTMLTextAreaElement | HTMLInputElement) => { + const style = getComputedStyle(input); + const ctx = document.createElement("canvas").getContext("2d")!; + ctx.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`; + const room = input.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight); + return { text: ctx.measureText(input.placeholder).width, room }; + }); + await evidence.json("03-hu-placeholder", fits); + expect(fits.text).toBeLessThanOrEqual(fits.room); + await evidence.shot("03-hu-placeholder"); +}); + +test("ask-cutoff: a streamed answer cut off at its length cap says so under the answer", async ({ signedIn: page, evidence }) => { + test.skip(LIVE, "only the fake provider can force a cut-off reply"); + await fake.truncate("ask-stream"); + await page.goto("/app/ask"); + await page.getByPlaceholder("Ask your debate coach…").fill("How should I weigh access against cost?"); + const stream = page.waitForResponse((response) => response.url().endsWith("/functions/v1/ai-stream"), { timeout: AI_TIMEOUT }); + await page.getByRole("button", { name: "Send message" }).click(); + const events = await (await stream).text(); + expect(events).toContain('"replyTruncated":true'); + // The partial answer stays, with the notice under it rather than an error. + await expect(page.getByText("weigh access against cost.").first()).toBeVisible(); + await expect(page.getByText("This answer hit its length limit and stops early.", { exact: false })).toBeVisible(); + await expect(page.getByText("Response was interrupted")).toHaveCount(0); + await evidence.shot("01-cut-off-notice"); + const features = (await providerCalls()).map((call) => call.feature); + expect(features).toEqual(["ask-stream"]); +}); + test("ask-cited: Ask in citation mode uses the structured endpoint", async ({ signedIn: page, evidence }) => { await page.goto("/app/ask"); + // The Ask switches live in the Options popover above the message box. + await page.getByRole("button", { name: "Options" }).click(); await page.locator("#citation-mode").click(); - await page.getByPlaceholder("Ask your debate coach...").fill("Which evidence supports fare-free transit?"); + await page.keyboard.press("Escape"); + await page.getByPlaceholder("Ask your debate coach…").fill("Which evidence supports fare-free transit?"); const response = aiResponse(page, "ask"); await page.getByRole("button", { name: "Send message" }).click(); const body = await response; @@ -106,8 +198,11 @@ test("ask-cited: Ask in citation mode uses the structured endpoint", async ({ si test("ask-web: Ask with web search shows the sources it used", async ({ signedIn: page, evidence }) => { await page.goto("/app/ask"); + // The Ask switches live in the Options popover above the message box. + await page.getByRole("button", { name: "Options" }).click(); await page.locator("#web-search-mode").click(); - await page.getByPlaceholder("Ask your debate coach...").fill("What did recent studies find about fare-free public transport ridership?"); + await page.keyboard.press("Escape"); + await page.getByPlaceholder("Ask your debate coach…").fill("What did recent studies find about fare-free public transport ridership?"); const response = aiResponse(page, "ask"); await page.getByRole("button", { name: "Send message" }).click(); const body = await response; diff --git a/.claude/skills/verify-debatelab/recipes/auth.spec.ts b/.claude/skills/verify-debatelab/recipes/auth.spec.ts index ac1dc992e..19d45aba1 100644 --- a/.claude/skills/verify-debatelab/recipes/auth.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/auth.spec.ts @@ -1,33 +1,75 @@ -import { expect, test } from "./fixtures"; +import { createHash } from "node:crypto"; +import type { APIRequestContext, Page } from "@playwright/test"; +import { createDemoUser, debateControl, deleteDemoUser, expect, test } from "./fixtures"; -// Feature map: features/auth.md. Demo-code sign-in, reload, rejection, and +// Feature map: features/auth-demo-login.md. Demo-code sign-in, reload, rejection, and // sign-out are covered by e2e/full-stack/auth.spec.ts, which drive also runs. const mailer = process.env.WORKSPACE_TEST_MAILER_URL; +const supabaseUrl = process.env.LOCAL_SUPABASE_URL; -test("auth-email-otp: an emailed one-time code signs an existing account in", async ({ page, user, request, evidence }) => { - test.skip(!mailer, "WORKSPACE_TEST_MAILER_URL is missing; run through verify.sh drive"); - const sentAfter = Date.now(); - await page.goto("/login"); - await page.getByRole("tab", { name: "Email" }).click(); - await page.getByLabel("Email address").fill(user.email); - await page.getByRole("button", { name: "Send code" }).click(); - await expect(page.getByText("Code sent").first()).toBeVisible(); - await evidence.shot("01-code-sent"); - - // Read the real message from the local Mailpit inbox. +/** Reads the six-digit code sent to `email` after `sentAfter` from the local Mailpit inbox; `not` skips an older code. */ +async function readOtp(request: APIRequestContext, email: string, sentAfter: number, not?: string) { let otp = ""; await expect.poll(async () => { const payload = await (await request.get(`${mailer}/api/v1/messages`)).json() as { messages?: Array<Record<string, unknown>> }; for (const message of payload.messages ?? []) { - if (!JSON.stringify(message).includes(user.email)) continue; + if (!JSON.stringify(message).includes(email)) continue; if (Date.parse(String(message.Created ?? 0)) < sentAfter - 5_000) continue; const detail = await (await request.get(`${mailer}/api/v1/message/${message.ID}`)).text(); otp = detail.match(/\b(\d{6})\b/)?.[1] ?? ""; + if (otp === not) otp = ""; if (otp) break; } return otp; }, { timeout: 30_000 }).toMatch(/^\d{6}$/); + return otp; +} + +/** + * Ends the Resend countdown (AUTH-04) without waiting a minute. LoginPage reads + * the stored deadline every second, so the button turns back within one tick. + */ +async function expireResendCooldown(page: Page) { + await page.evaluate(() => { + for (const key of Object.keys(sessionStorage)) if (key.startsWith("debatelab:otp-resend-until:")) sessionStorage.setItem(key, "0"); + }); + await expect(page.getByRole("button", { name: "Resend code" })).toBeEnabled({ timeout: 3_000 }); +} + +/** Every path this tab shows, kept across reloads, so a flash of /login is caught. */ +async function recordPaths(page: Page) { + await page.addInitScript(() => { + const record = () => { + const seen = JSON.parse(sessionStorage.getItem("verify-paths") ?? "[]") as string[]; + sessionStorage.setItem("verify-paths", JSON.stringify([...seen, location.pathname])); + }; + for (const method of ["pushState", "replaceState"] as const) { + const original = history[method].bind(history); + history[method] = (...args: Parameters<History["pushState"]>) => { + original(...args); + record(); + }; + } + record(); + }); + return { + clear: () => page.evaluate(() => sessionStorage.removeItem("verify-paths")), + seen: () => page.evaluate(() => JSON.parse(sessionStorage.getItem("verify-paths") ?? "[]") as string[]), + }; +} + +test("auth-email-otp: an emailed one-time code signs an existing account in", async ({ page, user, request, evidence }) => { + test.skip(!mailer, "WORKSPACE_TEST_MAILER_URL is missing; run through verify.sh drive"); + const sentAfter = Date.now(); + await page.goto("/login"); + await page.getByRole("tab", { name: "Email" }).click(); + await page.getByLabel("Email address").fill(user.email); + await page.getByRole("button", { name: "Send code" }).click(); + await expect(page.getByText("Code sent").first()).toBeVisible(); + await evidence.shot("01-code-sent"); + + const otp = await readOtp(request, user.email, sentAfter); await evidence.step("Read the one-time code from Mailpit"); await page.getByRole("textbox", { name: "Verification code" }).fill(otp); @@ -37,3 +79,209 @@ test("auth-email-otp: an emailed one-time code signs an existing account in", as await expect(page).toHaveURL(/\/app/); await evidence.shot("02-signed-in-by-email"); }); + +test("auth-session-expired: a session the server revoked signs out to the login page with a message", async ({ signedIn: page, evidence }) => { + await page.goto("/app"); + await expect(page).toHaveURL(/\/app$/); + // Like an account deleted in another tab or a password reset: the stored + // tokens are still there, but the server refuses both of them. + await page.evaluate(() => { + const key = Object.keys(localStorage).find((name) => /^debate-auth-[a-z]+$/.test(name)); + if (!key) throw new Error("no stored session"); + const stored = JSON.parse(localStorage.getItem(key)!) as Record<string, unknown>; + const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" })).replace(/=+$/, ""); + const claims = btoa(JSON.stringify({ sub: "00000000-0000-4000-8000-000000000000", exp: Math.floor(Date.now() / 1000) + 3000 })).replace(/=+$/, ""); + localStorage.setItem(key, JSON.stringify({ + ...stored, + access_token: `${header}.${claims}.invalid-signature`, + refresh_token: "x", + expires_at: Math.floor(Date.now() / 1000) + 3000, + })); + }); + await evidence.step("Replaced the stored session with tokens the server refuses"); + await page.reload(); + await expect(page).toHaveURL(/\/login/, { timeout: 15_000 }); + await expect(page.getByRole("status").filter({ hasText: "Your session expired. Sign in again." })).toBeVisible(); + await evidence.shot("01-login-session-expired"); + await evidence.aria("01-login-session-expired"); + // The notice is for this sign-out only. + await page.reload(); + await expect(page.getByText("Your session expired. Sign in again.")).toHaveCount(0); +}); + +test("auth-email-link: the emailed sign-in link signs in after a failed resend, and a used link says so", async ({ page, user, request, evidence }) => { + test.skip(!mailer || !supabaseUrl, "Mailpit or LOCAL_SUPABASE_URL is missing; run through verify.sh drive"); + const paths = await recordPaths(page); + const sentAfter = Date.now(); + await page.goto("/login"); + await page.getByRole("tab", { name: "Email" }).click(); + await page.getByLabel("Email address").fill(user.email); + const otpRequest = page.waitForRequest((request) => request.url().includes("/auth/v1/otp")); + await page.getByRole("button", { name: "Send code" }).click(); + // The link returns to the callback route with this sign-in's own flow id. + const redirectTo = new URL((await otpRequest).url()).searchParams.get("redirect_to") ?? ""; + expect(redirectTo).toMatch(/^http:\/\/127\.0\.0\.1:18081\/auth\/callback\?sb_flow_id=[0-9a-f]{32}$/); + await expect(page.getByText("Code sent").first()).toBeVisible(); + const otp = await readOtp(request, user.email, sentAfter); + await evidence.step(`Sent a code; the link returns to ${redirectTo.replace(/sb_flow_id=.*/, "sb_flow_id=<flow>")}`); + + // AUTH-01: the resend hits Auth's rate limit, and auth-js drops that resend's verifier. + await expect(page.getByRole("button", { name: /^Resend in 0:\d\d$/ })).toBeDisabled(); + await expireResendCooldown(page); + await page.route("**/auth/v1/otp**", (route) => route.fulfill({ + status: 429, + contentType: "application/json", + body: JSON.stringify({ code: 429, error_code: "over_email_send_rate_limit", msg: "email rate limit exceeded" }), + })); + await page.getByRole("button", { name: "Resend code" }).click(); + await expect(page.getByText("Too many attempts. Try again later.")).toBeVisible(); + await page.unroute("**/auth/v1/otp**"); + await evidence.shot("01-resend-rate-limited"); + + // The link in prod's email: Auth's /verify with the PKCE token hash of this code. + const link = `${supabaseUrl}/auth/v1/verify?${new URLSearchParams({ + token: `pkce_${createHash("sha224").update(user.email + otp).digest("hex")}`, + type: "magiclink", + redirect_to: redirectTo, + })}`; + await paths.clear(); + await page.goto(link); + await expect(page).toHaveURL(/\/app$/, { timeout: 20_000 }); + await expect(debateControl(page)).toBeVisible(); + // AUTH-17: the callback waits for the exchange; it never shows /login on the way. + const seen = await paths.seen(); + await evidence.json("02-paths-after-link", seen); + expect(seen).toContain("/auth/callback"); + expect(seen).not.toContain("/login"); + await page.reload(); + await expect(page).toHaveURL(/\/app$/); + await evidence.shot("02-signed-in-by-link"); + await evidence.step("Opened the emailed link after the failed resend; signed in on /app without passing /login"); + + // AUTH-02: the same link again, in a browser that is no longer signed in. + await page.evaluate(() => { + for (const key of Object.keys(localStorage)) if (/^debate-auth-[a-z]+$/.test(key)) localStorage.removeItem(key); + }); + await page.goto(link); + await expect(page).toHaveURL(/\/login$/, { timeout: 20_000 }); + await expect(page.getByRole("alert")).toHaveText("This sign-in link has expired or was already used. Send a new code."); + await evidence.shot("03-used-link-message"); + await evidence.aria("03-used-link-message"); + await evidence.step("Opened the used link again; /login says it expired or was used"); +}); + +test("auth-deep-link-email-code: a signed-out deep link signs in by email code and returns to the page", async ({ page, user, request, evidence }) => { + test.skip(!mailer, "WORKSPACE_TEST_MAILER_URL is missing; run through verify.sh drive"); + // AUTH-14 and AX-06: the deep link goes to /login, which has one <main> and an <h1>. + await page.goto("/app/generate"); + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByRole("main")).toHaveCount(1); + await expect(page.getByRole("heading", { level: 1, name: "Sign in to DebateOwl" })).toBeVisible(); + await evidence.aria("01-login-from-deep-link"); + + let sentAfter = Date.now(); + await page.getByLabel("Email address").fill(user.email); + await page.getByRole("button", { name: "Send code" }).click(); + await expect(page.getByText("Code sent", { exact: true })).toBeVisible(); + // AUTH-04: Resend waits out Auth's minimum gap, and says how long. + await expect(page.getByRole("button", { name: /^Resend in 0:\d\d$/ })).toBeDisabled(); + await evidence.shot("02-code-sent-countdown"); + const firstOtp = await readOtp(request, user.email, sentAfter); + + // AUTH-03: real Auth answers otp_expired for a wrong code; the message says it may be wrong. + await page.getByRole("textbox", { name: "Verification code" }).fill(firstOtp === "000000" ? "111111" : "000000"); + await page.getByRole("button", { name: "Verify code" }).click(); + await expect(page.getByRole("alert")).toHaveText("That code is wrong or has expired. Check it or send a new one."); + await evidence.shot("03-wrong-code"); + + // AUTH-05: a new send clears the old error. + await expireResendCooldown(page); + sentAfter = Date.now(); + await page.getByRole("button", { name: "Resend code" }).click(); + await expect(page.getByText("New code sent", { exact: true })).toBeVisible(); + await expect(page.getByRole("alert")).toHaveCount(0); + const otp = await readOtp(request, user.email, sentAfter, firstOtp); + await evidence.step("Wrong code refused, countdown ended, a new code sent"); + + await page.getByRole("textbox", { name: "Verification code" }).fill(otp); + await page.getByRole("button", { name: "Verify code" }).click(); + await expect(page).toHaveURL(/\/app\/generate$/, { timeout: 20_000 }); + await evidence.shot("04-back-on-generate"); + await evidence.step("Signed in by email code and returned to /app/generate"); +}); + +test("auth-switch-account: the next account on this browser starts on /app, gets its own tour, and sign-out stays local", async ({ signedIn: page, evidence }, testInfo) => { + const second = await createDemoUser(`${testInfo.project.name}-second`); + try { + // The first account (tour already seen) leaves off on Settings and signs out. + await page.goto("/app/settings"); + const logout = page.waitForRequest((request) => request.url().includes("/auth/v1/logout")); + await page.getByRole("button", { name: "Sign out" }).click(); + // AUTH-19: this device only. + expect(new URL((await logout).url()).searchParams.get("scope")).toBe("local"); + await expect(page).toHaveURL(/\/login$/); + await evidence.step("Signed out from /app/settings with scope=local"); + + // AUTH-06: a different account signs in on the same /login page. + await page.getByRole("tab", { name: "Demo code" }).click(); + await page.getByRole("textbox", { name: "Demo code" }).fill(second.code); + await page.getByRole("button", { name: "Sign in with demo code" }).click(); + await expect(page).toHaveURL(/\/app$/, { timeout: 20_000 }); + await evidence.step("Signed in as a second account; landed on /app, not /app/settings"); + + // AUTH-07: the second account gets its own tour. + const tour = page.getByText("Welcome to DebateOwl"); + await expect(tour).toBeVisible({ timeout: 10_000 }); + await evidence.shot("01-second-account-tour"); + const tabBar = page.getByRole("navigation", { name: "Mobile navigation" }); + if (await page.getByRole("button", { name: "Open menu" }).isVisible()) { + // Older layouts had a header menu here. The tour must not block it. + await page.getByRole("button", { name: "Open menu" }).click(); + const menu = page.getByRole("navigation", { name: "Mobile navigation" }); + await expect(menu).toBeVisible(); + await evidence.shot("02-menu-open-during-tour"); + await menu.getByRole("link", { name: /History/ }).click(); + await expect(page).toHaveURL(/\/app\/history$/); + await evidence.step("Opened the menu and went to History while the tour showed"); + // The tour waits on /app and picks up again there. + await page.goBack(); + await expect(tour).toBeVisible({ timeout: 10_000 }); + } else if (await tabBar.isVisible()) { + // Phones use the tab bar. History stays on the Home utilities row. + await tabBar.getByRole("link", { name: "Library", exact: true }).click(); + await expect(page).toHaveURL(/\/app\/library$/); + await evidence.shot("02-tab-bar-during-tour"); + await evidence.step("Used the phone tab bar to open Library while the tour showed"); + await page.goBack(); + await expect(tour).toBeVisible({ timeout: 10_000 }); + } + await page.locator('[data-test-id="button-skip"]').click(); + await expect(tour).toBeHidden(); + await evidence.step("Skipped the tour"); + } finally { + await deleteDemoUser(second); + } +}); + +// QA round 3: Sign out lives on Settings, and the same account signing back in by +// email code landed on /app/settings ("Opening Settings") instead of /app. +test("auth-same-account-after-sign-out: signing back in by email code after Sign out on Settings opens /app", async ({ signedIn: page, user, request, evidence }) => { + test.skip(!mailer, "WORKSPACE_TEST_MAILER_URL is missing; run through verify.sh drive"); + await page.goto("/app/settings"); + await page.getByRole("button", { name: "Sign out" }).click(); + await expect(page).toHaveURL(/\/login$/); + await evidence.step("Signed out from /app/settings"); + + const sentAfter = Date.now(); + await page.getByRole("tab", { name: "Email" }).click(); + await page.getByLabel("Email address").fill(user.email); + await page.getByRole("button", { name: "Send code" }).click(); + await expect(page.getByText("Code sent").first()).toBeVisible(); + const otp = await readOtp(request, user.email, sentAfter); + await page.getByRole("textbox", { name: "Verification code" }).fill(otp); + await page.getByRole("button", { name: "Verify code" }).click(); + await expect(page).toHaveURL(/\/app$/, { timeout: 20_000 }); + await expect(page.getByText("Opening Settings")).toHaveCount(0); + await evidence.shot("01-landed-on-app"); + await evidence.step("The same account signed back in by email code and landed on /app"); +}); diff --git a/.claude/skills/verify-debatelab/recipes/billing-states.spec.ts b/.claude/skills/verify-debatelab/recipes/billing-states.spec.ts new file mode 100644 index 000000000..7ec6ca81e --- /dev/null +++ b/.claude/skills/verify-debatelab/recipes/billing-states.spec.ts @@ -0,0 +1,357 @@ +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, 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 +// leaves behind), then Settings, the pricing dialog and an AI request must +// agree on it. + +const DAY = 24 * 60 * 60 * 1000; +const at = (days: number) => new Date(Date.now() + days * DAY).toISOString(); + +type Text = { en: string; hu: string }; +type State = { + name: string; + settings: Record<string, unknown>; + subscription?: Record<string, unknown>; + badge: Text; + text: { en: RegExp; hu: RegExp }; + /** The one plan button, the same in Settings and the pricing dialog. */ + button: Text; + /** The pricing dialog card marked Current. */ + plan: "plan-trial" | "plan-pro" | null; + /** The plan's daily AI uses; null when the account has no access. */ + dailyLimit: number | null; + /** What a refused AI request opens. */ + refusal: { title: Text; button: Text }; +}; + +const UPGRADE: Text = { en: "Upgrade to Pro", hu: "Váltás Pro-ra" }; +const MANAGE: Text = { en: "Manage subscription", hu: "Előfizetés kezelése" }; +const PAUSED: Text = { en: "Daily AI uses used up", hu: "Elfogyott a napi MI-keret" }; +const OK: Text = { en: "OK", hu: "Rendben" }; + +const STATES: State[] = [ + { + name: "trial", + settings: { subscription_tier: "trial", trial_ends_at: at(2.5) }, + badge: { en: "Free trial", hu: "Ingyenes próba" }, + text: { en: /AI access is active/, hu: /Az MI-hozzáférés aktív/ }, + button: UPGRADE, + plan: "plan-trial", + dailyLimit: 5, + refusal: { title: PAUSED, button: UPGRADE }, + }, + { + // QB-02: an override on an expired trial is a deliberate grant. + name: "extended-access", + settings: { subscription_tier: "trial", trial_ends_at: at(-1), daily_limit_override: 5 }, + badge: { en: "Extended access", hu: "Bővített hozzáférés" }, + text: { en: /Extended access is active: 5 AI uses a day/, hu: /Bővített hozzáférésed van: napi 5 MI-használat/ }, + button: UPGRADE, + plan: null, + dailyLimit: 5, + refusal: { title: PAUSED, button: UPGRADE }, + }, + { + name: "pro-active", + settings: { subscription_tier: "pro" }, + subscription: { status: "active", current_period_start: at(-10), current_period_end: at(20), cancel_at_period_end: false }, + badge: { en: "Pro", hu: "Pro" }, + text: { en: /Renews on/, hu: /Megújul:/ }, + button: MANAGE, + plan: "plan-pro", + dailyLimit: 50, + refusal: { title: PAUSED, button: OK }, + }, + { + // QB-15 + name: "pro-canceling", + settings: { subscription_tier: "pro" }, + subscription: { status: "active", current_period_start: at(-10), current_period_end: at(20), cancel_at_period_end: true }, + badge: { en: "Pro", hu: "Pro" }, + text: { en: /Cancels on/, hu: /Lemondva, lejár:/ }, + button: MANAGE, + plan: "plan-pro", + dailyLimit: 50, + refusal: { title: PAUSED, button: OK }, + }, + { + // QA round 3: Pro from a promo code, no Stripe customer. Settings says where + // Pro came from and that billing can wait until it ends. + name: "promo-pro", + 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" }, + plan: "plan-pro", + dailyLimit: 50, + refusal: { title: PAUSED, button: OK }, + }, + { + // QB-13: the tier the old webhook wrote on the first failed invoice. + name: "past-due-grace", + settings: { subscription_tier: "expired" }, + subscription: { status: "past_due", current_period_start: at(-2), current_period_end: at(28) }, + badge: { en: "Payment failed", hu: "Sikertelen fizetés" }, + text: { en: /Pro access continues until/, hu: /a Pro-hozzáférésed .+ napjáig megmarad/ }, + button: MANAGE, + plan: "plan-pro", + dailyLimit: 50, + refusal: { title: PAUSED, button: OK }, + }, + { + // QB-14 + name: "subscription-ended", + settings: { subscription_tier: "expired", trial_ends_at: at(-60) }, + subscription: { status: "canceled", current_period_start: at(-40), current_period_end: at(-10), ended_at: at(-10) }, + badge: { en: "Expired", hu: "Lejárt" }, + text: { en: /Your Pro subscription ended on/, hu: /A Pro-előfizetésed véget ért/ }, + button: { en: "Resubscribe", hu: "Újra előfizetek" }, + plan: null, + dailyLimit: null, + refusal: { title: { en: "Your Pro subscription has ended", hu: "Lejárt a Pro-előfizetésed" }, button: { en: "Resubscribe", hu: "Újra előfizetek" } }, + }, +]; + +/** A state by name, so adding one to the list never shifts another test's pick. */ +const stateNamed = (name: string) => STATES.find((candidate) => candidate.name === name)!; + +const today = () => new Date().toISOString().slice(0, 10); + +async function setState(userId: string, state: State) { + await admin.from("subscriptions").delete().eq("user_id", userId); + await admin.from("daily_usage_counters").delete().eq("user_id", userId); + const { error } = await admin.from("user_settings").update({ + subscription_tier: "trial", + trial_ends_at: null, + grace_period_ends_at: null, + daily_limit_override: null, + ...state.settings, + }).eq("user_id", userId); + if (error) throw error; + if (state.subscription) { + const { error: subError } = await admin.from("subscriptions").insert({ + user_id: userId, + stripe_customer_id: `cus_verify_${randomUUID()}`, + stripe_subscription_id: `sub_verify_${randomUUID()}`, + ...state.subscription, + }); + if (subError) throw subError; + } +} + +async function chargeAIUnits(userId: string, units: number) { + await admin.from("daily_usage_counters").delete().eq("user_id", userId); + const { error } = await admin.from("daily_usage_counters").insert({ user_id: userId, usage_date: today(), quota_key: "ai", request_count: units }); + if (error) throw error; +} + +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(); + 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(); + await expect(dialog).toBeHidden(); +} + +async function useHungarian(page: Page) { + await page.goto("/app/settings"); + await page.getByLabel("Interface language").click(); + await page.getByRole("option", { name: "Magyar" }).click(); + await expect(page.getByText("Előfizetés", { exact: true })).toBeVisible(); +} + +// Phones on the touch project, tablet and desktop on the desktop project. +const WIDTHS: Record<string, number[]> = { "mobile-chromium": [360, 375], "desktop-chromium": [768, 1280] }; + +/** One region at each width this project covers, light and dark. */ +async function shots(page: Page, evidence: Evidence, testInfo: TestInfo, name: string, region: () => Locator) { + const original = page.viewportSize()!; + for (const width of WIDTHS[testInfo.project.name] ?? [original.width]) { + await page.setViewportSize({ width, height: original.height }); + for (const colorScheme of ["light", "dark"] as const) { + await page.emulateMedia({ colorScheme }); + await expect(page.locator("html")).toHaveClass(colorScheme === "dark" ? /\bdark\b/ : /^(?!.*\bdark\b)/); + await region().screenshot({ path: join(evidence.dir, `${name}-${width}-${colorScheme}.png`), animations: "disabled" }); + } + } + await page.emulateMedia({ colorScheme: "light" }); + await page.setViewportSize(original); +} + +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 + // payer reaches it, and the path QB-16 is about. + let portalCalls = 0; + await page.route("**/functions/v1/create-portal", (route) => { + portalCalls += 1; + return route.fulfill({ status: 400, contentType: "application/json", body: JSON.stringify({ error: "No active subscription found. Upgrade to Pro to start a subscription." }) }); + }); + + for (const state of STATES) { + await setState(userId, state); + await page.goto("/app/settings"); + // That fallback also toasts "No subscription found"; the shots are of the dialog. + await page.addStyleTag({ content: "[data-sonner-toaster] { display: none !important; }" }); + const card = page.getByTestId("subscription-card"); + await expect(card.getByText(state.badge[language], { exact: true })).toBeVisible(); + await expect(card.getByText(state.text[language])).toBeVisible(); + await expect(card.getByRole("button", { name: state.button[language] })).toBeVisible(); + // QB-02: never "Expired · Your access has expired" beside a working plan. + await expect(card.getByText(language === "en" ? "Your access has expired" : "A hozzáférésed lejárt", { exact: false })).toHaveCount(0); + // QB-03: the server's counter, the same number on a tool page. + if (state.dailyLimit) { + const usage = language === "en" ? `${state.dailyLimit} of ${state.dailyLimit} AI uses left today` : `Mára ${state.dailyLimit} / ${state.dailyLimit} MI-használat maradt`; + await expect(card.getByTestId("daily-usage")).toContainText(usage); + } else { + await expect(card.getByTestId("daily-usage")).toHaveCount(0); + } + await expect(page.getByTestId("trial-banner")).toHaveCount(state.name === "trial" ? 1 : 0); + await shots(page, evidence, testInfo, `${language}-${state.name}-settings`, () => card); + + // QB-16: the dialog marks the plan the account is on and offers only its next step. + const portalBefore = portalCalls; + await card.getByRole("button", { name: state.button[language] }).click(); + const pricing = page.getByRole("dialog"); + if (state.button === MANAGE) await expect.poll(() => portalCalls).toBe(portalBefore + 1); + await expect(pricing.getByRole("button", { name: state.button[language] })).toBeVisible(); + for (const other of [UPGRADE, MANAGE, { en: "Resubscribe", hu: "Újra előfizetek" }].filter((text) => text.en !== state.button.en)) { + await expect(pricing.getByRole("button", { name: other[language], exact: true })).toHaveCount(0); + } + for (const plan of ["plan-trial", "plan-pro"] as const) { + await expect(pricing.getByTestId(plan).getByText(language === "en" ? "Current" : "Jelenlegi", { exact: true })).toHaveCount(plan === state.plan ? 1 : 0); + } + // IN-07: the price comes from the price config in the reader's format. + await expect(pricing.getByTestId("plan-pro")).toContainText(language === "en" ? "$5" : "5 $"); + // VS-08: a 44 px close target. + const close = await pricing.getByRole("button", { name: language === "en" ? "Close" : "Bezárás" }).boundingBox(); + expect(Math.round(close!.width)).toBeGreaterThanOrEqual(44); + expect(Math.round(close!.height)).toBeGreaterThanOrEqual(44); + await shots(page, evidence, testInfo, `${language}-${state.name}-pricing`, () => page.getByRole("dialog")); + await page.keyboard.press("Escape"); + await expect(pricing).toBeHidden(); + + // QB-06: a refused request opens the same dialog in every state's own words. + if (state.dailyLimit) await chargeAIUnits(userId, state.dailyLimit); + await brainstorm(page, language); + const refused = page.getByRole("dialog"); + await expect(refused.getByText(state.refusal.title[language], { exact: true })).toBeVisible({ timeout: AI_TIMEOUT }); + await expect(refused.getByRole("button", { name: state.refusal.button[language] })).toBeVisible(); + await expect(refused.getByText(/non-2xx|Edge Function/)).toHaveCount(0); + await shots(page, evidence, testInfo, `${language}-${state.name}-limit`, () => page.getByRole("dialog")); + await page.keyboard.press("Escape"); + } + await evidence.step(`${language}: each state shows its own badge, usage, pricing dialog and refusal`); + }); +} + +// 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(); + await expect(page.getByText("Suscripción", { exact: true })).toBeVisible(); + const ES: Record<string, { badge: string; button: string }> = { + "trial": { badge: "Prueba gratis", button: "Mejorar a Pro" }, + "pro-canceling": { badge: "Pro", button: "Gestionar suscripción" }, + "past-due-grace": { badge: "Pago fallido", button: "Gestionar suscripción" }, + }; + for (const state of STATES.filter((candidate) => candidate.name in ES)) { + await setState(userId, state); + await page.goto("/app/settings"); + const card = page.getByTestId("subscription-card"); + await expect(card.getByText(ES[state.name].badge, { exact: true })).toBeVisible(); + await expect(card.getByRole("button", { name: ES[state.name].button })).toBeVisible(); + await shots(page, evidence, testInfo, `es-${state.name}-settings`, () => card); + } + await evidence.step("es: trial, cancelling and past-due cards"); +}); + +test("bill-usage: the tool pages show the server's weighted count, and it updates after a run", async ({ signedIn: page, user, evidence }) => { + const userId = await user.userId(); + await setState(userId, STATES[0]); + // Three units already charged today, as a weighted model or a Sparring round leaves them. + await chargeAIUnits(userId, 3); + + await page.goto("/app/settings"); + await expect(page.getByTestId("daily-usage")).toContainText("2 of 5 AI uses left today"); + for (const tool of ["generate", "attack", "brainstorm", "factoids", "ask", "crossfire", "sparring"]) { + await page.goto(`/app/${tool}`); + await expect(page.getByTestId("daily-usage")).toHaveText("2 of 5 AI uses left today"); + } + await evidence.shot("01-sparring-header"); + + const served = aiResponse(page, "brainstorm"); + await brainstorm(page); + await served; + // No reload: the count is read again after the request. + await expect(page.getByTestId("daily-usage")).toHaveText("1 of 5 AI uses left today"); + await evidence.shot("02-brainstorm-after-run"); + await evidence.step("Settings and every tool show 2 of 5 left; one run later Brainstorm shows 1 of 5 without a reload"); +}); + +test("bill-trial-banner: the trial banner counts down, opens the plans and stays dismissed", async ({ signedIn: page, user, evidence }) => { + await setState(await user.userId(), STATES[0]); + await page.goto("/app"); + const banner = page.getByTestId("trial-banner"); + await expect(banner).toContainText(/Free trial: [23] days left\./); + await evidence.shot("01-banner"); + await banner.getByRole("button", { name: "Upgrade to Pro" }).click(); + await expect(page.getByRole("dialog").getByRole("heading", { name: "Upgrade to Pro" })).toBeVisible(); + await page.keyboard.press("Escape"); + await banner.getByRole("button", { name: "Hide the trial reminder" }).click(); + await expect(banner).toHaveCount(0); + // Once the plan has loaded again (the Settings badge), the banner stays away. + await page.goto("/app/settings"); + await expect(page.getByTestId("subscription-card").getByText("Free trial", { exact: true })).toBeVisible(); + await expect(page.getByTestId("trial-banner")).toHaveCount(0); + await evidence.step("Trial banner shows days left, opens the pricing dialog, and stays hidden after dismissal"); +}); + +test("bill-redirect: /app/billing opens the Settings subscription section", async ({ signedIn: page, evidence }) => { + await page.goto("/app/billing"); + await expect(page).toHaveURL(/\/app\/settings#subscription$/); + await expect(page.getByTestId("subscription-card")).toBeInViewport(); + await evidence.shot("01-billing-redirect"); + await evidence.step("QB-12: /app/billing lands on Settings with the subscription card in view"); +}); + +test("bill-access: extended access is served; a past-due payer after the grace is sent to Settings", async ({ signedIn: page, user, evidence }) => { + const userId = await user.userId(); + + // QB-02: extended access really is served. + await setState(userId, stateNamed("extended-access")); + const served = aiResponse(page, "brainstorm"); + await brainstorm(page); + expect(((await served).output.topFive as unknown[]).length).toBeGreaterThan(0); + await evidence.shot("ai-extended-access-served"); + + // QB-13: past the grace, a past-due payer is refused with payment_failed and sent to Settings, not Checkout. + await setState(userId, { + ...stateNamed("past-due-grace"), + subscription: { status: "past_due", current_period_start: at(-20), current_period_end: at(10) }, + }); + await brainstorm(page); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByText("Payment failed", { exact: true })).toBeVisible({ timeout: AI_TIMEOUT }); + await expect(dialog.getByText(/free trial/i)).toHaveCount(0); + await expect(dialog.getByRole("button", { name: /Upgrade to Pro/ })).toHaveCount(0); + await evidence.shot("ai-payment-failed-refused"); + await dialog.getByRole("button", { name: "Update payment method" }).click(); + await expect(page).toHaveURL(/\/app\/settings$/); + await expect(page.getByText("Payment failed", { exact: true })).toBeVisible(); + await evidence.step("Extended access is served; a past-due payer after the grace gets Payment failed and goes to Settings"); +}); diff --git a/.claude/skills/verify-debatelab/recipes/billing.spec.ts b/.claude/skills/verify-debatelab/recipes/billing.spec.ts index 64661910b..d8e7a7925 100644 --- a/.claude/skills/verify-debatelab/recipes/billing.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/billing.spec.ts @@ -15,14 +15,14 @@ test("bill-promo: an expired account is blocked, redeems a promo code, and gets if (error) throw error; const brainstorm = async () => { await page.goto("/app/brainstorm"); - await page.getByLabel("Motion / Topic *").fill("Public transport should be free"); - await page.getByRole("button", { name: "Start Brainstorming" }).click(); + await page.getByLabel("Motion / topic *").fill("Public transport should be free"); + await page.getByRole("button", { name: "Start brainstorming" }).click(); // No Current Debate, so the motion check asks first; the paywall (or the run) follows it. await chooseForMotion(page, "once"); }; try { await brainstorm(); - await expect(page.getByRole("dialog").getByText("Free Trial Ended")).toBeVisible({ timeout: AI_TIMEOUT }); + await expect(page.getByRole("dialog").getByText("Your free trial has ended")).toBeVisible({ timeout: AI_TIMEOUT }); await evidence.shot("01-blocked"); expect((await providerCalls()).filter((call) => call.feature === "brainstorm")).toEqual([]); @@ -31,7 +31,7 @@ test("bill-promo: an expired account is blocked, redeems a promo code, and gets await page.getByRole("button", { name: "Have a promo code?" }).click(); await page.getByPlaceholder("Enter promo code").fill(code); await page.getByRole("button", { name: "Apply", exact: true }).click(); - await expect(page.getByText("Promo code applied!").first()).toBeVisible(); + await expect(page.getByText("Promo code applied").first()).toBeVisible(); await evidence.shot("02-promo-applied"); const { data } = await admin.from("user_settings").select("subscription_tier").eq("user_id", userId).single(); expect(data?.subscription_tier).toBe("pro"); @@ -45,3 +45,56 @@ test("bill-promo: an expired account is blocked, redeems a promo code, and gets await admin.from("promo_codes").delete().eq("id", promo.id); } }); + +test("bill-discount: a discount code is claimed once, stays in the dialog, and goes to Checkout", async ({ signedIn: page, user, evidence }) => { + const userId = await user.userId(); + // An ended trial, so Settings and the dialog both offer "Upgrade to Pro". + await admin.from("user_settings") + .update({ subscription_tier: "expired", trial_ends_at: null, grace_period_ends_at: null, daily_limit_override: null }) + .eq("user_id", userId); + const code = `VERIFYD${randomUUID().slice(0, 8).toUpperCase()}`; + const { data: promo, error } = await admin.from("promo_codes") + .insert({ code, type: "discount", config: { stripe_promotion_code: "promo_VerifyRecipe" }, max_redemptions: 1 }) + .select("id").single(); + if (error) throw error; + const claims = async () => { + const [{ data: row }, { count }] = await Promise.all([ + admin.from("promo_codes").select("redemptions_count").eq("id", promo.id).single(), + admin.from("user_promos").select("id", { count: "exact", head: true }).eq("promo_code_id", promo.id).eq("user_id", userId), + ]); + return { redemptions: row?.redemptions_count, held: count }; + }; + // Stripe Checkout is out of reach locally; record what the dialog sends instead. + const checkoutBodies: unknown[] = []; + await page.route("**/functions/v1/create-checkout", async (route) => { + checkoutBodies.push(route.request().postDataJSON()); + await route.fulfill({ status: 400, json: { error: "Invalid or expired promo code", code: "promo_invalid" } }); + }); + try { + await page.goto("/app/settings"); + await page.getByRole("button", { name: "Upgrade to Pro" }).click(); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("button", { name: "Have a promo code?" }).click(); + await dialog.getByPlaceholder("Enter promo code").fill(code); + await dialog.getByRole("button", { name: "Apply", exact: true }).click(); + await expect(page.getByText("Discount code saved! It will be applied when you upgrade.").first()).toBeVisible(); + await expect(dialog.getByPlaceholder("Enter promo code")).toHaveValue(code); + await evidence.shot("01-discount-saved"); + expect(await claims()).toEqual({ redemptions: 1, held: 1 }); + + // Applying again is not a second claim. + const again = page.waitForResponse((response) => response.url().includes("/functions/v1/redeem-promo")); + await dialog.getByRole("button", { name: "Apply", exact: true }).click(); + expect(await (await again).json()).toMatchObject({ success: true, code: "promo_discount_noted" }); + expect(await claims()).toEqual({ redemptions: 1, held: 1 }); + + await dialog.getByRole("button", { name: "Upgrade to Pro" }).click(); + await expect.poll(() => checkoutBodies.length).toBe(1); + expect(checkoutBodies[0]).toMatchObject({ promoCode: code }); + await evidence.shot("02-checkout-sent-code"); + } finally { + await page.unroute("**/functions/v1/create-checkout"); + await admin.from("user_promos").delete().eq("promo_code_id", promo.id); + await admin.from("promo_codes").delete().eq("id", promo.id); + } +}); diff --git a/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts b/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts index 1920fe22a..181585152 100644 --- a/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts @@ -13,14 +13,14 @@ type CaseOutput = { caseStructure: { thesis: string }; contentions: Array<{ clai async function openGenerate(page: import("@playwright/test").Page) { await startDebate(page, motion); await page.goto("/app/generate"); - await expect(page.getByRole("heading", { name: "Ready to Generate" })).toBeVisible(); - await expect(page.getByRole("textbox", { name: "Motion / Topic *" })).toHaveValue(motion); + await expect(page.getByRole("heading", { name: "Your case will appear here" })).toBeVisible(); + await expect(page.getByRole("textbox", { name: "Motion / topic *" })).toHaveValue(motion); } async function generate(page: import("@playwright/test").Page) { 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. @@ -92,10 +92,10 @@ test("gen-create: a case generated in the Current Debate is filed there and reop test("gen-save: a one-off case is saved under a title and reopens from History on a fresh device", async ({ signedIn: page, user, evidence, browser }) => { await page.goto("/app/generate"); - await expect(page.getByRole("heading", { name: "Ready to Generate" })).toBeVisible(); - await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); + await expect(page.getByRole("heading", { name: "Your case will appear here" })).toBeVisible(); + await page.getByRole("textbox", { name: "Motion / topic *" }).fill(motion); const response = aiResponse(page, "generate"); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + 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`); @@ -119,6 +119,36 @@ test("gen-save: a one-off case is saved under a title and reopens from History o await reopenFromHistory(browser, user, title, output.caseStructure.thesis, evidence, "02", "one-off"); }); +test("gen-flowchart: the Flowchart builds from the Current Debate's case, and its app text follows the language", async ({ signedIn: page, evidence }) => { + const { output } = await generate(page); + await page.goto("/app/flowchart"); + // The template stays the blank start; the case is one click away. + await expect(page.getByText("Your Thesis", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Build from current case" }).click(); + await expect(page.getByText(output.caseStructure.thesis).first()).toBeVisible(); + for (const contention of output.contentions) await expect(page.getByText(contention.claim).first()).toBeVisible(); + await expect(page.getByText("Your Thesis", { exact: true })).toHaveCount(0); + // Every node fits the canvas once it is built. + await expect.poll(() => page.evaluate(() => { + const canvas = document.querySelector(".react-flow")!.getBoundingClientRect(); + return [...document.querySelectorAll(".react-flow__node")].filter((node) => { + const box = node.getBoundingClientRect(); + return box.left < canvas.left - 1 || box.right > canvas.right + 1 || box.top < canvas.top - 1 || box.bottom > canvas.bottom + 1; + }).length; + })).toBe(0); + await expect(page.getByRole("button", { name: "From Thesis to Argument 1" })).toHaveCount(1); + await evidence.shot("01-built-from-case"); + + // Node titles the app wrote follow the language on the same canvas; the case's own text does not change. + await page.getByRole("button", { name: "Language", exact: true }).click(); + await page.getByRole("radiogroup", { name: "Interface language" }).getByRole("radio", { name: "Magyar" }).click(); + await page.keyboard.press("Escape"); + await expect(page.getByText("1. érv", { exact: true })).toBeVisible(); + await expect(page.getByText("Argument 1", { exact: true })).toHaveCount(0); + await expect(page.getByText(output.caseStructure.thesis).first()).toBeVisible(); + await evidence.shot("02-built-from-case-hu"); +}); + test("gen-research: generation researches the web and cites what it found", async ({ signedIn: page, evidence }) => { const { body } = await generate(page); await evidence.json("01-generate-response", body); @@ -172,7 +202,7 @@ test("gen-regenerate: regenerating one section keeps the rest of the case and it await expect(overlay).toBeVisible(); await expect(page.getByText(thesis).first()).toBeVisible(); await expect(page.getByRole("button", { name: "PDF", exact: true })).toBeVisible(); - await expect(page.getByRole("button", { name: "Regenerate Case Structure" })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Regenerate Case structure" })).toBeDisabled(); // The form button reads "Generating..." while busy, so find it by its tour anchor. await expect(page.locator('[data-tour="generate-button"]')).toBeDisabled(); await evidence.shot("02-regenerating-one-section"); @@ -184,30 +214,49 @@ test("gen-regenerate: regenerating one section keeps the rest of the case and it await expect(page.getByText("Section regenerated").first()).toBeVisible({ timeout: AI_TIMEOUT }); await expect(overlay).toHaveCount(0); await expect(page.getByText(thesis).first()).toBeVisible(); - await expect(page.getByRole("button", { name: "Regenerate Case Structure" })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Regenerate Case structure" })).toBeEnabled(); await evidence.shot("03-regenerated"); }); 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 expect(page.getByText("Generation Failed", { exact: true })).toBeVisible({ timeout: AI_TIMEOUT }); + 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 page.getByRole("button", { name: "Generate Debate Case" }).click(); + await failure.getByRole("button", { name: "Retry" }).click(); const body = await response; + await expect(failure).toHaveCount(0); await expect(page.getByText((body.output as unknown as CaseOutput).caseStructure.thesis).first()).toBeVisible(); await evidence.shot("02-retried"); }); +test("gen-cutoff: a case cut off at its length cap is retried once with a larger cap and still appears", async ({ signedIn: page, evidence }) => { + test.skip(LIVE, "only the fake provider can force a cut-off reply"); + await fake.truncate("generate"); + await openGenerate(page); + const response = aiResponse(page, "generate"); + 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); + const caps = (await fake.requests()).filter((request) => request.feature === "generate").map((request) => request.maxTokens ?? 0); + await evidence.json("01-generate-max-tokens", caps); + expect(caps).toHaveLength(2); + expect(caps[1]).toBeGreaterThan(caps[0]); + await evidence.shot("02-case-after-retry"); +}); + 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 expect(page.getByRole("dialog").getByText("Daily Access Paused")).toBeVisible({ timeout: AI_TIMEOUT }); + await page.getByRole("button", { name: "Generate debate case" }).click(); + await expect(page.getByRole("dialog").getByText("Daily AI uses used up")).toBeVisible({ timeout: AI_TIMEOUT }); await evidence.shot("01-quota-dialog"); expect((await fake.requests()).filter((request) => request.feature === "generate")).toEqual([]); }); diff --git a/.claude/skills/verify-debatelab/recipes/current-debate.spec.ts b/.claude/skills/verify-debatelab/recipes/current-debate.spec.ts index a7918eca8..dcd4803b2 100644 --- a/.claude/skills/verify-debatelab/recipes/current-debate.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/current-debate.spec.ts @@ -13,9 +13,9 @@ 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("textbox", { name: "Source title..." }).fill(title); - await page.getByRole("textbox", { name: "Paste source content..." }).fill(content); + await page.getByRole("button", { name: "Add source" }).click(); + await page.getByRole("textbox", { name: "Title", exact: true }).fill(title); + await page.getByRole("textbox", { name: "Content", exact: true }).fill(content); await page.getByRole("button", { name: "Save to Library" }).click(); await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked(); } @@ -56,10 +56,10 @@ test("debate-start, debate-scope, debate-once, debate-switch: two debates keep t await page.goto("/app/brainstorm"); await expect(page.getByTestId("current-debate-strip")).toContainText(`Working in: ${HOMEWORK}`); - await expect(page.getByLabel("Motion / Topic *")).toHaveValue(HOMEWORK); + await expect(page.getByLabel("Motion / topic *")).toHaveValue(HOMEWORK); let before = await callCount("brainstorm"); const homeworkRun = aiResponse(page, "brainstorm"); - await page.getByRole("button", { name: "Start Brainstorming" }).click(); + 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); @@ -80,10 +80,10 @@ test("debate-start, debate-scope, debate-once, debate-switch: two debates keep t await evidence.shot("03-library-in-zoo-debate"); await page.goto("/app/generate"); - await expect(page.getByRole("textbox", { name: "Motion / Topic *" })).toHaveValue(ZOOS); + await expect(page.getByRole("textbox", { name: "Motion / topic *" })).toHaveValue(ZOOS); before = await callCount("generate"); const zooRun = aiResponse(page, "generate"); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + 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); @@ -94,10 +94,10 @@ test("debate-start, debate-scope, debate-once, debate-switch: two debates keep t // A different motion asks first; "use once" keeps it out of every debate. await page.goto("/app/brainstorm"); - await page.getByLabel("Motion / Topic *").fill("This House would lower the voting age"); + await page.getByLabel("Motion / topic *").fill("This House would lower the voting age"); before = await callCount("brainstorm"); const oneOffRun = aiResponse(page, "brainstorm"); - await page.getByRole("button", { name: "Start Brainstorming" }).click(); + 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")); @@ -155,9 +155,9 @@ test("debate-start, debate-scope, debate-once, debate-switch: two debates keep t test("debate-offer: with no Current Debate, a tool offers to start one for the motion it was given", async ({ signedIn: page, user, evidence }) => { const motion = "This House would abolish zoos"; await page.goto("/app/generate"); - await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); + await page.getByRole("textbox", { name: "Motion / topic *" }).fill(motion); const run = aiResponse(page, "generate"); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + 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); @@ -198,7 +198,7 @@ test("debate-tour: a brand-new account starts its first debate from the onboardi const tooltip = page.locator(".react-joyride__tooltip"); const next = page.locator('[data-test-id="button-primary"]'); - await expect(tooltip).toContainText("Welcome to DebateLab!", { timeout: 15_000 }); + await expect(tooltip).toContainText("Welcome to DebateOwl", { timeout: 15_000 }); await expect(tooltip).toContainText("Start your first debate here."); await evidence.shot("01-tour-first-step"); @@ -214,9 +214,9 @@ test("debate-tour: a brand-new account starts its first debate from the onboardi await next.click(); await expect(tooltip).toContainText("Generate"); await next.click(); - await page.getByRole("button", { name: "Get Started!" }).click(); + await page.getByRole("button", { name: "Get started" }).click(); await expect(tooltip).toHaveCount(0); - await evidence.step("Tour: Library → Generate → Get Started!"); + await evidence.step("Tour: Library → Generate → Get started"); const userId = await user.userId(); const { data: debates } = await admin.from("debates").select("id,motion").eq("user_id", userId); @@ -227,9 +227,9 @@ test("debate-tour: a brand-new account starts its first debate from the onboardi // The first tool already works inside that debate, with no motion check. await page.locator('main [data-tour="mode-generate"]').click(); await expect(page.getByTestId("current-debate-strip")).toContainText(`Working in: ${motion}`); - await expect(page.getByRole("textbox", { name: "Motion / Topic *" })).toHaveValue(motion); + await expect(page.getByRole("textbox", { name: "Motion / topic *" })).toHaveValue(motion); const run = aiResponse(page, "generate"); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await 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 3a87ea4e8..49bf24c38 100644 --- a/.claude/skills/verify-debatelab/recipes/fixtures.ts +++ b/.claude/skills/verify-debatelab/recipes/fixtures.ts @@ -12,6 +12,8 @@ import { expect, fake, stackReady, test as stackTest } from "../../../../e2e/ful export * from "../../../../e2e/full-stack/stack"; // Scripted microphone, recogniser, and browser voice for spoken rounds. export { say, spokenByBrowser, stubSpeech } from "../../../../e2e/helpers/voice"; +import { readAIResponse, type AIResponseBody } from "../../../../e2e/helpers/ai-response"; +export { readAIResponse }; if (!stackReady) throw new Error("Stack environment missing; run recipes through `verify.sh drive`"); @@ -47,12 +49,38 @@ export function aiResponse(page: Page, feature: string, operation?: string) { } catch { return false; } - }, { timeout: AI_TIMEOUT }).then(async (response) => { - expect(response.status(), `ai ${feature} status`).toBe(200); - return response.json() as Promise<{ output: Record<string, unknown>; discoveredSources?: unknown[]; [key: string]: unknown }>; + }, { timeout: AI_TIMEOUT }).then(async (response): Promise<AIResponseBody> => { + // Pages stream the answer live; the final event holds the status and body. + const { status, body } = await readAIResponse(response); + expect(status, `ai ${feature} status`).toBe(200); + return body; }); } +/** + * Each feature's provider calls asked the model to stream: the page showed + * the answer live instead of waiting for the whole reply. + */ +export async function expectStreamed(features: string[]) { + const calls = await providerCalls(); + for (const feature of features) { + const own = calls.filter((call) => call.feature === feature); + expect(own.length, `provider calls for ${feature}`).toBeGreaterThan(0); + expect(own.map((call) => call.stream), `${feature} calls streamed`).toEqual(own.map(() => true)); + } +} + +/** + * 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 879b2e713..c998f9542 100644 --- a/.claude/skills/verify-debatelab/recipes/library.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/library.spec.ts @@ -2,13 +2,13 @@ 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("textbox", { name: "Source title..." }).fill(title); - await page.getByRole("textbox", { name: "Paste source content..." }).fill(content); + await page.getByRole("button", { name: "Add source" }).click(); + await page.getByRole("textbox", { name: "Title", exact: true }).fill(title); + await page.getByRole("textbox", { name: "Content", exact: true }).fill(content); await page.getByRole("button", { name: "Save to Library" }).click(); await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked(); } @@ -22,14 +22,17 @@ test("lib-add, lib-toggle, lib-ground, lib-delete: only active sources ground AI await addSource(page, "Paused study", "PAUSED-SOURCE-MARKER congestion did not change."); await page.getByRole("switch", { name: "Toggle Paused study" }).click(); await expect(page.getByRole("switch", { name: "Toggle Paused study" })).not.toBeChecked(); + // The switch moves before its write lands; leaving the page first could cancel the write. + await expect.poll(async () => (await admin.from("sources").select("is_active").eq("user_id", await user.userId()).eq("title", "Paused study")).data) + .toEqual([{ is_active: false }]); await evidence.step("Added two sources and paused one"); await evidence.shot("01-library"); // Brainstorm prefills the Current Debate's motion, so no motion check opens. await page.goto("/app/brainstorm"); - await expect(page.getByLabel("Motion / Topic *")).toHaveValue(motion); + await expect(page.getByLabel("Motion / topic *")).toHaveValue(motion); const response = aiResponse(page, "brainstorm"); - await page.getByRole("button", { name: "Start Brainstorming" }).click(); + await 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"); @@ -37,13 +40,28 @@ test("lib-add, lib-toggle, lib-ground, lib-delete: only active sources ground AI await evidence.json("02-grounding", { activeInPrompt: true, pausedInPrompt: false }); await page.goto("/app/library"); + const userId = await user.userId(); + const pausedRow = async () => + ((await admin.from("sources").select("id,created_at,is_active").eq("user_id", userId).eq("title", "Paused study")).data ?? []) + // The client keeps milliseconds; Postgres keeps microseconds. + .map((row) => ({ id: row.id, at: Math.floor(Date.parse(row.created_at)), active: row.is_active })); + const before = await pausedRow(); + expect(before).toHaveLength(1); + // Deleting a source acts at once; the toast's Undo puts the same row back (same id, so a + // per-request exclusion still applies, and the same date, so it keeps its place). + await page.getByRole("button", { name: "Delete Paused study", exact: true }).click(); + await expect(page.getByRole("switch", { name: "Toggle Paused study" })).toHaveCount(0); + await expect.poll(pausedRow).toEqual([]); + await page.getByRole("button", { name: "Undo" }).click(); + await expect(page.getByRole("switch", { name: "Toggle Paused study" })).toHaveCount(1); + await expect.poll(pausedRow).toEqual(before); + await evidence.step("Deleted Paused study, then restored the same row with Undo"); await page.getByRole("button", { name: "Delete Paused study", exact: true }).click(); await expect(page.getByRole("switch", { name: "Toggle Paused study" })).toHaveCount(0); await page.reload(); await expect(page.getByRole("switch", { name: "Toggle Active study" })).toBeVisible(); await expect(page.getByRole("switch", { name: "Toggle Paused study" })).toHaveCount(0); // Live research can save the web sources it found, so check only the two added here. - const userId = await user.userId(); const { data: debate } = await admin.from("debates").select("id").eq("user_id", userId).eq("motion", motion).single(); const { data } = await admin.from("sources").select("title,is_active,debate_id").eq("user_id", userId).in("title", ["Active study", "Paused study"]); expect(data).toEqual([{ title: "Active study", is_active: true, debate_id: debate?.id }]); @@ -55,10 +73,10 @@ 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("Source Ready", { exact: true })).toBeVisible({ timeout: AI_TIMEOUT }); + await expect(page.getByText("Import preview", { exact: true })).toBeVisible({ timeout: AI_TIMEOUT }); await evidence.shot("01-import-ready"); await page.getByRole("button", { name: "Save imported source" }).click(); await expect.poll(async () => { @@ -68,3 +86,125 @@ test("lib-import: a web page imports into the Library", async ({ signedIn: page, const { data } = await admin.from("sources").select("title,origin").eq("user_id", await user.userId()); await evidence.json("02-imported-row", data); }); + +test("lib-offline: offline, the switch does not move and says the change was not saved", async ({ signedIn: page, user, evidence }) => { + await startDebate(page, motion); + await page.goto("/app/library"); + await addSource(page, "Offline study", "Fares and ridership."); + await expect(page.getByText("Saved Offline study to the Library")).toBeVisible(); + await page.context().setOffline(true); + await page.getByRole("switch", { name: "Toggle Offline study" }).click(); + await expect(page.getByText("You are offline. Reconnect and try again.")).toBeVisible(); + await expect(page.getByRole("switch", { name: "Toggle Offline study" })).toBeChecked(); + await evidence.shot("01-offline-toggle"); + // A save that fails keeps what the student typed, so they can retry. + await page.getByRole("button", { name: "Offline study", exact: true }).click(); + const sheet = page.getByRole("dialog"); + await sheet.getByRole("textbox", { name: "Content" }).fill("Fares, ridership and a rewritten note."); + await sheet.getByRole("button", { name: "Save" }).click(); + await expect(page.getByText("Could not save this source.")).toBeVisible(); + await expect(sheet.getByRole("textbox", { name: "Content" })).toHaveValue("Fares, ridership and a rewritten note."); + await evidence.shot("02-offline-edit-kept"); + await page.context().setOffline(false); + const row = async () => (await admin.from("sources").select("content,is_active").eq("user_id", await user.userId()).eq("title", "Offline study")).data; + expect(await row()).toEqual([{ content: "Fares and ridership.", is_active: true }]); + // Back online, the kept text saves on retry and the switch is left as it was. + await sheet.getByRole("button", { name: "Save" }).click(); + await expect(page.getByText("Source updated")).toBeVisible(); + await expect.poll(row).toEqual([{ content: "Fares, ridership and a rewritten note.", is_active: true }]); + await evidence.json("03-row-after-retry", await row()); +}); + +test("lib-edit-while-switching: the editor follows a switch that rolls back, and Save sends only what was edited", async ({ signedIn: page, user, evidence }) => { + await startDebate(page, motion); + await page.goto("/app/library"); + await addSource(page, "Race study", "Original text."); + // The switch write hangs, then fails, while the editor is open. + const failSwitch = async (route: import("@playwright/test").Route) => { + if (route.request().method() === "PATCH" && (route.request().postData() ?? "").includes("is_active")) { + await new Promise((resolve) => setTimeout(resolve, 1_500)); + await route.abort("failed"); + return; + } + await route.continue(); + }; + await page.route("**/rest/v1/sources?**", failSwitch); + await page.getByRole("switch", { name: "Toggle Race study" }).click(); + await page.getByRole("button", { name: "Race study", exact: true }).click(); + const sheet = page.getByRole("dialog"); + // The editor opened while the write was still running, so it shows the switch off. + await expect(sheet.getByRole("switch", { name: "Use for AI grounding" })).not.toBeChecked(); + await expect(page.getByText("Could not change this source")).toBeVisible(); + // The switch went back on; the editor shows that, since it was not edited there. + await expect(sheet.getByRole("switch", { name: "Use for AI grounding" })).toBeChecked(); + await page.unroute("**/rest/v1/sources?**", failSwitch); + await sheet.getByRole("textbox", { name: "Content" }).fill("Edited text."); + await sheet.getByRole("button", { name: "Save" }).click(); + await expect(page.getByText("Source updated")).toBeVisible(); + const row = async () => (await admin.from("sources").select("content,is_active").eq("user_id", await user.userId()).eq("title", "Race study")).data; + await expect.poll(row).toEqual([{ content: "Edited text.", is_active: true }]); + await evidence.shot("01-editor-after-rollback"); + + // Typing on while a save runs keeps that text as an edit, so the next Save sends it. + const slowSave = async (route: import("@playwright/test").Route) => { + if (route.request().method() === "PATCH") await new Promise((resolve) => setTimeout(resolve, 1_500)); + await route.continue(); + }; + await page.route("**/rest/v1/sources?**", slowSave); + const content = sheet.getByRole("textbox", { name: "Content" }); + await content.fill("Second edit."); + await sheet.getByRole("button", { name: "Save" }).click(); + await content.fill("Second edit, typed on."); + await expect.poll(row).toEqual([{ content: "Second edit.", is_active: true }]); + await expect(sheet.getByRole("button", { name: "Save" })).toBeEnabled(); + await page.unroute("**/rest/v1/sources?**", slowSave); + await sheet.getByRole("button", { name: "Save" }).click(); + await expect.poll(row).toEqual([{ content: "Second edit, typed on.", is_active: true }]); + await evidence.json("02-row", await row()); +}); + +test("lib-dedupe and lib-layout: a page saved once per debate, and long titles keep their room on a phone", async ({ signedIn: page, user, evidence }) => { + await startDebate(page, motion); + const userId = await user.userId(); + const { data: debateRow } = await admin.from("debates").select("id").eq("user_id", userId).eq("motion", motion).single(); + const title = "Por qué deberían prohibirse los deberes escolares en la educación primaria según los expertos"; + const url = "https://www.example.org/estudios/deberes-escolares-primaria/"; + await admin.from("sources").insert({ user_id: userId, debate_id: debateRow!.id, title, content: "Texto", type: "url", origin: url, folder: "General", is_active: true }); + await page.setViewportSize({ width: 360, height: 780 }); + await page.goto("/app/library"); + const titleButton = page.getByRole("button", { name: title, exact: true }); + await expect(titleButton).toBeVisible(); + // Before, the controls shared the title's line and left it a few letters. + expect((await titleButton.boundingBox())!.width).toBeGreaterThan(200); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(360); + await evidence.shot("01-phone-row"); + + // 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("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(); + expect(imported).toBe(false); + await evidence.shot("02-duplicate-warning"); + const { data } = await admin.from("sources").select("id").eq("user_id", userId); + expect(data).toHaveLength(1); +}); + +test("lib-counts: the debate summary and Ask agree on how many sources a request uses", async ({ signedIn: page, user, evidence }) => { + await startDebate(page, motion); + const userId = await user.userId(); + const { data: debateRow } = await admin.from("debates").select("id").eq("user_id", userId).eq("motion", motion).single(); + // What Attack's research leaves behind: several pages switched on, some off. + await admin.from("sources").insert([1, 2, 3, 4, 5, 6, 7].map((n) => ({ + user_id: userId, debate_id: debateRow!.id, title: `Researched page ${n}`, content: `Finding ${n}`, type: "url", + origin: `https://example.org/page-${n}`, folder: "Auto-saved", is_active: n <= 5, + }))); + await page.goto("/app"); + await expect(page.getByTestId("home-current-debate")).toContainText("7 sources (5 active)"); + await evidence.shot("01-home-counts"); + await page.goto("/app/ask"); + await expect(page.getByTestId("debate-context-control")).toContainText("Using 5 sources"); + await evidence.shot("02-ask-counts"); +}); diff --git a/.claude/skills/verify-debatelab/recipes/live-generation.spec.ts b/.claude/skills/verify-debatelab/recipes/live-generation.spec.ts new file mode 100644 index 000000000..214059c9b --- /dev/null +++ b/.claude/skills/verify-debatelab/recipes/live-generation.spec.ts @@ -0,0 +1,112 @@ +import type { Page, Response } from "@playwright/test"; +import { AI_TIMEOUT, expect, expectStreamed, fake, readAIResponse, startDebate, test } from "./fixtures"; + +// Feature map: features/live-generation.md. +const motion = "This House would make public transport free at the point of use"; + +/** The next `ai` request for a feature, with the body the page sent. */ +function aiRequest(page: Page, feature: string): Promise<Response> { + return page.waitForResponse((response) => { + if (!response.url().endsWith("/functions/v1/ai") || response.request().method() !== "POST") return false; + try { + return (response.request().postDataJSON() as { feature?: string }).feature === feature; + } catch { + return false; + } + }, { timeout: AI_TIMEOUT }); +} + +/** The event stream's shape: token deltas in order, then exactly one result. */ +async function expectLiveStream(response: Response) { + expect((response.request().postDataJSON() as { stream?: unknown }).stream, "the page asked for live generation").toBe(true); + expect(response.headers()["content-type"]).toContain("text/event-stream"); + const text = await response.text(); + const events = text.split("\n\n").flatMap((block) => /^event: (\w+)$/m.exec(block)?.[1] ?? []); + const deltas = events.filter((event) => event === "delta").length; + expect(deltas, "token deltas").toBeGreaterThan(3); + expect(events.filter((event) => event === "result"), "one result event").toHaveLength(1); + expect(events.at(-1)).toBe("result"); + const { status, body } = await readAIResponse(response); + expect(status).toBe(200); + return { deltas, body }; +} + +test("live-generate: a case fills in section by section while the model writes it", async ({ signedIn: page, evidence }) => { + // Fake mode writes a case in about a second; slow it so the draft can be inspected. + await fake.streamDelay(80); + await startDebate(page, motion); + 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(); + + // Before the answer is complete, the draft shows what is written so far. + const draft = page.getByTestId("live-draft"); + await expect(draft).toBeVisible({ timeout: AI_TIMEOUT }); + await expect(draft.getByRole("region", { name: "Case structure" })).toBeVisible(); + await expect(draft.getByTestId("live-caret")).toHaveCount(1); + await expect(draft.getByRole("button", { name: "Cancel" })).toBeVisible(); + await evidence.shot("01-writing-live"); + await evidence.aria("01-writing-live", draft); + + const { deltas, body } = await expectLiveStream(await request); + await evidence.step(`Generate streamed ${deltas} token deltas before its result`); + const thesis = (body.output as { caseStructure: { thesis: string } }).caseStructure.thesis; + // The finished case replaces the draft with the full result and its toolbar. + await expect(draft).toHaveCount(0); + await expect(page.getByText(thesis).first()).toBeVisible(); + await expect(page.getByRole("button", { name: "PDF", exact: true })).toBeVisible(); + await evidence.shot("02-finished-case"); + await expectStreamed(["generate"]); +}); + +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(); + const draft = page.getByTestId("live-draft"); + await expect(draft).toBeVisible({ timeout: AI_TIMEOUT }); + await draft.getByRole("button", { name: "Cancel" }).click(); + await expect(draft).toHaveCount(0); + await expect(page.getByRole("heading", { name: "Your case will appear here" })).toBeVisible(); + await expect(page.getByRole("alert")).toHaveCount(0); + await evidence.shot("01-canceled"); +}); + +test("live-tools: Attack, Brainstorm, Factoids, and cited Ask stream their answers", async ({ signedIn: page, evidence }) => { + await startDebate(page, motion); + + await page.goto("/app/attack"); + await page.getByLabel("Your argument *").fill("Free transit raises ridership, so it always pays for itself."); + let request = aiRequest(page, "attack"); + 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 expectLiveStream(await request); + await evidence.shot("02-brainstorm"); + + await page.goto("/app/factoids"); + await page.getByLabel("Statement *").fill("Free fares increase public transport ridership."); + request = aiRequest(page, "factoids"); + await page.getByRole("button", { name: "Find factoids" }).click(); + await expectLiveStream(await request); + await evidence.shot("03-factoids"); + + await page.goto("/app/ask"); + await page.getByRole("button", { name: "Options" }).click(); + await page.locator("#citation-mode").click(); + await page.keyboard.press("Escape"); + await page.getByPlaceholder("Ask your debate coach…").fill("How should I weigh access against cost?"); + request = aiRequest(page, "ask"); + await page.getByRole("button", { name: "Send message" }).click(); + const { body } = await expectLiveStream(await request); + const answer = (body.output as { response: string }).response; + await expect(page.getByText(answer.slice(0, 30)).first()).toBeVisible(); + await evidence.shot("04-ask-cited"); + + await expectStreamed(["attack", "brainstorm", "factoids", "ask"]); +}); diff --git a/.claude/skills/verify-debatelab/recipes/localization.spec.ts b/.claude/skills/verify-debatelab/recipes/localization.spec.ts new file mode 100644 index 000000000..786a4b87b --- /dev/null +++ b/.claude/skills/verify-debatelab/recipes/localization.spec.ts @@ -0,0 +1,157 @@ +import { readFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import type { Page } from "@playwright/test"; +import { admin, expect, test } from "./fixtures"; + +// Feature map: features/localization.md (T12: IN-01, SE-01, IN-03, IN-04, IN-11, IN-14). + +const catalog = (language: string) => JSON.parse(readFileSync(`src/locales/${language}.json`, "utf8")); +const en = catalog("en"); +const es = catalog("es"); +const hu = catalog("hu"); +const site = JSON.parse(readFileSync("src/config/site.json", "utf8")); +const BRAND = site.brand; +const head = (text: string) => text.replace("{{brand}}", BRAND).replace("{{product}}", site.product); +const LANGUAGE_NAMES = { en: "English", es: "Español", hu: "Magyar" } as const; +/** The switcher's own name in whichever language is showing. */ +const SWITCHER_NAME = new RegExp(`^(${[en, es, hu].map((strings) => strings.langSwitcher.label).join("|")})$`); + +const APP_ROUTES = ["/app", "/app/generate", "/app/attack", "/app/history", "/app/settings"]; +const PUBLIC_ROUTES = ["/terms", "/privacy", "/tools", "/no-such-page"]; + +/** Phones at 375 px, the width the QA report used; desktop keeps the project's 1280. */ +test.beforeEach(async ({ page }, testInfo) => { + if (testInfo.project.name === "mobile-chromium") await page.setViewportSize({ width: 375, height: page.viewportSize()!.height }); +}); + +async function chooseLanguage(page: Page, language: keyof typeof LANGUAGE_NAMES) { + await page.getByRole("button", { name: SWITCHER_NAME }).first().click(); + await page.getByRole("radiogroup").first().getByRole("radio", { name: LANGUAGE_NAMES[language] }).click(); + await expect(page.locator("html")).toHaveAttribute("lang", language); +} + +/** The tab title once the page has set its own (the shell's is just "DebateOwl"). */ +async function pageTitle(page: Page, route: string) { + await page.goto(route); + await expect.poll(() => page.title()).not.toBe("DebateOwl"); + return page.title(); +} + +test("loc-titles: every route's tab title is its own and follows the interface language", async ({ signedIn: page, evidence }) => { + const titles: Record<string, Record<string, string>> = {}; + for (const language of ["en", "hu", "es"] as const) { + await page.goto(`/app?lang=${language}`); + await expect(page.locator("html")).toHaveAttribute("lang", language); + titles[language] = {}; + for (const route of [...APP_ROUTES, ...PUBLIC_ROUTES]) titles[language][route] = await pageTitle(page, route); + await evidence.step(`Recorded document.title on ${APP_ROUTES.length + PUBLIC_ROUTES.length} routes in ${language}`); + } + await evidence.json("01-document-titles", titles); + + for (const route of APP_ROUTES) { + for (const language of ["en", "hu", "es"]) expect(titles[language][route]).toMatch(/ · DebateOwl$/); + } + for (const route of PUBLIC_ROUTES) { + for (const language of ["en", "hu", "es"]) expect(titles[language][route]).toMatch(new RegExp(`\\| ${BRAND}$`)); + } + expect(titles.en["/app/generate"]).toBe(`${en.nav.generate} · DebateOwl`); + expect(titles.hu["/app/generate"]).toBe(`${hu.nav.generate} · DebateOwl`); + expect(titles.hu["/no-such-page"]).toBe(hu.pageHead.notFound.title.replace("{{brand}}", BRAND)); + // Each language names every page in its own words (the brand aside). + for (const route of [...APP_ROUTES, ...PUBLIC_ROUTES]) { + expect(titles.hu[route], route).not.toBe(titles.en[route]); + } + + // A switch retitles the page on screen, with no reload. + await page.goto("/app/attack"); + await chooseLanguage(page, "en"); + await expect(page).toHaveTitle(`${en.nav.attack} · DebateOwl`); + await chooseLanguage(page, "hu"); + await expect(page).toHaveTitle(`${hu.nav.attack} · DebateOwl`); + await evidence.shot("02-attack-title-hu"); +}); + +test("loc-validation: an empty Attack shows its required-field error in the interface language, only after submit", async ({ signedIn: page, evidence }) => { + await page.goto("/app/attack?lang=hu"); + const caseText = page.getByRole("textbox", { name: new RegExp(hu.attackForm.argument) }); + await expect(caseText).toBeVisible(); + await expect(page.getByText(hu.validation.caseTextRequired)).toHaveCount(0); + await evidence.shot("01-attack-hu-before-submit"); + await evidence.aria("01-attack-hu-before-submit", page.getByRole("main")); + + await page.getByRole("button", { name: hu.attackForm.submit }).click(); + await expect(page.getByText(hu.validation.caseTextRequired)).toBeVisible(); + await expect(page.getByText(en.validation.caseTextRequired)).toHaveCount(0); + await evidence.step("Submitted an empty Attack in Hungarian"); + await evidence.shot("02-attack-hu-required"); + + await chooseLanguage(page, "es"); + await expect(page.getByText(es.validation.caseTextRequired)).toBeVisible(); + await evidence.shot("03-attack-es-required"); +}); + +test("loc-switcher: sign-in and not-found pages carry the language switcher", async ({ page, evidence }) => { + await page.goto("/login"); + await expect(page.getByRole("heading", { level: 1, name: en.login.title })).toBeVisible(); + await evidence.shot("01-login-en"); + await chooseLanguage(page, "hu"); + await expect(page.getByRole("heading", { level: 1, name: hu.login.title })).toBeVisible(); + // "Bejelentkezés a DebateOwl-ba" already names the app, so it gets no suffix. + await expect(page).toHaveTitle(hu.login.title); + await evidence.shot("02-login-hu"); + + await page.goto("/no-such-page"); + await expect(page.getByRole("heading", { level: 1, name: hu.notFound.title })).toBeVisible(); + await expect(page).toHaveTitle(hu.pageHead.notFound.title.replace("{{brand}}", BRAND)); + await evidence.shot("03-not-found-hu"); + await chooseLanguage(page, "es"); + await expect(page).toHaveTitle(es.pageHead.notFound.title.replace("{{brand}}", BRAND)); + await expect(page.getByText(es.notFound.message)).toBeVisible(); + await evidence.shot("04-not-found-es"); +}); + +test("loc-links: ?lang= opens the landing page in that language, and /es and /hu are pages of their own", async ({ page, evidence }) => { + await page.goto("/?lang=hu"); + await expect(page.locator("html")).toHaveAttribute("lang", "hu"); + await expect(page).toHaveTitle(head(hu.pageHead.home.title)); + // The choice is kept and the address drops the parameter. + await expect.poll(() => new URL(page.url()).search).toBe(""); + await evidence.shot("01-landing-lang-hu"); + + await page.goto("/es"); + await expect(page.locator("html")).toHaveAttribute("lang", "es"); + await expect(page).toHaveTitle(head(es.pageHead.home.title)); + expect(new URL(page.url()).pathname).toBe("/es"); + await evidence.shot("02-landing-es"); + + await page.goto("/hu"); + await expect(page.locator("html")).toHaveAttribute("lang", "hu"); + // Start leads into the app in Hungarian, and the choice survives a reload. + await page.getByRole("link", { name: hu.nav.startTrial }).first().click(); + await expect.poll(() => new URL(page.url()).pathname).not.toBe("/hu"); + await expect(page.locator("html")).toHaveAttribute("lang", "hu"); + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("lang", "hu"); + await evidence.shot("03-start-from-hu"); +}); + +test("loc-server-error: a refused promo code reads in the interface language", async ({ signedIn: page, user, evidence }) => { + const userId = await user.userId(); + // The promo field shows for an account that would start a Checkout. + await admin.from("user_settings") + .update({ subscription_tier: "expired", trial_ends_at: null, grace_period_ends_at: null, daily_limit_override: null }) + .eq("user_id", userId); + await page.goto("/app/settings?lang=hu"); + await page.getByRole("button", { name: hu.billing.upgradeToPro }).first().click(); + await page.getByRole("button", { name: hu.pricingModal.promoCode }).click(); + await page.getByPlaceholder(hu.pricingModal.promoPlaceholder).fill(`NOPE${randomUUID().slice(0, 6).toUpperCase()}`); + const answer = page.waitForResponse((response) => response.url().includes("/functions/v1/redeem-promo")); + await page.getByRole("button", { name: hu.pricingModal.apply, exact: true }).click(); + const body = await (await answer).json(); + await evidence.json("01-redeem-promo-answer", body); + // The server still sends English beside the code; the screen shows the translation. + expect(body).toMatchObject({ success: false, code: "promo_invalid", message: en.serverErrors.promoInvalid }); + await expect(page.getByText(hu.serverErrors.promoInvalid).first()).toBeVisible(); + await expect(page.getByText(en.serverErrors.promoInvalid)).toHaveCount(0); + await evidence.shot("02-promo-refused-hu"); +}); diff --git a/.claude/skills/verify-debatelab/recipes/model-defaults.spec.ts b/.claude/skills/verify-debatelab/recipes/model-defaults.spec.ts new file mode 100644 index 000000000..8b74ba79c --- /dev/null +++ b/.claude/skills/verify-debatelab/recipes/model-defaults.spec.ts @@ -0,0 +1,125 @@ +import type { Page } from "@playwright/test"; +import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, expect, providerCalls, test } from "./fixtures"; + +// Feature map: features/model-defaults.md. +const motion = "This House would make public transport free at the point of use"; +const SONNET = "anthropic/claude-sonnet-5.5"; +const HAIKU = "anthropic/claude-haiku-5.5"; + +async function settings(userId: string) { + const { data, error } = await admin.from("user_settings").select("feature_configs").eq("user_id", userId).single(); + if (error) throw error; + return data.feature_configs as Record<string, { model: string }>; +} + +async function updateSettings(userId: string, values: Record<string, unknown>) { + const { error } = await admin.from("user_settings").update(values).eq("user_id", userId); + if (error) throw error; +} + +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 chooseForMotion(page, "once"); + return response; +} + +test("model-pro-default: a new Pro account starts on Claude Sonnet 5.5, keeps Opus as an opt-in, and generates with Sonnet", async ({ signedIn: page, user, evidence }) => { + const userId = await user.userId(); + await expect.poll(async () => (await settings(userId)).generate?.model, { timeout: AI_TIMEOUT }).toBe(SONNET); + + await page.goto("/app/generate"); + const selector = page.getByRole("combobox", { name: "Select AI model for generate" }); + await expect(selector).toContainText("Claude Sonnet 5.5"); + await selector.click(); + await expect(page.getByRole("option", { name: /Claude Haiku 5\.5/ })).toBeEnabled(); + await expect(page.getByRole("option", { name: /Claude Opus 5\.5/ })).toBeEnabled(); + await evidence.shot("01-pro-model-list"); + await page.keyboard.press("Escape"); + await evidence.step("New Pro account: selector shows Claude Sonnet 5.5; Opus 5.5 stays enabled as an opt-in"); + + const body = await generateOnce(page); + expect((body.meta as { resolvedModel?: string } | undefined)?.resolvedModel ?? SONNET).toBe(SONNET); + const calls = await providerCalls(); + // Live mode rewrites the upstream model to the pin, so read what the app asked for. + expect(calls.filter((call) => call.feature === "generate").map((call) => call.requestedModel ?? call.model)).toContain(SONNET); + const { data: usage } = await admin.from("ai_requests").select("feature,status,model").eq("user_id", userId); + expect(usage).toContainEqual(expect.objectContaining({ feature: "generate", status: "success", model: SONNET })); + await evidence.json("02-ai-requests", usage); + await evidence.shot("02-generated-with-sonnet"); +}); + +test("model-basic-default: a Basic account starts on Claude Haiku 5.5 with only Haiku, Luna and Muse Spark unlocked", async ({ signedIn: page, user, evidence }) => { + const userId = await user.userId(); + // Demo sign-in grants Pro; turn the account into a fresh trial with nothing saved. + await updateSettings(userId, { + subscription_tier: "trial", + trial_ends_at: new Date(Date.now() + 2 * 86_400_000).toISOString(), + grace_period_ends_at: null, + daily_limit_override: null, + feature_configs: {}, + }); + await page.goto("/app/generate"); + await expect.poll(async () => (await settings(userId)).generate?.model, { timeout: AI_TIMEOUT }).toBe(HAIKU); + + const selector = page.getByRole("combobox", { name: "Select AI model for generate" }); + await expect(selector).toContainText("Claude Haiku 5.5"); + await selector.click(); + await expect(page.getByRole("option", { name: /Claude Opus 5\.5/ })).toBeDisabled(); + await expect(page.getByRole("option", { name: /Claude Sonnet 5\.5/ })).toBeDisabled(); + await expect(page.getByRole("option", { name: /Gemini 3\.8 Flash/ })).toBeDisabled(); + await expect(page.getByRole("option", { name: /GPT-6 Luna/ })).toBeEnabled(); + await expect(page.getByRole("option", { name: /Muse Spark 1\.3/ })).toBeEnabled(); + await evidence.shot("01-basic-model-list"); + await page.keyboard.press("Escape"); + await evidence.step("Basic account: selector shows Claude Haiku 5.5; Luna and Muse Spark enabled; Opus, Sonnet and Gemini locked behind Pro"); + + await generateOnce(page); + const { data: usage } = await admin.from("ai_requests").select("feature,status,model").eq("user_id", userId); + expect(usage).toContainEqual(expect.objectContaining({ feature: "generate", status: "success", model: HAIKU })); + await evidence.json("02-ai-requests", usage); +}); + +test("model-retired: a saved Claude Sonnet 5 setting moves to Claude Sonnet 5.5", async ({ signedIn: page, user, evidence }) => { + const userId = await user.userId(); + const retired = Object.fromEntries(["generate", "attack", "brainstorm", "factoids", "ask", "sparring"].map((feature) => [ + feature, + { provider: "openrouter", model: "anthropic/claude-sonnet-5", temperature: 0.7, reasoningEffort: "low" }, + ])); + await updateSettings(userId, { feature_configs: retired }); + await page.evaluate(() => localStorage.removeItem("debate-trainer-settings")); + await page.goto("/app/generate"); + + await expect(page.getByRole("combobox", { name: "Select AI model for generate" })).toContainText("Claude Sonnet 5.5"); + await expect.poll(async () => Object.values(await settings(userId)).map((config) => config.model), { timeout: AI_TIMEOUT }) + .toEqual(Array(6).fill(SONNET)); + await evidence.shot("01-retired-migrated"); + await evidence.step("Saved Sonnet 5 configs were rewritten to Sonnet 5.5 in user_settings"); +}); + +// QA round 3: at 375 px the Auto Fast description was squeezed into the field and clipped. +test("model-picker-narrow: an Auto choice shows its name in the field and its description below, at 375 px in Hungarian", async ({ signedIn: page, evidence }, testInfo) => { + if (testInfo.project.name === "mobile-chromium") await page.setViewportSize({ width: 375, height: 812 }); + await page.goto("/app/generate"); + const picker = page.getByRole("combobox", { name: "Select AI model for generate" }); + await picker.click(); + await page.getByRole("option", { name: /^Auto Fast/ }).click(); + await expect(picker).toHaveText("Auto Fast"); + await expect(page.getByTestId("model-auto-description")).toHaveText("Fast routing, usually Claude Haiku"); + await page.getByRole("button", { name: "Language", exact: true }).click(); + await page.getByRole("radio", { name: "Magyar" }).first().click(); + const description = page.getByTestId("model-auto-description"); + const huPicker = page.getByRole("combobox", { name: /^MI-modell kiválasztása/ }); + await expect(description).toHaveText("Gyors útválasztás, általában Claude Haikuval"); + // Nothing in the field or its description is cut off. + await expect(huPicker).toHaveText("Auto Fast"); + for (const element of [huPicker, description]) { + const clipped = await element.evaluate((node) => node.scrollWidth > node.clientWidth + 1); + expect(clipped).toBe(false); + } + await evidence.shot("01-auto-fast-hu"); + await huPicker.click(); + await evidence.shot("02-auto-fast-hu-open"); + await page.keyboard.press("Escape"); +}); diff --git a/.claude/skills/verify-debatelab/recipes/practice.spec.ts b/.claude/skills/verify-debatelab/recipes/practice.spec.ts index ded9dda58..5a62753a9 100644 --- a/.claude/skills/verify-debatelab/recipes/practice.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/practice.spec.ts @@ -1,4 +1,4 @@ -import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, expect, expectProviderCalls, test } from "./fixtures"; +import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, expect, expectProviderCalls, expectStreamed, test } from "./fixtures"; // Feature map: features/practice.md. Voice rounds are recipes/voice-rounds.spec.ts. // A new account has no Current Debate, so each round first accepts the offer @@ -43,6 +43,66 @@ test("spar-round and spar-verdict: a Lincoln-Douglas round is argued, judged, an expect(session).toMatchObject({ side: "pro", debates: { motion: sparMotion } }); const calls = await expectProviderCalls(["sparring"]); expect(calls.some((call) => call.feature === "sparring" && /SPARRING ACTION: score/m.test(call.user ?? ""))).toBe(true); + // Opponent speeches and the scorecard were shown live (features/live-generation.md). + await expectStreamed(["sparring"]); + + // QA round 3: the radar chart's axis labels follow the interface language. + await switchLanguage(page, "Language", "Magyar"); + const axis = page.locator(".recharts-polar-angle-axis-tick text"); + await expect.poll(async () => (await axis.allTextContents()).sort()).toEqual(["Bizonyíték", "Előadás", "Logika", "Stratégia"]); + await evidence.shot("04-verdict-hu"); + await switchLanguage(page, "Nyelv", "English"); +}); + +/** The header's language popover: interface language first. */ +async function switchLanguage(page: import("@playwright/test").Page, buttonName: string, language: string) { + await page.getByRole("button", { name: buttonName, exact: true }).click(); + await page.getByRole("radio", { name: language }).first().click(); +} + +test("spar-start: the setup prices the whole round, and Start shows the quota check until the round opens", async ({ signedIn: page, evidence }, testInfo) => { + // The QA pass measured 375 px; the mobile project's Pixel 5 is wider. + if (testInfo.project.name === "mobile-chromium") await page.setViewportSize({ width: 375, height: 812 }); + await page.goto("/app/sparring"); + await page.getByLabel("Motion").fill(sparMotion); + // The school format has 4 opponent replies; the round is one charge, not one per reply. + await expect(page.getByTestId("sparring-round-cost")).toHaveText(/^\d+ AI uses?$/); + await expect(page.getByText("For the whole round: 4 replies + the judge")).toBeVisible(); + await evidence.shot("01-setup-cost-en"); + + await switchLanguage(page, "Language", "Magyar"); + await expect(page.getByText("A forduló ára", { exact: true })).toBeVisible(); + await expect(page.getByText("Az egész fordulóra: 4 válasz + a bíró")).toBeVisible(); + await expect(page.getByText("Az iskolai formátum: bevezető, R1, R2 és záró beszéd mindkét oldalon.")).toBeVisible(); + await evidence.shot("02-setup-cost-hu"); + await switchLanguage(page, "Nyelv", "English"); + + // Hold the pre-round quota check, as a slow server would. + let release!: () => void; + const held = new Promise<void>((resolve) => { release = resolve; }); + let checks = 0; + await page.route("**/functions/v1/ai", async (route) => { + const body = route.request().postDataJSON() as { quotaCheck?: boolean } | null; + if (body?.quotaCheck) { + checks += 1; + await held; + } + await route.fallback(); + }); + await page.getByRole("button", { name: "Start round" }).click(); + await chooseForMotion(page, "create"); + const pending = page.getByRole("button", { name: /Opening room/ }); + await expect(pending).toBeDisabled(); + await expect(pending).toHaveAttribute("aria-busy", "true"); + await expect(page.getByTestId("sparring-starting")).toHaveText("Checking your AI uses for today…"); + await pending.click({ force: true }); + await evidence.shot("03-start-pending"); + await evidence.step("Start pressed twice while the quota check was held"); + + release(); + await expect(page.getByText("Round in progress")).toBeVisible({ timeout: AI_TIMEOUT }); + expect(checks).toBe(1); + await evidence.shot("04-round-open"); }); async function startCrossfire(page: import("@playwright/test").Page, mode: "mode-question-ai" | "mode-ai-questions-user") { @@ -80,6 +140,26 @@ test("cross-turn and cross-debrief: questioning the AI gets an answer, a Jev sig await evidence.shot("03-debrief"); await evidence.aria("03-debrief", page.getByTestId("crossfire-debrief")); await expectCrossfireSaved(await user.userId()); + // The turn and the debrief were shown live (features/live-generation.md). + await expectStreamed(["crossfire"]); + + // cross-history: after leaving the page, History reopens the round and can delete it. + await page.goto("/app/history?tab=crossfire"); + const row = page.getByTestId("history-crossfire-row"); + await expect(row).toHaveCount(1); + await expect(row).toContainText(resolution); + await row.getByRole("button", { name: `Open Crossfire round: ${resolution}` }).click(); + const saved = page.getByTestId("saved-crossfire"); + await expect(saved.getByTestId("saved-crossfire-debrief")).toBeVisible(); + await expect(saved.getByTestId("saved-crossfire-transcript")).toContainText(text!.slice(0, 40)); + await evidence.shot("04-history-crossfire"); + await evidence.aria("04-history-crossfire", saved); + await page.keyboard.press("Escape"); + await page.getByRole("button", { name: `Delete Crossfire round: ${resolution}` }).click(); + await page.getByRole("alertdialog").getByRole("button", { name: "Delete" }).click(); + await expect(row).toHaveCount(0); + await expect.poll(async () => (await admin.from("crossfires").select("id").eq("user_id", await user.userId())).data).toEqual([]); + await evidence.step("Reopened the round from History, then deleted it"); }); test("cross-ai-questions: the AI questions the user, answers are judged, and the debrief completes", async ({ signedIn: page, user, evidence }) => { @@ -105,3 +185,56 @@ test("cross-ai-questions: the AI questions the user, answers are judged, and the await evidence.shot("02-debrief"); await expectCrossfireSaved(await user.userId()); }); + +/** + * Today's AI requests are used up: the server's counter (what the ai function + * checks, what Crossfire and Sparring ask it for before a round, and what the + * usage line reads) and the usage rows real calls leave. A refused call and a + * Jev helper row are on the list too, and must not count as used. + */ +async function useUpDailyRequests(userId: string, limit: number) { + await admin.from("user_settings").update({ daily_limit_override: limit }).eq("user_id", userId); + const today = new Date().toISOString().slice(0, 10); + const counter = await admin.from("daily_usage_counters").upsert({ user_id: userId, usage_date: today, quota_key: "ai", request_count: limit }); + expect(counter.error).toBeNull(); + const rows = [ + ...Array.from({ length: limit }, () => ({ user_id: userId, feature: "generate", provider: "openrouter", model: "fake", status: "success" })), + { user_id: userId, feature: "ask", provider: "openrouter", model: "fake", status: "rate_limited" }, + { user_id: userId, feature: "jev_model_router", provider: "openrouter", model: "fake", status: "success" }, + ]; + const usage = await admin.from("ai_requests").insert(rows); + expect(usage.error).toBeNull(); +} + +test("cross-daily-limit and spar-daily-limit: with no requests left, Crossfire stays in setup and Sparring warns first", async ({ signedIn: page, user, evidence }) => { + await useUpDailyRequests(await user.userId(), 2); + await evidence.step("Used up the account's 2 daily AI requests"); + + await startCrossfire(page, "mode-ai-questions-user"); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible({ timeout: AI_TIMEOUT }); + await expect(dialog).toContainText("2 of 2"); + await evidence.shot("01-crossfire-limit-dialog"); + await evidence.aria("01-crossfire-limit-dialog", dialog); + // The round never started: no clock, no End button, the setup kept. + await expect(page.getByTestId("end-crossfire")).toHaveCount(0); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("start-crossfire")).toBeVisible(); + await expect(page.getByTestId("crossfire-resolution")).toHaveValue(resolution); + await evidence.shot("02-crossfire-back-in-setup"); + + await page.goto("/app/sparring"); + // QB-06: the same words as the Crossfire dialog above. + await expect(page.getByTestId("sparring-request-shortfall")).toContainText("You've used 2 of 2 included AI uses today."); + await evidence.shot("03-sparring-warns"); + await evidence.aria("03-sparring-warns", page.getByTestId("sparring-request-shortfall")); +}); + +test("cross-end-early: ending a round before any exchange returns to setup without an error", async ({ signedIn: page, evidence }) => { + await startCrossfire(page, "mode-question-ai"); + await expect(page.getByTestId("end-crossfire")).toBeVisible(); + await page.getByTestId("end-crossfire").click(); + await expect(page.getByTestId("start-crossfire")).toBeVisible(); + await expect(page.getByText("Complete at least one exchange before ending the session.")).toHaveCount(0); + await evidence.shot("01-back-in-setup"); +}); diff --git a/.claude/skills/verify-debatelab/recipes/usage-limits.spec.ts b/.claude/skills/verify-debatelab/recipes/usage-limits.spec.ts new file mode 100644 index 000000000..b10c3be65 --- /dev/null +++ b/.claude/skills/verify-debatelab/recipes/usage-limits.spec.ts @@ -0,0 +1,232 @@ +import type { Page } from "@playwright/test"; +import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, expect, fake, LIVE, providerCalls, startDebate, test } from "./fixtures"; + +// Feature map: features/usage-limits.md. +const motion = "This House would make public transport free at the point of use"; + +async function openGenerate(page: Page) { + await startDebate(page, motion); + await page.goto("/app/generate"); + await expect(page.getByRole("heading", { name: "Your case will appear here" })).toBeVisible(); +} + +async function aiCounter(userId: string): Promise<number> { + const { data, error } = await admin.from("daily_usage_counters").select("request_count") + .eq("user_id", userId).eq("quota_key", "ai").eq("usage_date", new Date().toISOString().slice(0, 10)).maybeSingle(); + if (error) throw error; + return data?.request_count ?? 0; +} + +async function generateCalls(): Promise<number> { + return (await providerCalls()).filter((call) => call.feature === "generate").length; +} + +test("usage-weighted: Opus counts as 5 requests, a lighter model still runs, and the weekly cap stops everything", async ({ signedIn: page, user, evidence }) => { + const userId = await user.userId(); + // Demo users are Pro on Claude Opus 5.5 with a limit of 999. Leave room for + // 4 more units today: too few for Opus, enough for Haiku. + await admin.from("user_settings").update({ daily_limit_override: 7 }).eq("user_id", userId); + const { error: seedError } = await admin.from("daily_usage_counters").insert({ + user_id: userId, usage_date: new Date().toISOString().slice(0, 10), quota_key: "ai", request_count: 3, + }); + if (seedError) throw seedError; + + await openGenerate(page); + const selector = page.getByRole("combobox", { name: "Select AI model for generate" }); + // Demo users default to Sonnet (20261022120000_opus_default_to_sonnet.sql); pick Opus. + 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(); + const daily = page.getByRole("dialog"); + await expect(daily.getByText("Daily AI uses used up")).toBeVisible({ timeout: AI_TIMEOUT }); + await expect(daily.getByText("This model counts as 5 uses, and you have 4 left today.", { exact: false })).toBeVisible(); + await evidence.shot("01-opus-weighted-refusal"); + expect(await generateCalls()).toBe(0); + expect(await aiCounter(userId)).toBe(3); + await evidence.step("Opus refused with 4 units left; no provider call; counter unchanged at 3"); + // Pro accounts get OK, others Not now; Escape closes either. + await page.keyboard.press("Escape"); + await expect(daily).toHaveCount(0); + + 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 response; + expect(await generateCalls()).toBe(1); + expect(await aiCounter(userId)).toBe(4); + await evidence.shot("02-haiku-runs"); + await evidence.step("Haiku ran and used 1 unit (counter 4)"); + + // Reach the weekly budget with cap spend logged earlier this week. + const { error: spendError } = await admin.from("ai_requests").insert({ + 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(); + 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(); + await evidence.shot("03-weekly-cap"); + expect(await generateCalls()).toBe(1); + expect(await aiCounter(userId)).toBe(4); + await evidence.step("Weekly cap refused the request; no provider call; daily counter unchanged"); + await weekly.getByRole("button", { name: "OK" }).click(); + await expect(weekly).toHaveCount(0); +}); + +test("usage-weekly-sparring: with the week's allowance used, Sparring says so before a round and never opens one", async ({ signedIn: page, user, evidence }) => { + const userId = await user.userId(); + const { error } = await admin.from("ai_requests").insert({ + user_id: userId, feature: "sparring", provider: "openrouter", model: "anthropic/claude-opus-5.5", status: "success", cost_usd: 2, cap_cost_usd: 2, + }); + if (error) throw error; + + await page.goto("/app/sparring"); + await page.getByLabel("Motion").fill(motion); + // The setup asks the server first and shows the catalog's words, not the server's English reason. + await expect(page.getByTestId("session-cost")).toContainText("You've used this week's AI allowance.", { timeout: AI_TIMEOUT }); + await expect(page.getByText("You've reached this week's AI usage allowance", { exact: false })).toHaveCount(0); + await evidence.shot("04-sparring-weekly-before-start"); + await page.getByRole("button", { name: "Start round" }).click(); + await chooseForMotion(page, "create"); + await expect(page.getByRole("dialog").getByText("Weekly AI allowance used")).toBeVisible({ timeout: AI_TIMEOUT }); + // No round opened, so no speech can be written into a round the server would refuse. + await expect(page.getByRole("textbox", { name: /^Draft for / })).toHaveCount(0); + expect((await providerCalls()).filter((call) => call.feature === "sparring")).toEqual([]); + await evidence.step("Sparring refused at Start by the weekly cap with the translated message; no round, no provider call"); +}); + +async function sessionRow(userId: string, feature: string) { + const { data, error } = await admin.from("ai_quota_sessions").select("charged,units,calls_used").eq("user_id", userId).eq("feature", feature); + if (error) throw error; + return data; +} + +test("usage-session-sparring: with one request left, a whole Sparring round (every reply and the judge) runs for one use", async ({ signedIn: page, user, evidence }) => { + const userId = await user.userId(); + // QB-01: a trial-sized day (5) with 4 already used. Per call, the round would + // stop at the second reply; per round it finishes. + await admin.from("user_settings").update({ daily_limit_override: 5 }).eq("user_id", userId); + const { error: seedError } = await admin.from("daily_usage_counters").insert({ + user_id: userId, usage_date: new Date().toISOString().slice(0, 10), quota_key: "ai", request_count: 4, + }); + if (seedError) throw seedError; + + await page.goto("/app/sparring"); + await page.getByLabel("Motion").fill(motion); + await page.getByRole("combobox", { name: "Round format" }).click(); + await page.getByRole("option", { name: /Lincoln-Douglas · 5 speeches/ }).click(); + await page.getByRole("combobox", { name: "Select AI model for sparring" }).click(); + await page.getByRole("option", { name: /Claude Haiku 5\.5/ }).click(); + const cost = page.getByTestId("session-cost"); + await expect(cost).toHaveText("This round costs 1 AI use, however many turns it takes. You have 1 left today.", { timeout: AI_TIMEOUT }); + await evidence.shot("01-sparring-cost-before-start"); + await page.getByRole("button", { name: "Start round" }).click(); + await chooseForMotion(page, "create"); + + for (const [turn, content] of [ + ["Affirmative constructive", "Universal access removes the direct price barrier to participation in work, school, and civic life."], + ["First affirmative rebuttal", "Targeted discounts leave eligibility gaps and add administrative friction that keeps poor riders out."], + ["Second affirmative rebuttal", "Weigh reliability: the universal policy covers every marginal trip, while targeting misses many of them."], + ] as const) { + const draft = page.getByRole("textbox", { name: `Draft for ${turn}` }); + await expect(draft).toBeVisible({ timeout: AI_TIMEOUT }); + await draft.fill(content); + await page.getByRole("button", { name: "Submit speech" }).click(); + } + await expect(page.getByRole("heading", { name: "Here is what happened in the room." })).toBeVisible({ timeout: AI_TIMEOUT * 2 }); + await evidence.shot("02-sparring-judged-on-one-use"); + const calls = (await providerCalls()).filter((call) => call.feature === "sparring"); + expect(calls.length).toBe(3); + expect(await aiCounter(userId)).toBe(5); + expect(await sessionRow(userId, "sparring")).toEqual([{ charged: true, units: 1, calls_used: 3 }]); + await evidence.step("Two opponent replies and the judge ran on the last request of the day; the counter moved 4 -> 5 once"); +}); + +test("usage-session-crossfire: a Crossfire round (opening, answers, debrief) is charged once", async ({ signedIn: page, user, evidence }) => { + const userId = await user.userId(); + await page.goto("/app/crossfire"); + await page.getByTestId("crossfire-resolution").fill(motion); + await page.getByTestId("crossfire-argument").fill("Free fares remove the price barrier for the poorest riders."); + await page.getByTestId("mode-ai-questions-user").click(); + await expect(page.getByTestId("session-cost")).toContainText("however many turns it takes.", { timeout: AI_TIMEOUT }); + await evidence.shot("01-crossfire-cost-before-start"); + const opening = aiResponse(page, "crossfire", "next-turn"); + await page.getByTestId("start-crossfire").click(); + await chooseForMotion(page, "create"); + await opening; + // MD-03: once the round runs, the setup hint is gone. + await expect(page.getByText("Set a resolution and an argument to start a focused cross-examination.")).toHaveCount(0); + const units = (await sessionRow(userId, "crossfire"))?.[0]?.units ?? 0; + expect(units).toBeGreaterThan(0); + expect(await aiCounter(userId)).toBe(units); + + for (const answer of ["Fare revenue covers a small share of operating cost in most systems.", "Ridership gains offset congestion costs downtown."]) { + await page.getByTestId("crossfire-input").fill(answer); + const next = aiResponse(page, "crossfire", "next-turn"); + await page.getByTestId("submit-turn").click(); + await next; + } + const debrief = aiResponse(page, "crossfire", "debrief"); + await page.getByTestId("end-crossfire").click(); + await debrief; + await expect(page.getByTestId("crossfire-debrief")).toBeVisible({ timeout: AI_TIMEOUT }); + await evidence.shot("02-crossfire-debrief"); + expect(await aiCounter(userId)).toBe(units); + expect(await sessionRow(userId, "crossfire")).toEqual([{ charged: true, units, calls_used: 4 }]); + await evidence.step(`Opening question, two answers and the debrief: four calls, one charge of ${units}`); +}); + +test("usage-refund-reload: reloading while Brainstorm streams costs nothing", async ({ signedIn: page, user, evidence }) => { + test.skip(LIVE, "only the fake provider can hold a reply mid-stream"); + const userId = await user.userId(); + 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 chooseForMotion(page, "create"); + // The run is reserved and streaming: the counter shows it. + await expect.poll(() => aiCounter(userId), { timeout: AI_TIMEOUT }).toBeGreaterThan(0); + await expect.poll(async () => (await providerCalls()).some((call) => call.feature === "brainstorm"), { timeout: AI_TIMEOUT }).toBe(true); + await evidence.shot("01-brainstorm-streaming"); + await page.reload(); + await expect.poll(async () => { + const { data } = await admin.from("ai_requests").select("status,error_code").eq("user_id", userId).eq("feature", "brainstorm"); + return data; + }, { timeout: AI_TIMEOUT }).toEqual([{ status: "cancelled", error_code: "aborted" }]); + expect(await aiCounter(userId)).toBe(0); + await evidence.step("Reload mid-stream: the run was logged cancelled/aborted and its units given back (counter 0)"); +}); + +test("usage-ask-not-sent: a question refused by the daily limit stays, marked Not sent, and Retry sends it once", async ({ signedIn: page, user, evidence }) => { + const userId = await user.userId(); + await admin.from("user_settings").update({ daily_limit_override: 2 }).eq("user_id", userId); + const { error: seedError } = await admin.from("daily_usage_counters").insert({ + user_id: userId, usage_date: new Date().toISOString().slice(0, 10), quota_key: "ai", request_count: 2, + }); + if (seedError) throw seedError; + const question = "What is the strongest objection to free public transport?"; + await page.goto("/app/ask"); + await page.getByRole("textbox", { name: "Message to your debate coach" }).fill(question); + await page.keyboard.press("Enter"); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible({ timeout: AI_TIMEOUT }); + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + const notSent = page.getByRole("alert").filter({ hasText: "Not sent: daily limit reached" }); + await expect(notSent).toBeVisible(); + await expect(page.getByText(question).first()).toBeVisible(); + await evidence.shot("01-ask-not-sent"); + await evidence.aria("01-ask-not-sent", notSent); + + await admin.from("user_settings").update({ daily_limit_override: 20 }).eq("user_id", userId); + // Plain Ask answers stream from ai-stream. + const answer = page.waitForResponse((response) => response.url().endsWith("/functions/v1/ai-stream"), { timeout: AI_TIMEOUT }); + await notSent.getByRole("button", { name: "Retry" }).click(); + await answer; + await expect(notSent).toHaveCount(0); + await expect(page.getByText(question)).toHaveCount(1); + await evidence.shot("02-ask-retried"); +}); diff --git a/.claude/skills/verify-debatelab/recipes/workspace-autosave.spec.ts b/.claude/skills/verify-debatelab/recipes/workspace-autosave.spec.ts index 623485010..9b222ab0a 100644 --- a/.claude/skills/verify-debatelab/recipes/workspace-autosave.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/workspace-autosave.spec.ts @@ -107,8 +107,9 @@ test("ws-sparring-autosave: a slow Sparring save does not overwrite a newer draf const draft = page.getByRole("textbox", { name: /^Draft for / }); await expect(draft).toBeVisible({ timeout: AI_TIMEOUT }); // A running clock saves the session every tick, which would hide a stale save. - const pause = page.getByRole("button", { name: "Pause clock" }); - if (await pause.isVisible()) await pause.click(); + // The turn opens on its ready step; start it, then pause, so typing does not restart the clock. + await page.getByRole("button", { name: "Start speaking" }).click(); + await page.getByRole("button", { name: "Pause clock" }).click(); await expect(page.getByRole("button", { name: "Resume clock" })).toBeVisible(); const saves = await holdSave(page, (progress) => progress.draft === "Coding"); diff --git a/.github/workflows/agentic-e2e.yml b/.github/workflows/agentic-e2e.yml index be4cbde71..b8d68505e 100644 --- a/.github/workflows/agentic-e2e.yml +++ b/.github/workflows/agentic-e2e.yml @@ -80,7 +80,7 @@ jobs: - name: Set up Supabase CLI uses: supabase/setup-cli@46f7f98c7f948ad727d22c1e67fab04c223a0520 # v3.0.0 with: - version: 2.116.0 + version: 2.121.0 - name: Install dependencies run: npm ci @@ -186,7 +186,7 @@ jobs: - name: Set up Supabase CLI uses: supabase/setup-cli@46f7f98c7f948ad727d22c1e67fab04c223a0520 # v3.0.0 with: - version: 2.116.0 + version: 2.121.0 - name: Install dependencies run: npm ci diff --git a/.github/workflows/database.yml b/.github/workflows/database.yml index 5d4c91132..efb81124a 100644 --- a/.github/workflows/database.yml +++ b/.github/workflows/database.yml @@ -72,7 +72,7 @@ jobs: - name: Set up Supabase CLI uses: supabase/setup-cli@46f7f98c7f948ad727d22c1e67fab04c223a0520 # v3.0.0 with: - version: 2.116.0 + version: 2.121.0 - name: Install dependencies run: npm ci diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index e1adf8564..e559a44b3 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -42,7 +42,7 @@ jobs: - name: Set up Supabase CLI uses: supabase/setup-cli@46f7f98c7f948ad727d22c1e67fab04c223a0520 # v3.0.0 with: - version: 2.116.0 + version: 2.121.0 - name: Install dependencies run: npm ci diff --git a/.github/workflows/model-matrix.yml b/.github/workflows/model-matrix.yml index 19db35d24..8b9158c72 100644 --- a/.github/workflows/model-matrix.yml +++ b/.github/workflows/model-matrix.yml @@ -42,7 +42,7 @@ jobs: - name: Set up Supabase CLI uses: supabase/setup-cli@46f7f98c7f948ad727d22c1e67fab04c223a0520 # v3.0.0 with: - version: 2.116.0 + version: 2.121.0 - name: Install dependencies run: npm ci diff --git a/.github/workflows/prod-canary.yml b/.github/workflows/prod-canary.yml index 2b1a582e8..40f5f67bd 100644 --- a/.github/workflows/prod-canary.yml +++ b/.github/workflows/prod-canary.yml @@ -18,9 +18,14 @@ concurrency: jobs: canary: + # Vercel names the deployment environment after the project. The live site + # is debate-navigator; debatelab-staging's "production" is staging, so it + # must not start the canary. Plain 'Production' is the pre-2026-10 name. if: >- github.event_name != 'deployment_status' || - (github.event.deployment_status.state == 'success' && github.event.deployment.environment == 'Production') + (github.event.deployment_status.state == 'success' && + (github.event.deployment.environment == 'Production – debate-navigator' || + github.event.deployment.environment == 'Production')) runs-on: ubuntu-latest timeout-minutes: 15 steps: diff --git a/.gitignore b/.gitignore index 5e8a49898..03baf9c01 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,10 @@ coverage/ # TypeScript / Vite cache *.tsbuildinfo + +# Python bytecode from the scripts/ generators +__pycache__/ +*.pyc .vite/ .playwright-cli/ .playwright-mcp/ diff --git a/.superset/db-gate.sh b/.superset/db-gate.sh index 7a92b0e3b..139b1964c 100755 --- a/.superset/db-gate.sh +++ b/.superset/db-gate.sh @@ -21,6 +21,8 @@ esac DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$DIR/lib/lock.sh" +source "$DIR/lib/supabase-cli.sh" +use_pinned_supabase_cli "$(cd "$DIR/.." && pwd)" DB_LOCK="$HOME/.superset/debatelab-db.lock" FAKE_PROVIDER=debatelab-fake-openrouter @@ -28,9 +30,11 @@ FAKE_PROVIDER=debatelab-fake-openrouter # the fake provider. It needs LIVE_OPENROUTER_API_KEY and LIVE_EXA_API_KEY. # LIVE_MODEL=as-requested runs e2e/model-matrix instead: one case per catalog model. LIVE_PROVIDERS="${LIVE_PROVIDERS:-0}" -# Muse Spark 1.3 Contributor is the cheapest model that supports every request -# parameter the app sends. It is a test-only model: it is not in the app catalog. -LIVE_MODEL="${LIVE_MODEL:-meta/muse-spark-1.3-contributor}" # empty also means default +# Claude Haiku runs on DebateLab's Anthropic key (BYOK in OpenRouter), so the +# live gate spends no OpenRouter credits. Other families would, so they are +# refused; as-requested (the model matrix) is the one deliberate exception. +LIVE_MODEL="${LIVE_MODEL:-anthropic/claude-haiku-5.5}" # empty also means default +case "$LIVE_MODEL" in anthropic/*|as-requested) ;; *) echo "LIVE_MODEL must be an anthropic/ model (Anthropic BYOK) or as-requested, not $LIVE_MODEL" >&2; exit 1 ;; esac if [ "$LIVE_PROVIDERS" = 1 ] && { [ -z "${LIVE_OPENROUTER_API_KEY:-}" ] || [ -z "${LIVE_EXA_API_KEY:-}" ]; }; then echo "LIVE_PROVIDERS=1 requires LIVE_OPENROUTER_API_KEY and LIVE_EXA_API_KEY" >&2 exit 1 diff --git a/.superset/lib/supabase-cli.sh b/.superset/lib/supabase-cli.sh new file mode 100644 index 000000000..e1a02a04d --- /dev/null +++ b/.superset/lib/supabase-cli.sh @@ -0,0 +1,14 @@ +# Puts the repo-pinned Supabase CLI (the exact `supabase` devDependency in +# package.json) first on PATH. Every worktree then runs one CLI version, so +# Docker keeps one Supabase image set instead of one per CLI version. +# Tests point DEBATELAB_SUPABASE_BIN_DIR at a stub directory instead. +# +# usage: use_pinned_supabase_cli <repo root> +use_pinned_supabase_cli() { + local bin="${DEBATELAB_SUPABASE_BIN_DIR:-$1/node_modules/.bin}" + if [ ! -x "$bin/supabase" ]; then + echo "The repo-pinned Supabase CLI is missing ($bin/supabase). Run npm ci first." >&2 + return 1 + fi + export PATH="$bin:$PATH" +} diff --git a/.superset/teardown.sh b/.superset/teardown.sh index 61b591503..a244540af 100755 --- a/.superset/teardown.sh +++ b/.superset/teardown.sh @@ -6,6 +6,9 @@ set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$DIR/lib/lock.sh" +source "$DIR/lib/supabase-cli.sh" +# Teardown still runs when npm ci never did; then there is no stack to stop. +use_pinned_supabase_cli "$(cd "$DIR/.." && pwd)" 2>/dev/null || exit 0 WORKSPACE_KEY="$(pwd)" PORTS_LOCK="$HOME/.superset/debatelab-ports/lock" diff --git a/AGENTS.md b/AGENTS.md index b11b10cd2..c070958fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,18 +16,27 @@ The local Supabase stack still uses shared default ports and container names. In ## System map - `src/` is the React 18 and TypeScript client. Routes and lazy page entry points live in `src/App.tsx`. -- `src/lib/stores/` owns browser state and Supabase hydration. `src/lib/ai/client.ts` is the client AI boundary. +- `src/lib/stores/` owns browser state and Supabase hydration. `src/lib/ai/client.ts` is the client AI boundary: app code imports only from it. The request transport is `request.ts` (streaming Ask uses `streaming.ts`), and each feature has a file in `features/`, mirroring `supabase/functions/ai/features/`. - `packages/core/` (`@debatelab/core`, npm workspace) holds platform-neutral shared code: Zod schemas, validation, languages, Crossfire and Sparring logic. No DOM globals, React, or `import.meta` there; old `src/lib/` paths re-export it. Stores and i18n catalogs stay in `src/`. - `apps/mobile/` (`@debatelab/mobile`, npm workspace) is the Expo iOS app (React 19, Expo Router). It imports `@debatelab/core`, the `src/locales` catalogs and `src/lib/supabase/projects.ts`; it runs in Expo Go, and native modules Expo Go lacks go only into the development build (`DEBATELAB_BUILD=dev` in `app.config.ts`). Read [apps/mobile/AGENTS.md](apps/mobile/AGENTS.md) before changing it. - `supabase/functions/` contains Deno Edge Functions. Shared auth, CORS, research, prompts, cost, and validation code lives in `supabase/functions/_shared/`. - `supabase/migrations/` is the database history and schema source of truth. - `src/lib/stores/debate-store.ts` owns the Current Debate: every source and saved result belongs to one debate or to one-off work, and nothing crosses debates. - Collaborative Workspace is hidden. Its routes render only when `VITE_COLLABORATION_URL` is set, and production does not set it. Do not extend it unless Adam asks. -- `.github/workflows/` owns CI behavior. `vercel.json` owns production hosting: build output, SPA fallback, cache policy, and security headers. +- `.github/workflows/` owns CI behavior. `vercel.json` owns production hosting: build output, SPA fallback, cache policy, security headers, and which Git branches deploy. ## Deployment -The frontend is hosted on Vercel at `https://debate.pi-stack.org`. Merging to `main` starts a Vercel production deployment, so a merge ships the frontend. Supabase migrations and Edge Functions are not deployed to production by that merge; production deploys are separate authorized operations. Staging syncs itself from `main` within about 5 minutes through a VPS timer; [docs/staging.md](docs/staging.md) is the one place for staging access, seed data, and the sync. `npm run backend:status` (read-only) lists what the linked project is missing; run it before any backend deploy. +The frontend is hosted on Vercel at `https://debateowl.com`. Merging to `main` starts a Vercel production deployment, so a merge ships the frontend. Supabase migrations and Edge Functions are not deployed to production by that merge; production deploys are separate authorized operations. Staging syncs itself from `main` within about 5 minutes through a VPS timer; [docs/staging.md](docs/staging.md) is the one place for staging access, seed data, and the sync. `npm run backend:status` (read-only) lists what the linked project is missing; run it before any backend deploy. + +Vercel previews are opt-in. A push to `main` deploys both Vercel projects (production, and the staging project's site, which also tracks `main`). A push to any other branch does not create a deployment and does not count toward the daily cap. Request a hosted preview only when there is a real UI change to check. From the repo root, with the Vercel CLI logged in (`vercel login`, or `VERCEL_TOKEN` in the environment; never in the repo): + +```bash +npx vercel link --yes --non-interactive --scope adam-gaborjanis-projects --project debatelab-staging +npx vercel deploy --yes --non-interactive --scope adam-gaborjanis-projects +``` + +That preview is pinned to staging. Do not pass `--prod`, and do not run `vercel --yes` before the link: the first deploy of a new project is production. For a preview that uses production data, link `debate-navigator` instead. One preview is enough. Adam can create the same preview from the Vercel dashboard (Create Deployment, pick the branch, leave the target as Preview) without changing project settings. [docs/operations.md](docs/operations.md#opt-in-previews) has the detail. ## Normal ports @@ -67,12 +76,12 @@ Database migrations and pgTAP policies use a separate Docker-backed gate. In Sup Outside Superset, run the underlying commands directly: ```bash -supabase db start +npx supabase db start npm run test:db -supabase stop --no-backup +npx supabase stop --no-backup ``` -Supabase CLI 2.115 or newer is required. Do not run this gate concurrently from multiple worktrees. If this task starts the stack, stop it before finishing, including after failures when safe. +Use only the repo-pinned Supabase CLI (the exact `supabase` devDependency, 2.121.0). `npm run` scripts, `./.superset/db-gate.sh`, `scripts/agent-stack.sh`, and the verify harness already put it first on PATH; anywhere else, call `npx supabase`. Never use a global CLI or `npx supabase@latest`, and never bump the version in one worktree only: each CLI version pulls its own ~7 GB set of Docker images onto the shared VPS. Change it in `package.json`, `package-lock.json`, and the `setup-cli` steps in `.github/workflows/` together. Do not run this gate concurrently from multiple worktrees. If this task starts the stack, stop it before finishing, including after failures when safe. WebKit is a separate release smoke test: @@ -88,14 +97,14 @@ To prove that a user-facing change works, drive the real app with the `verify-de ```bash H=.claude/skills/verify-debatelab/bin/verify.sh $H up # fake model provider, free -$H up --live # real AI: OpenRouter pinned to meta/muse-spark-1.3-contributor, real Exa +$H up --live # real AI: OpenRouter pinned to anthropic/claude-haiku-5.5 (Anthropic BYOK), real Exa $H doctor # proves the stack is local, ours, and (live) pinned $H drive all # every recipe; or recipes/<name>, e2e/<path>; add --project desktop-chromium $H e2e run # agentic tests in agentic/ (TesterArmy e2e); `e2e explore "<goal>" --session student --agent <persona>` hunts for bugs $H down # always, including after failures ``` -- Use `--live` when the change touches prompts, model output handling, web research, or citations. The fake mode cannot prove those. URL import fetches pages directly and works the same in both modes. Every chat call is rewritten to the Muse Spark contributor model, so a full run costs cents plus Exa searches, and the proxy stops after 400 upstream calls (`--max-calls N`). Only use made-up debate content: the contributor model may keep prompts. +- Use `--live` when the change touches prompts, model output handling, web research, or citations. The fake mode cannot prove those. URL import fetches pages directly and works the same in both modes. Every chat call is rewritten to Claude Haiku 5.5 pinned to the Anthropic provider, so model calls bill the Anthropic BYOK key, not OpenRouter credits; Exa searches still cost, and the proxy stops after 400 upstream calls (`--max-calls N`). Only use made-up debate content. Never point a default, an Auto pool, or the testing agent at a non-`anthropic/` model or enable `allow_fallbacks`: `src/test/ai/anthropic-only-guard.test.ts` fails on it. - Live keys come from `~/.config/debatelab/live-verify.env` (mode 600, `LIVE_OPENROUTER_API_KEY` and `LIVE_EXA_API_KEY`). On Adam's VPS it holds copies of the Hermes keys in `~/.hermes/.env`. Never print them. - The stack holds the same lock as `./.superset/db-gate.sh`, so only one worktree can run it at a time. - Most tools work inside a Current Debate. Recipes start one with `startDebate` or answer the motion dialog with `chooseForMotion`; see the skill's Drive section. diff --git a/README.md b/README.md index f3ef35825..39f782291 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ DebateLab is an AI debate preparation and practice app for competitive debaters. Students keep one Current Debate for the motion they are preparing, build cases and research for both sides, and practise against the AI, with everything saved to that debate. -Live application: [debate.pi-stack.org](https://debate.pi-stack.org) +Live application: [debateowl.com](https://debateowl.com) (previously debate.pi-stack.org, which now redirects here) ## What it does @@ -24,7 +24,7 @@ Requirements: - Node.js 22.x and npm, using the committed `package-lock.json` - Deno 2.x for Edge Function tests -- Docker and Supabase CLI 2.115 or newer for the local database gate +- Docker and Supabase CLI 2.121.0, pinned in `package.json` (`npm ci` installs it) for the local database gate - Playwright browsers for browser tests ```bash @@ -59,7 +59,7 @@ Browser or PWA -> OpenRouter models, research providers, Stripe, Google APIs ``` -- The frontend is hosted on **Vercel**. [`vercel.json`](vercel.json) owns the build, SPA fallback, cache policy, and security headers. Merging to `main` deploys it to production. +- The frontend is hosted on **Vercel**. [`vercel.json`](vercel.json) owns the build, app-route rewrites, cache policy, and security headers. Merging to `main` deploys it to production. Pushes to other branches do not. - **Supabase** holds auth, data, and Edge Functions. Migrations and function deploys are separate operations that need explicit authorization. - The browser never holds provider secrets. AI requests go to the `ai` and `ai-stream` Edge Functions, which check auth, quotas, and controls, load debate sources by ID, call the model through OpenRouter, validate the output, and record usage. diff --git a/agentic/pages.e2e.ts b/agentic/pages.e2e.ts index 446531a29..813a86b68 100644 --- a/agentic/pages.e2e.ts +++ b/agentic/pages.e2e.ts @@ -30,7 +30,7 @@ const PUBLIC_PAGES = ["/", "/login", "/terms", "/privacy", "/expocit/sources"]; const NAMES: Record<Language, string> = { en: "English", es: "Spanish", hu: "Hungarian" }; const EXEMPT = - "Product names such as DebateLab and ExpoCit, AI model names (Claude Sonnet 5), plan names (Pro), debate format codes, language names offered as choices (English, Español, Magyar), and user content do not count. Technical loanwords the app uses on purpose, such as email, chat, prompt and token, count as translated."; + "Product names such as DebateOwl, DebateLab and ExpoCit, AI model names (Claude Sonnet 5), plan names (Pro), debate format codes and the competition format names kept in English in every language (Public Forum, Lincoln-Douglas, Policy, British Parliamentary, World Schools, Asian Parliamentary), language names offered as choices (English, Español, Magyar), and user content do not count. Technical loanwords the app uses on purpose, such as email, chat, prompt and token, count as translated."; async function checkPage(browser: Browser, agent: Agent, language: Language) { await expect(browser.locator("html")).toHaveAttribute("lang", language); diff --git a/agentic/replay/0b6684c4cf8f85ea83d9d2c90b3638a2f99b41bd44866a41d64915470d42a880.json b/agentic/replay/0b6684c4cf8f85ea83d9d2c90b3638a2f99b41bd44866a41d64915470d42a880.json index 461b8c044..f2a29b384 100644 --- a/agentic/replay/0b6684c4cf8f85ea83d9d2c90b3638a2f99b41bd44866a41d64915470d42a880.json +++ b/agentic/replay/0b6684c4cf8f85ea83d9d2c90b3638a2f99b41bd44866a41d64915470d42a880.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T09:24:38.325Z", + "createdAt": "2026-10-09T12:15:15.386Z", "payload": { "actions": [ { @@ -24,14 +24,6 @@ } } }, - { - "name": "tap", - "summary": "tap button \"Delete node\"", - "target": { - "role": "button", - "name": "Delete node" - } - }, { "name": "tap", "summary": "tap button \"Save\"", @@ -41,15 +33,10 @@ } }, { - "name": "type", - "summary": "type \"Zoo ban flow\" into textbox \"Node title...\" in \"Save Flowchart\"", - "target": { - "role": "textbox", - "name": "Node title...", - "placeholder": "Node title...", - "within": "Save Flowchart" - }, - "value": "Zoo ban flow" + "name": "typeText", + "summary": "type \"Zoo ban flow\" into the focused field", + "value": "Zoo ban flow", + "replace": false }, { "name": "tap", @@ -72,7 +59,7 @@ "callIndex": 0, "agent": "default" }, - "summary": "Deleted Argument 1; the flowchart now shows two arguments. Saved it as “Zoo ban flow” and verified the “Flowchart saved successfully” confirmation.", + "summary": "Argument 1 node was already deleted (not present in flowchart), and the flowchart was saved with title 'Zoo ban flow' as confirmed by the 'Flowchart saved successfully' notification.", "startPath": "/app/flowchart", "endPath": "/app/flowchart", "endAnchors": [ @@ -109,6 +96,6 @@ "name": "Opening Flowchart" } ], - "endWaitMs": 13789 + "endWaitMs": 13064 } } diff --git a/agentic/replay/126b78cf76461974e31c525473531fea764059779def23ffec087cecc00a0950.json b/agentic/replay/126b78cf76461974e31c525473531fea764059779def23ffec087cecc00a0950.json index ec8071a47..9ca170ecc 100644 --- a/agentic/replay/126b78cf76461974e31c525473531fea764059779def23ffec087cecc00a0950.json +++ b/agentic/replay/126b78cf76461974e31c525473531fea764059779def23ffec087cecc00a0950.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T08:38:04.778Z", + "createdAt": "2026-10-09T12:08:00.760Z", "payload": { "actions": [ { @@ -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,19 +34,19 @@ "callIndex": 0, "agent": "default" }, - "summary": "Entered the provided argument and ran Find Weaknesses. The completed analysis is visible, identifying a major evidential weakness and offering an opponent attack, an improved version, and further critique.", + "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": [ { - "role": "button", - "name": "Close" + "role": "heading", + "name": "Attack" }, { - "text": "Attack analysis complete" + "text": "No current debate. Results stay one-off until you start one." }, { - "text": "The critique is ready." + "text": "*" }, { "role": "textbox", @@ -55,36 +55,24 @@ "value": "Free public transport cuts congestion because more people ride the bus." }, { - "role": "heading", - "name": "Overall Assessment" + "text": "Pro" }, { - "text": "FAKE-PROVIDER attack summary: the central warrant is underdefended." + "text": "Reasoning" }, { - "text": "Case Strength" + "text": "Low" }, { - "text": "62/100" + "text": "Options" } ], "goneAnchors": [ { - "role": "textbox", - "name": "Your Argument *", - "placeholder": "Paste your debate case or argument here..." - }, - { - "text": "⚔️" - }, - { - "role": "heading", - "name": "No Mercy Mode" - }, - { - "text": "Paste your argument to receive ruthless criticism, identify logical fallacies, and get actionable improvements." + "role": "status", + "name": "Opening Attack" } ], - "endWaitMs": 14918 + "endWaitMs": 31437 } } diff --git a/agentic/replay/2296281fbea5e96beb19e409eaaf03e9257953f151152980f9f33713c14bf2c3.json b/agentic/replay/2296281fbea5e96beb19e409eaaf03e9257953f151152980f9f33713c14bf2c3.json index 87d78c7bd..46a4210a1 100644 --- a/agentic/replay/2296281fbea5e96beb19e409eaaf03e9257953f151152980f9f33713c14bf2c3.json +++ b/agentic/replay/2296281fbea5e96beb19e409eaaf03e9257953f151152980f9f33713c14bf2c3.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T08:38:38.089Z", + "createdAt": "2026-10-09T12:20:12.998Z", "payload": { "actions": [ { @@ -37,7 +37,7 @@ "callIndex": 0, "agent": "default" }, - "summary": "Opened History, found the saved PRO case for “This House would replace school exams with continuous assessment,” and selected Load. The app returned to Generate with the saved case displayed, including its thesis, arguments, predicted opponent case, and prepared counters.", + "summary": "Opened History and loaded the debate case. The case is now displayed on the Generate page with the 'Working in:' strip showing the motion 'This House would replace school exams with continuous assessment', and the full case content is visible including thesis, framework, 3 arguments, predicted oppo…", "startPath": "/app", "endPath": "/app/generate", "endAnchors": [ @@ -60,17 +60,23 @@ "name": "Use once" }, { - "role": "heading", - "name": "Generate Debate Case" + "text": "*" }, { - "text": "Create structured arguments for any motion" + "role": "textbox", + "name": "Motion / Topic *", + "placeholder": "e.g., This house believes that artificial intelligence will create more jobs than it destroys", + "value": "This House would replace school exams with continuous assessment" }, { - "text": "*" + "text": "Side" } ], "goneAnchors": [ + { + "role": "heading", + "name": "What are you working on?" + }, { "text": "Last activity less than a minute ago · 0 sources · 1 outputs" }, @@ -93,11 +99,8 @@ }, { "text": "Explore angles for a motion." - }, - { - "text": "Factoids" } ], - "endWaitMs": 15541 + "endWaitMs": 15310 } } diff --git a/agentic/replay/23755b48030e2ba88ae7593c0e65506ecd8b0b7d98a4006cfda221eda5edb068.json b/agentic/replay/23755b48030e2ba88ae7593c0e65506ecd8b0b7d98a4006cfda221eda5edb068.json index 1f420f629..4abe87714 100644 --- a/agentic/replay/23755b48030e2ba88ae7593c0e65506ecd8b0b7d98a4006cfda221eda5edb068.json +++ b/agentic/replay/23755b48030e2ba88ae7593c0e65506ecd8b0b7d98a4006cfda221eda5edb068.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T10:49:38.577Z", + "createdAt": "2026-10-09T12:08:39.285Z", "payload": { "actions": [ { @@ -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" } } ], @@ -64,22 +64,17 @@ "callIndex": 0, "agent": "default" }, - "summary": "The new debate is active with the motion “This House would replace school exams with continuous assessment.” Generate completed successfully and displayed “Your debate case is ready,” with the generated case structure and arguments visible.", + "summary": "Debate started on motion 'This House would replace school exams with continuous assessment' and a debate case was generated on the Generate page, as confirmed by the current debate strip and the displayed case with thesis, arguments, and counters.", "startPath": "/app", "endPath": "/app/generate", "endAnchors": [ { - "role": "button", - "name": "Close" - }, - { - "text": "Generate complete" - }, - { - "text": "Your debate case is ready." + "text": "Current debate" }, { - "text": "Current debate" + "role": "button", + "name": "Current debate: This House would replace school exams with continuous assessment", + "testId": "current-debate-desktop" }, { "role": "heading", @@ -93,6 +88,14 @@ }, { "text": "In favor" + }, + { + "role": "button", + "name": "Change" + }, + { + "role": "button", + "name": "Use once" } ], "goneAnchors": [ @@ -101,6 +104,10 @@ "name": "Start debate", "testId": "current-debate-desktop" }, + { + "role": "heading", + "name": "What are you working on?" + }, { "text": "No current debate" }, @@ -119,11 +126,8 @@ }, { "text": "Brainstorm" - }, - { - "text": "Explore angles for a motion." } ], - "endWaitMs": 15032 + "endWaitMs": 36219 } } diff --git a/agentic/replay/32ddc883c4362b19704486dd14d5fe76f672e02d9bd0633ed5a4d9f36794f5b2.json b/agentic/replay/32ddc883c4362b19704486dd14d5fe76f672e02d9bd0633ed5a4d9f36794f5b2.json index 890b7856a..83ad63f55 100644 --- a/agentic/replay/32ddc883c4362b19704486dd14d5fe76f672e02d9bd0633ed5a4d9f36794f5b2.json +++ b/agentic/replay/32ddc883c4362b19704486dd14d5fe76f672e02d9bd0633ed5a4d9f36794f5b2.json @@ -1,14 +1,14 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T08:52:18.270Z", + "createdAt": "2026-10-09T17:44:08.490Z", "payload": { "actions": [ { "name": "tap", - "summary": "tap combobox \"Idioma de Salida IA\"", + "summary": "tap combobox \"Idioma de salida IA\"", "target": { "role": "combobox", - "name": "Idioma de Salida IA" + "name": "Idioma de salida IA" } }, { @@ -32,7 +32,7 @@ "callIndex": 0, "agent": "default" }, - "summary": "Changed “Idioma de Salida IA” to Magyar. The settings page now visibly shows Magyar as the selected output language.", + "summary": "Set AI output language to Magyar. The 'Idioma de salida IA' combobox now displays 'Magyar'.", "startPath": "/app/settings", "endPath": "/app/settings", "endAnchors": [ @@ -45,6 +45,6 @@ "text": "English" } ], - "endWaitMs": 17571 + "endWaitMs": 12369 } } diff --git a/agentic/replay/495abc7af387dc9131381dcd246cd42ee762f88ed3d578f31d3211625788e805.json b/agentic/replay/495abc7af387dc9131381dcd246cd42ee762f88ed3d578f31d3211625788e805.json index a9e454ca7..f8ff3d0b9 100644 --- a/agentic/replay/495abc7af387dc9131381dcd246cd42ee762f88ed3d578f31d3211625788e805.json +++ b/agentic/replay/495abc7af387dc9131381dcd246cd42ee762f88ed3d578f31d3211625788e805.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T09:21:22.438Z", + "createdAt": "2026-10-09T12:10:08.347Z", "payload": { "actions": [ { @@ -24,14 +24,6 @@ } } }, - { - "name": "tap", - "summary": "tap button \"Delete node\"", - "target": { - "role": "button", - "name": "Delete node" - } - }, { "name": "tap", "summary": "tap button \"Save\"", @@ -72,7 +64,7 @@ "callIndex": 0, "agent": "default" }, - "summary": "Deleted the Argument 1 node and saved the flowchart as “Zoo ban flow.” The app confirmed “Node deleted” and “Flowchart saved successfully.”.", + "summary": "Argument 1 was already deleted (cached replay), and the flowchart was saved with title 'Zoo ban flow'. The screen confirms 'Flowchart saved successfully'.", "startPath": "/app/flowchart", "endPath": "/app/flowchart", "endAnchors": [ @@ -109,6 +101,6 @@ "name": "Opening Flowchart" } ], - "endWaitMs": 13409 + "endWaitMs": 14370 } } diff --git a/agentic/replay/4b19b5a96c8088da6cfdc7035b87036fd9cc0ac6dbdd9fc8d128176a57f60dfd.json b/agentic/replay/4b19b5a96c8088da6cfdc7035b87036fd9cc0ac6dbdd9fc8d128176a57f60dfd.json index 06642db28..81d809bbc 100644 --- a/agentic/replay/4b19b5a96c8088da6cfdc7035b87036fd9cc0ac6dbdd9fc8d128176a57f60dfd.json +++ b/agentic/replay/4b19b5a96c8088da6cfdc7035b87036fd9cc0ac6dbdd9fc8d128176a57f60dfd.json @@ -1,14 +1,14 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T08:39:34.786Z", + "createdAt": "2026-10-09T12:10:07.885Z", "payload": { "actions": [ { "name": "type", - "summary": "type \"How do I weigh access against cost in a…\" into textbox \"Ask your debate coach...\"", + "summary": "type \"How do I weigh access against cost in a…\" into textbox \"Message to your debate coach\"", "target": { "role": "textbox", - "name": "Ask your debate coach...", + "name": "Message to your debate coach", "placeholder": "Ask your debate coach..." }, "value": "How do I weigh access against cost in a rebuttal?" @@ -34,7 +34,7 @@ "callIndex": 0, "agent": "default" }, - "summary": "Asked the debate coach “How do I weigh access against cost in a rebuttal?” and verified a visible answer: “FAKE-PROVIDER streamed answer: weigh access against cost.”", + "summary": "The question 'How do I weigh access against cost in a rebuttal?' was successfully asked and the coach answered with 'FAKE-PROVIDER streamed answer: weigh access against cost.' The screen confirms both the user message and the AI response are present.", "startPath": "/app/ask", "endPath": "/app/ask", "endAnchors": [ @@ -59,10 +59,13 @@ "text": "FAKE-PROVIDER streamed answer: weigh access against cost." }, { - "text": "Citations" + "role": "button", + "name": "Options" }, { - "text": "Web search" + "role": "textbox", + "name": "Message to your debate coach", + "placeholder": "Ask your debate coach..." } ], "goneAnchors": [ @@ -71,6 +74,6 @@ "name": "Opening Ask" } ], - "endWaitMs": 15156 + "endWaitMs": 27799 } } diff --git a/agentic/replay/67db0f98d129349c3fc659ab0da340448eafbee4174149a9cd7c2643da67f437.json b/agentic/replay/67db0f98d129349c3fc659ab0da340448eafbee4174149a9cd7c2643da67f437.json index 456f5be49..a38eaea22 100644 --- a/agentic/replay/67db0f98d129349c3fc659ab0da340448eafbee4174149a9cd7c2643da67f437.json +++ b/agentic/replay/67db0f98d129349c3fc659ab0da340448eafbee4174149a9cd7c2643da67f437.json @@ -1,14 +1,14 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T08:52:21.873Z", + "createdAt": "2026-10-09T17:40:45.020Z", "payload": { "actions": [ { "name": "tap", - "summary": "tap combobox \"Idioma de Salida IA\"", + "summary": "tap combobox \"Idioma de salida IA\"", "target": { "role": "combobox", - "name": "Idioma de Salida IA" + "name": "Idioma de salida IA" } }, { @@ -32,7 +32,7 @@ "callIndex": 0, "agent": "default" }, - "summary": "Set the AI Output Language to Magyar. The language list shows Magyar as selected.", + "summary": "Successfully set the AI output language to Magyar. The settings page shows the 'Idioma de salida IA' combobox now displays 'Magyar' as the selected value.", "startPath": "/app/settings", "endPath": "/app/settings", "endAnchors": [ @@ -45,6 +45,6 @@ "text": "English" } ], - "endWaitMs": 14920 + "endWaitMs": 12464 } } diff --git a/agentic/replay/6944dfabce5ae31228a20c2c28d91e6b10f370683c1bdd64349ee6ebd73846e3.json b/agentic/replay/6944dfabce5ae31228a20c2c28d91e6b10f370683c1bdd64349ee6ebd73846e3.json index 04876ad16..ce31996ad 100644 --- a/agentic/replay/6944dfabce5ae31228a20c2c28d91e6b10f370683c1bdd64349ee6ebd73846e3.json +++ b/agentic/replay/6944dfabce5ae31228a20c2c28d91e6b10f370683c1bdd64349ee6ebd73846e3.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T08:40:56.435Z", + "createdAt": "2026-10-09T12:22:43.719Z", "payload": { "actions": [ { @@ -34,7 +34,7 @@ "callIndex": 0, "agent": "default" }, - "summary": "Opened History, found the saved case for “This House would replace school exams with continuous assessment,” and selected Load. The app returned to Generate with the case loaded, showing the case structure, arguments, predicted opponent case, and prepared counters.", + "summary": "Opened History and loaded the generated debate case. The case for 'This House would replace school exams with continuous assessment' was found in History and loaded into Generate, showing the thesis, 3 arguments, predicted opponent case, and prepared counters.", "startPath": "/app", "endPath": "/app/generate", "endAnchors": [ @@ -57,17 +57,23 @@ "name": "Use once" }, { - "role": "heading", - "name": "Generate Debate Case" + "text": "*" }, { - "text": "Create structured arguments for any motion" + "role": "textbox", + "name": "Motion / Topic *", + "placeholder": "e.g., This house believes that artificial intelligence will create more jobs than it destroys", + "value": "This House would replace school exams with continuous assessment" }, { - "text": "*" + "text": "Side" } ], "goneAnchors": [ + { + "role": "heading", + "name": "What are you working on?" + }, { "text": "Last activity less than a minute ago · 0 sources · 1 outputs" }, @@ -90,11 +96,8 @@ }, { "text": "Explore angles for a motion." - }, - { - "text": "Factoids" } ], - "endWaitMs": 14008 + "endWaitMs": 14908 } } diff --git a/agentic/replay/729490fb7619dcddf35c0c60cedc58d2bc05aff8f07aad051c9ef2cd457f9799.json b/agentic/replay/729490fb7619dcddf35c0c60cedc58d2bc05aff8f07aad051c9ef2cd457f9799.json index b9efbbe79..e58758516 100644 --- a/agentic/replay/729490fb7619dcddf35c0c60cedc58d2bc05aff8f07aad051c9ef2cd457f9799.json +++ b/agentic/replay/729490fb7619dcddf35c0c60cedc58d2bc05aff8f07aad051c9ef2cd457f9799.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T09:22:24.040Z", + "createdAt": "2026-10-09T12:11:44.831Z", "payload": { "actions": [ { @@ -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" } } ], @@ -64,26 +64,21 @@ "callIndex": 0, "agent": "default" }, - "summary": "The new debate is active for “This House would replace school exams with continuous assessment.” Generate is open, and the visible “Generate complete — Your debate case is ready” confirmation and generated case verify the step succeeded.", + "summary": "Debate on", "startPath": "/app", "endPath": "/app/generate", "endAnchors": [ - { - "role": "button", - "name": "Close" - }, - { - "text": "Generate complete" - }, - { - "text": "Your debate case is ready." - }, { "text": "Current debate" }, { "text": "This House would replace school exams with continuous assessment" }, + { + "role": "button", + "name": "Current debate: This House would replace school exams with continuous assessment", + "testId": "current-debate-mobile" + }, { "role": "heading", "name": "Generate" @@ -93,6 +88,14 @@ }, { "text": "In favor" + }, + { + "role": "button", + "name": "Change" + }, + { + "role": "button", + "name": "Use once" } ], "goneAnchors": [ @@ -101,6 +104,10 @@ "name": "Start debate", "testId": "current-debate-mobile" }, + { + "role": "heading", + "name": "What are you working on?" + }, { "text": "No current debate" }, @@ -119,11 +126,8 @@ }, { "text": "Brainstorm" - }, - { - "text": "Explore angles for a motion." } ], - "endWaitMs": 14124 + "endWaitMs": 33894 } } diff --git a/agentic/replay/79a9ee2259d8d16afd564cc66fa33f4b3906e33910768c64dc5c2d643baa1ea1.json b/agentic/replay/79a9ee2259d8d16afd564cc66fa33f4b3906e33910768c64dc5c2d643baa1ea1.json index 6c57d4360..ac9660b25 100644 --- a/agentic/replay/79a9ee2259d8d16afd564cc66fa33f4b3906e33910768c64dc5c2d643baa1ea1.json +++ b/agentic/replay/79a9ee2259d8d16afd564cc66fa33f4b3906e33910768c64dc5c2d643baa1ea1.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T08:46:53.105Z", + "createdAt": "2026-10-09T12:09:24.322Z", "payload": { "actions": [ { @@ -34,36 +34,37 @@ "callIndex": 0, "agent": "default" }, - "summary": "Submitted the factoid search for “Cities with free buses have fewer cars downtown.” Research completed and the results screen shows “Found 0 Factoids” with a no-matching-sources message.", + "summary": "Factoid search for 'Cities with free buses have fewer cars downtown.' was executed and completed. The search finished with 0 factoids found, showing 'No verified matching sources were found' — which satisfies the step goal since completion matters regardless of results.", "startPath": "/app/factoids", "endPath": "/app/factoids", "endAnchors": [ { - "role": "button", - "name": "Close" + "role": "heading", + "name": "Factoids" }, { - "text": "Research complete" + "text": "No current debate. Results stay one-off until you start one." }, { - "text": "Verified evidence is ready." + "text": "*" }, { - "role": "heading", - "name": "Factoids" + "role": "textbox", + "name": "Statement *", + "placeholder": "e.g., Renewable energy is more cost-effective than fossil fuels", + "value": "Cities with free buses have fewer cars downtown." }, { - "text": "No current debate. Results stay one-off until you start one." + "text": "Position" }, { - "role": "heading", - "name": "Find Evidence" + "text": "In favor" }, { - "text": "Get debate-ready factoids with verified citations" + "text": "Source Type" }, { - "text": "*" + "text": "Any Reputable Source" } ], "goneAnchors": [ @@ -72,6 +73,6 @@ "name": "Opening Factoids" } ], - "endWaitMs": 14699 + "endWaitMs": 29486 } } diff --git a/agentic/replay/a7ab22475ff220f06530d1b208935a31518cb6002dad033bbe5549c162f9df6e.json b/agentic/replay/a7ab22475ff220f06530d1b208935a31518cb6002dad033bbe5549c162f9df6e.json index efb7b374b..23cec4a2d 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": [ @@ -98,7 +98,7 @@ "goneAnchors": [ { "role": "alertdialog", - "name": "Welcome to DebateLab! 🎉Everything in DebateLab belongs to one debate: the motion you are preparing, its sources, and your results. Start your first debate here.Start debate" + "name": "Welcome to DebateOwl! 🎉Everything in DebateOwl belongs to one debate: the motion you are preparing, its sources, and your results. Start your first debate here.Start debate" }, { "role": "button", @@ -113,14 +113,14 @@ }, { "role": "heading", - "name": "Welcome to DebateLab! 🎉" + "name": "Welcome to DebateOwl! 🎉" }, { - "text": "Everything in DebateLab belongs to one debate: the motion you are preparing, its sources, and your results. Start your first debate here." + "text": "Everything in DebateOwl belongs to one debate: the motion you are preparing, its sources, and your results. Start your first debate here." }, { "role": "button", - "name": "Skip Tour" + "name": "Skip tour" }, { "role": "button", diff --git a/agentic/replay/c6cf102fb2172d14552e85bf19abb2d80a6075794717cc7c0c2cd0610bb9fe37.json b/agentic/replay/c6cf102fb2172d14552e85bf19abb2d80a6075794717cc7c0c2cd0610bb9fe37.json index 88482039c..60e2a68d9 100644 --- a/agentic/replay/c6cf102fb2172d14552e85bf19abb2d80a6075794717cc7c0c2cd0610bb9fe37.json +++ b/agentic/replay/c6cf102fb2172d14552e85bf19abb2d80a6075794717cc7c0c2cd0610bb9fe37.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T08:42:12.424Z", + "createdAt": "2026-10-09T07:51:18.781Z", "payload": { "actions": [ { @@ -46,28 +46,28 @@ }, { "name": "tap", - "summary": "tap button \"Add Source\"", + "summary": "tap button \"Add source\"", "target": { "role": "button", - "name": "Add Source" + "name": "Add source" } }, { "name": "type", - "summary": "type \"Plastic bag levy study\" into textbox \"Source title...\"", + "summary": "type \"Plastic bag levy study\" into textbox \"Title\"", "target": { "role": "textbox", - "name": "Source title...", + "name": "Title", "placeholder": "Source title..." }, "value": "Plastic bag levy study" }, { "name": "type", - "summary": "type \"After a 5p levy, single-use bag sales a…\" into textbox \"Paste source content...\"", + "summary": "type \"After a 5p levy, single-use bag sales a…\" into textbox \"Content\"", "target": { "role": "textbox", - "name": "Paste source content...", + "name": "Content", "placeholder": "Paste source content..." }, "value": "After a 5p levy, single-use bag sales at major retailers fell by more than 95 percent." @@ -93,36 +93,38 @@ "callIndex": 0, "agent": "default" }, - "summary": "Started the debate “This House would ban single-use plastics” and added “Plastic bag levy study” to the Library with the provided content. The Library shows the saved source and a “Source Added” confirmation.", + "summary": "Started debate on 'This House would ban single-use plastics', opened Library, and added source 'Plastic bag levy study' with content about 5p levy reducing bag sales by 95%. Source now appears in library with 1 source active and grounding enabled.", "startPath": "/app", "endPath": "/app/library", "endAnchors": [ { - "role": "button", - "name": "Close" + "text": "Current debate" }, { - "text": "Source Added" + "text": "This House would ban single-use plastics" }, { - "text": "Your source is now available for grounding." + "role": "button", + "name": "Current debate: This House would ban single-use plastics", + "testId": "current-debate-mobile" }, { - "text": "Current debate" + "role": "heading", + "name": "Library" }, { - "text": "This House would ban single-use plastics" + "role": "button", + "name": "Change" }, { - "role": "heading", - "name": "Source Library" + "text": "1 sources · 1 active · 1 folders" }, { - "role": "button", - "name": "Change" + "text": "Smart" }, { - "text": "All folders" + "role": "heading", + "name": "Your library" } ], "goneAnchors": [ @@ -131,6 +133,10 @@ "name": "Start debate", "testId": "current-debate-mobile" }, + { + "role": "heading", + "name": "What are you working on?" + }, { "text": "No current debate" }, @@ -149,11 +155,8 @@ }, { "text": "Brainstorm" - }, - { - "text": "Explore angles for a motion." } ], - "endWaitMs": 12722 + "endWaitMs": 13153 } } diff --git a/agentic/replay/cc6903d8aafb702ef5a496a05b9c45338886c52fc41ea6a7c0f6fd1435dfbfec.json b/agentic/replay/cc6903d8aafb702ef5a496a05b9c45338886c52fc41ea6a7c0f6fd1435dfbfec.json index f1d904802..a11e55982 100644 --- a/agentic/replay/cc6903d8aafb702ef5a496a05b9c45338886c52fc41ea6a7c0f6fd1435dfbfec.json +++ b/agentic/replay/cc6903d8aafb702ef5a496a05b9c45338886c52fc41ea6a7c0f6fd1435dfbfec.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T08:52:21.869Z", + "createdAt": "2026-10-09T07:48:57.404Z", "payload": { "actions": [ { @@ -32,16 +32,16 @@ "callIndex": 0, "agent": "default" }, - "summary": "Changed the Interface Language setting to Español. The settings page and navigation are now displayed in Spanish, including “Ajustes” and “Idioma de Interfaz.”.", + "summary": "The interface language has been switched to Español. The settings page now displays entirely in Spanish (Ajustes, Herramientas, Biblioteca, Historial, Idioma de Interfaz), and the combobox shows Español as the selected value. The language switch took effect successfully.", "startPath": "/app/settings", "endPath": "/app/settings", "endAnchors": [ { "role": "image", - "name": "Logotipo de DebateLab" + "name": "Logotipo de DebateOwl" }, { - "text": "Inicio" + "text": "Herramientas" }, { "role": "button", @@ -69,10 +69,10 @@ "goneAnchors": [ { "role": "image", - "name": "DebateLab logo" + "name": "DebateOwl logo" }, { - "text": "Home" + "text": "Tools" }, { "role": "button", @@ -97,6 +97,6 @@ "name": "Settings" } ], - "endWaitMs": 17439 + "endWaitMs": 32208 } } diff --git a/agentic/replay/cec2ee4bacb9750f2fa98e3124caf500077ddc18df231c58d9c29724467af6ac.json b/agentic/replay/cec2ee4bacb9750f2fa98e3124caf500077ddc18df231c58d9c29724467af6ac.json index 8c206c45f..24e0d292d 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": [ @@ -98,7 +98,7 @@ "goneAnchors": [ { "role": "alertdialog", - "name": "Welcome to DebateLab! 🎉Everything in DebateLab belongs to one debate: the motion you are preparing, its sources, and your results. Start your first debate here.Start debate" + "name": "Welcome to DebateOwl! 🎉Everything in DebateOwl belongs to one debate: the motion you are preparing, its sources, and your results. Start your first debate here.Start debate" }, { "role": "button", @@ -113,14 +113,14 @@ }, { "role": "heading", - "name": "Welcome to DebateLab! 🎉" + "name": "Welcome to DebateOwl! 🎉" }, { - "text": "Everything in DebateLab belongs to one debate: the motion you are preparing, its sources, and your results. Start your first debate here." + "text": "Everything in DebateOwl belongs to one debate: the motion you are preparing, its sources, and your results. Start your first debate here." }, { "role": "button", - "name": "Skip Tour" + "name": "Skip tour" }, { "role": "button", diff --git a/agentic/replay/d15dccac4ac18fdabdb7d4c086d281513bd8832a317f7d3d65ac987fec417dc4.json b/agentic/replay/d15dccac4ac18fdabdb7d4c086d281513bd8832a317f7d3d65ac987fec417dc4.json index 13f86910a..883031931 100644 --- a/agentic/replay/d15dccac4ac18fdabdb7d4c086d281513bd8832a317f7d3d65ac987fec417dc4.json +++ b/agentic/replay/d15dccac4ac18fdabdb7d4c086d281513bd8832a317f7d3d65ac987fec417dc4.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T08:52:18.268Z", + "createdAt": "2026-10-10T15:58:12.314Z", "payload": { "actions": [ { @@ -32,16 +32,13 @@ "callIndex": 0, "agent": "default" }, - "summary": "Changed the Interface Language setting to Español. The settings page now displays Spanish labels, including “Idioma de Interfaz” with “Español” selected.", + "summary": "Switched the interface language to Español. The settings page now displays all labels in Spanish (e.g., 'Idioma de interfaz', 'Ajustes', 'Iniciar debate', 'Cerrar sesión'), and the Interface Language combobox shows 'Español'.", "startPath": "/app/settings", "endPath": "/app/settings", "endAnchors": [ { "role": "image", - "name": "Logotipo de DebateLab" - }, - { - "text": "Cambiar tema" + "name": "Logotipo de DebateOwl" }, { "role": "button", @@ -56,28 +53,24 @@ "role": "heading", "name": "Suscripción" }, - { - "text": "Pro" - }, { "text": "El acceso a la IA está activo. Pueden aplicarse límites de uso." }, { "role": "heading", "name": "Ajustes globales" + }, + { + "text": "Idioma de interfaz" + }, + { + "text": "Español" } ], "goneAnchors": [ - { - "role": "status", - "name": "Opening Settings" - }, { "role": "image", - "name": "DebateLab logo" - }, - { - "text": "Toggle theme" + "name": "DebateOwl logo" }, { "role": "button", @@ -85,22 +78,27 @@ "testId": "current-debate-mobile" }, { - "role": "link", - "name": "Terms" + "role": "heading", + "name": "Settings" }, { - "role": "link", - "name": "Privacy" + "role": "heading", + "name": "Subscription" }, { - "role": "link", - "name": "DebateLab logo DebateLab" + "text": "AI access is active. Usage limits may apply." }, { - "role": "button", - "name": "Language" + "role": "heading", + "name": "Global Settings" + }, + { + "text": "Interface Language" + }, + { + "text": "AI Output Language" } ], - "endWaitMs": 12650 + "endWaitMs": 12842 } } diff --git a/agentic/replay/d636e78bd57e1346e9ad878bdd394cbf70ccb746bdcfcc35af4c0a7d2cf6795e.json b/agentic/replay/d636e78bd57e1346e9ad878bdd394cbf70ccb746bdcfcc35af4c0a7d2cf6795e.json index f0c6b8875..4b5b04e69 100644 --- a/agentic/replay/d636e78bd57e1346e9ad878bdd394cbf70ccb746bdcfcc35af4c0a7d2cf6795e.json +++ b/agentic/replay/d636e78bd57e1346e9ad878bdd394cbf70ccb746bdcfcc35af4c0a7d2cf6795e.json @@ -1,14 +1,14 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T08:41:41.696Z", + "createdAt": "2026-10-09T12:13:49.892Z", "payload": { "actions": [ { "name": "type", - "summary": "type \"How do I weigh access against cost in a…\" into textbox \"Ask your debate coach...\"", + "summary": "type \"How do I weigh access against cost in a…\" into textbox \"Message to your debate coach\"", "target": { "role": "textbox", - "name": "Ask your debate coach...", + "name": "Message to your debate coach", "placeholder": "Ask your debate coach..." }, "value": "How do I weigh access against cost in a rebuttal?" @@ -34,7 +34,7 @@ "callIndex": 0, "agent": "default" }, - "summary": "Asked the debate coach, “How do I weigh access against cost in a rebuttal?” The conversation displays the question and a response: “FAKE-PROVIDER streamed answer: weigh access against cost.”", + "summary": "The question", "startPath": "/app/ask", "endPath": "/app/ask", "endAnchors": [ @@ -62,7 +62,8 @@ "text": "FAKE-PROVIDER streamed answer: weigh access against cost." }, { - "text": "Citations" + "role": "button", + "name": "Options" } ], "goneAnchors": [ @@ -71,6 +72,6 @@ "name": "Opening Ask" } ], - "endWaitMs": 12792 + "endWaitMs": 48628 } } diff --git a/agentic/replay/d73284119fd8669a81b6ad8f2680b21f11103285e5b28beed208c736f63fd98c.json b/agentic/replay/d73284119fd8669a81b6ad8f2680b21f11103285e5b28beed208c736f63fd98c.json index 942a2d496..49a355a0d 100644 --- a/agentic/replay/d73284119fd8669a81b6ad8f2680b21f11103285e5b28beed208c736f63fd98c.json +++ b/agentic/replay/d73284119fd8669a81b6ad8f2680b21f11103285e5b28beed208c736f63fd98c.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T08:44:38.227Z", + "createdAt": "2026-10-09T12:12:49.396Z", "payload": { "actions": [ { @@ -34,36 +34,37 @@ "callIndex": 0, "agent": "default" }, - "summary": "Searched factoids for “Cities with free buses have fewer cars downtown.” Research completed; the results screen confirms “Found 0 Factoids” and reports no verified matching sources.", + "summary": "Factoid search completed for the statement", "startPath": "/app/factoids", "endPath": "/app/factoids", "endAnchors": [ { - "role": "button", - "name": "Close" + "role": "heading", + "name": "Factoids" }, { - "text": "Research complete" + "text": "No current debate. Results stay one-off until you start one." }, { - "text": "Verified evidence is ready." + "text": "*" }, { - "role": "heading", - "name": "Factoids" + "role": "textbox", + "name": "Statement *", + "placeholder": "e.g., Renewable energy is more cost-effective than fossil fuels", + "value": "Cities with free buses have fewer cars downtown." }, { - "text": "No current debate. Results stay one-off until you start one." + "text": "Position" }, { - "role": "heading", - "name": "Find Evidence" + "text": "In favor" }, { - "text": "Get debate-ready factoids with verified citations" + "text": "Source Type" }, { - "text": "*" + "text": "Any Reputable Source" } ], "goneAnchors": [ @@ -72,6 +73,6 @@ "name": "Opening Factoids" } ], - "endWaitMs": 14622 + "endWaitMs": 29790 } } diff --git a/agentic/replay/d837eeb2b0665a23180f65fb205e2ea67f8b44f1a1a6a400067d474f1b21cb5f.json b/agentic/replay/d837eeb2b0665a23180f65fb205e2ea67f8b44f1a1a6a400067d474f1b21cb5f.json index f13c2f6ca..66a840aff 100644 --- a/agentic/replay/d837eeb2b0665a23180f65fb205e2ea67f8b44f1a1a6a400067d474f1b21cb5f.json +++ b/agentic/replay/d837eeb2b0665a23180f65fb205e2ea67f8b44f1a1a6a400067d474f1b21cb5f.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T08:38:37.416Z", + "createdAt": "2026-10-09T12:08:46.651Z", "payload": { "actions": [ { @@ -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,13 +43,18 @@ "callIndex": 0, "agent": "default" }, - "summary": "Started a Current Debate for “This House would ban zoos” and brainstormed angles. The screen confirms “Brainstorm complete” and shows the saved Top 5 Argument Angles result.", + "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": [ { "text": "Current debate" }, + { + "role": "button", + "name": "Current debate: This House would ban zoos", + "testId": "current-debate-desktop" + }, { "role": "heading", "name": "Brainstorm" @@ -70,10 +75,6 @@ { "role": "button", "name": "Use once" - }, - { - "role": "heading", - "name": "Brainstorm Ideas" } ], "goneAnchors": [ @@ -87,6 +88,6 @@ "testId": "current-debate-desktop" } ], - "endWaitMs": 17658 + "endWaitMs": 35418 } } diff --git a/agentic/replay/e0c912b52a6de1dadb5287bb54346d1fac813a9d7ac70a1742898dc3abdf4a99.json b/agentic/replay/e0c912b52a6de1dadb5287bb54346d1fac813a9d7ac70a1742898dc3abdf4a99.json index 569dada6e..3a1b50143 100644 --- a/agentic/replay/e0c912b52a6de1dadb5287bb54346d1fac813a9d7ac70a1742898dc3abdf4a99.json +++ b/agentic/replay/e0c912b52a6de1dadb5287bb54346d1fac813a9d7ac70a1742898dc3abdf4a99.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T08:40:42.795Z", + "createdAt": "2026-10-09T12:22:15.982Z", "payload": { "actions": [ { @@ -15,19 +15,42 @@ }, { "name": "tap", - "summary": "tap button \"Start Brainstorming\"", + "summary": "tap button \"Start debate\" (2 of 2)", "target": { "role": "button", - "name": "Start Brainstorming" + "name": "Start debate", + "position": { + "index": 1, + "of": 2 + } } }, + { + "name": "type", + "summary": "type \"This House would ban zoos\" into textbox \"Motion\" in \"Start new debate\"", + "target": { + "role": "textbox", + "name": "Motion", + "placeholder": "This House would…", + "within": "Start new debate" + }, + "value": "This House would ban zoos" + }, { "name": "tap", - "summary": "tap button \"Start debate\" in \"Start a debate with this motion?\"", + "summary": "tap button \"Start debate\" in \"Start new debate\"", "target": { "role": "button", "name": "Start debate", - "within": "Start a debate with this motion?" + "within": "Start new debate" + } + }, + { + "name": "tap", + "summary": "tap button \"Start brainstorming\"", + "target": { + "role": "button", + "name": "Start brainstorming" } } ], @@ -43,7 +66,7 @@ "callIndex": 0, "agent": "default" }, - "summary": "Started a debate for “This House would ban zoos” and brainstormed angles. The page shows the current debate set to that motion and a saved Brainstorm result with five 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": [ @@ -53,6 +76,11 @@ { "text": "This House would ban zoos" }, + { + "role": "button", + "name": "Current debate: This House would ban zoos", + "testId": "current-debate-mobile" + }, { "role": "heading", "name": "Brainstorm" @@ -60,9 +88,6 @@ { "text": "Working in:" }, - { - "text": "Both sides" - }, { "role": "button", "name": "Change" @@ -72,8 +97,7 @@ "name": "Use once" }, { - "role": "heading", - "name": "Brainstorm Ideas" + "text": "*" } ], "goneAnchors": [ @@ -87,6 +111,6 @@ "testId": "current-debate-mobile" } ], - "endWaitMs": 24713 + "endWaitMs": 14778 } } diff --git a/agentic/replay/e753f1e71c4d4ed90310f668e44ebdc847f876ca5ec6d2ade3d672c29aedcaf9.json b/agentic/replay/e753f1e71c4d4ed90310f668e44ebdc847f876ca5ec6d2ade3d672c29aedcaf9.json index 6abd19266..70aa3a2f1 100644 --- a/agentic/replay/e753f1e71c4d4ed90310f668e44ebdc847f876ca5ec6d2ade3d672c29aedcaf9.json +++ b/agentic/replay/e753f1e71c4d4ed90310f668e44ebdc847f876ca5ec6d2ade3d672c29aedcaf9.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T08:40:03.869Z", + "createdAt": "2026-10-09T12:10:40.226Z", "payload": { "actions": [ { @@ -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,36 +34,37 @@ "callIndex": 0, "agent": "default" }, - "summary": "Entered the argument and selected Find Weaknesses. The attack analysis completed and shows an identified major evidential weakness: the congestion claim lacks comparative evidence, with criticism and improvement suggestions displayed.", + "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": [ { - "role": "button", - "name": "Close" + "role": "heading", + "name": "Attack" }, { - "text": "Attack analysis complete" + "text": "No current debate. Results stay one-off until you start one." }, { - "text": "The critique is ready." + "text": "*" }, { - "role": "heading", - "name": "Attack" + "role": "textbox", + "name": "Your Argument *", + "placeholder": "Paste your debate case or argument here...", + "value": "Free public transport cuts congestion because more people ride the bus." }, { - "text": "No current debate. Results stay one-off until you start one." + "text": "Pro" }, { - "role": "heading", - "name": "Attack My Case" + "text": "Reasoning" }, { - "text": "Find weaknesses and get improvement suggestions" + "text": "Low" }, { - "text": "*" + "text": "Options" } ], "goneAnchors": [ @@ -72,6 +73,6 @@ "name": "Opening Attack" } ], - "endWaitMs": 13006 + "endWaitMs": 27713 } } diff --git a/agentic/replay/f6d6a30f85648dfe2446aa2888ca7fba9df68b2746461d556a193ac4ed89d357.json b/agentic/replay/f6d6a30f85648dfe2446aa2888ca7fba9df68b2746461d556a193ac4ed89d357.json index 11923333c..579d9b692 100644 --- a/agentic/replay/f6d6a30f85648dfe2446aa2888ca7fba9df68b2746461d556a193ac4ed89d357.json +++ b/agentic/replay/f6d6a30f85648dfe2446aa2888ca7fba9df68b2746461d556a193ac4ed89d357.json @@ -1,18 +1,15 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T08:51:36.896Z", + "createdAt": "2026-10-09T08:24:01.480Z", "payload": { "actions": [ { "name": "tap", - "summary": "tap button \"Start debate\" (2 of 2)", + "summary": "tap button \"Start debate\"", "target": { "role": "button", "name": "Start debate", - "position": { - "index": 1, - "of": 2 - } + "testId": "current-debate-desktop" } }, { @@ -49,28 +46,28 @@ }, { "name": "tap", - "summary": "tap button \"Add Source\"", + "summary": "tap button \"Add source\"", "target": { "role": "button", - "name": "Add Source" + "name": "Add source" } }, { "name": "type", - "summary": "type \"Plastic bag levy study\" into textbox \"Source title...\"", + "summary": "type \"Plastic bag levy study\" into textbox \"Title\"", "target": { "role": "textbox", - "name": "Source title...", + "name": "Title", "placeholder": "Source title..." }, "value": "Plastic bag levy study" }, { "name": "type", - "summary": "type \"After a 5p levy, single-use bag sales a…\" into textbox \"Paste source content...\"", + "summary": "type \"After a 5p levy, single-use bag sales a…\" into textbox \"Content\"", "target": { "role": "textbox", - "name": "Paste source content...", + "name": "Content", "placeholder": "Paste source content..." }, "value": "After a 5p levy, single-use bag sales at major retailers fell by more than 95 percent." @@ -96,26 +93,21 @@ "callIndex": 0, "agent": "default" }, - "summary": "Started the debate “This House would ban single-use plastics” and added “Plastic bag levy study” to the Library with the requested content. The Library shows the source and a “Source Added” confirmation.", + "summary": "Successfully started a debate on 'This House would ban single-use plastics' and added a source titled 'Plastic bag levy study' with the specified content in the Library. The library now shows 1 source, and the source card displays the correct title and content.", "startPath": "/app", "endPath": "/app/library", "endAnchors": [ { - "role": "button", - "name": "Close" - }, - { - "text": "Source Added" - }, - { - "text": "Your source is now available for grounding." + "text": "Current debate" }, { - "text": "Current debate" + "role": "button", + "name": "Current debate: This House would ban single-use plastics", + "testId": "current-debate-desktop" }, { "role": "heading", - "name": "Source Library" + "name": "Library" }, { "text": "This House would ban single-use plastics" @@ -125,7 +117,14 @@ "name": "Change" }, { - "text": "All folders" + "text": "1 sources · 1 active · 1 folders" + }, + { + "text": "Smart" + }, + { + "role": "heading", + "name": "Your library" } ], "goneAnchors": [ @@ -134,6 +133,10 @@ "name": "Start debate", "testId": "current-debate-desktop" }, + { + "role": "heading", + "name": "What are you working on?" + }, { "text": "No current debate" }, @@ -152,11 +155,8 @@ }, { "text": "Brainstorm" - }, - { - "text": "Explore angles for a motion." } ], - "endWaitMs": 14336 + "endWaitMs": 12889 } } diff --git a/agentic/support/anthropic-cache.ts b/agentic/support/anthropic-cache.ts new file mode 100644 index 000000000..27ce8293e --- /dev/null +++ b/agentic/support/anthropic-cache.ts @@ -0,0 +1,46 @@ +// The testing agent marks Anthropic prompt-cache breakpoints on the system +// prompt and on the newest message. @openrouter/ai-sdk-provider sends a tool +// message as one OpenRouter message per tool result and copies the message's +// breakpoint onto each, so an actor turn with parallel tool calls asks +// Anthropic for more than its limit of four breakpoints and the call fails +// ("A maximum of 4 blocks with cache_control may be provided"). Moving the +// breakpoint onto the last tool result keeps one per message, which caches +// the same prefix. + +type Options = Record<string, Record<string, unknown> | undefined>; +type Part = { providerOptions?: Options } & Record<string, unknown>; +type Message = { role: string; content: unknown; providerOptions?: Options }; +type CallOptions = { prompt: Message[] } & Record<string, unknown>; + +export function oneBreakpointPerToolMessage(message: Message): Message { + if (message.role !== "tool" || !Array.isArray(message.content) || message.content.length < 2) return message; + const anthropic = message.providerOptions?.anthropic; + const breakpoint = anthropic?.cacheControl ?? anthropic?.cache_control; + if (!anthropic || breakpoint === undefined) return message; + const { cacheControl: _camel, cache_control: _snake, ...rest } = anthropic; + const parts = message.content as Part[]; + const last = parts.length - 1; + return { + ...message, + providerOptions: { ...message.providerOptions, anthropic: rest }, + content: parts.map((part, index) => + index === last + ? { ...part, providerOptions: { ...part.providerOptions, anthropic: { ...part.providerOptions?.anthropic, cacheControl: breakpoint } } } + : part, + ), + }; +} + +/** The same model, with every call's prompt passed through oneBreakpointPerToolMessage. */ +export function withAnthropicCacheLimit<M extends object>(model: M): M { + const fix = (options: CallOptions): CallOptions => ({ ...options, prompt: options.prompt.map(oneBreakpointPerToolMessage) }); + return new Proxy(model, { + get(target, property) { + const value: unknown = Reflect.get(target, property); + if ((property === "doGenerate" || property === "doStream") && typeof value === "function") { + return (options: CallOptions) => value.call(target, fix(options)); + } + return typeof value === "function" ? value.bind(target) : value; + }, + }); +} diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 4ab5568cc..842c984da 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -1,6 +1,6 @@ -# DebateLab mobile app +# DebateOwl mobile app -The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Expo Router. The plan is [plans/2026-10-08-native-ios-app.md](../../plans/2026-10-08-native-ios-app.md). Read the root [AGENTS.md](../../AGENTS.md) first; this file adds only what is specific to the app. +The iOS app DebateOwl (`@debatelab/mobile`; the bundle id is still `com.adam.debatelab`): Expo SDK 57, React Native 0.86, React 19, Expo Router. The plan is [plans/2026-10-08-native-ios-app.md](../../plans/2026-10-08-native-ios-app.md). Read the root [AGENTS.md](../../AGENTS.md) first; this file adds only what is specific to the app. ## Rules @@ -26,19 +26,20 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp - Generate and Attack mirror `generateCase` and `attackCase` in `src/lib/ai/client.ts` and the session store's `recordResult`: `results.ts` sends the requests through `ai-request.ts`, checks the output with the website's transform (moved to `packages/core/src/ai/generate-output.ts`), keeps researched sources in the debate's Auto-saved folder (`packages/core/src/ai/discovered-sources.ts`), saves the server's source rankings onto the Library sources a result used (`packages/core/src/ai/source-assessments.ts`, also for Ask's whole answers), says when the server shortened the input, and saves rows to `cases` and `attacks`. `motion.ts` and `motion-gate.ts` are the website's motion gate. `open-results.ts` holds results made in this session, so one-off work and failed saves still open as documents. It and the last Generate and Attack forms live in `session-memory.ts`, which the auth provider clears on every account change, including sign-out. `components/document.tsx` draws results as documents (mock 03); Brainstorm and Factoids reuse it. Export renders `export-html.ts` to a PDF with expo-print and shares it (`export.ts`); the web preview cannot print, so `export.web.ts` keeps the HTML and copied text on `window` for the screenshot script. Word export and Google Docs are not in the app. - Brainstorm and Factoids mirror `brainstorm` and `factoids` in `src/lib/ai/client.ts` and their pages, through the same `results.ts`: the brainstorm transform and the verified-factoid selection live in `packages/core/src/ai/brainstorm-output.ts` and `factoids-output.ts`, rows go to `brainstorms` and `factoids`. Brainstorm has the motion gate; Factoids, like Attack, follows the Current Debate and saves with side `neutral`. A factoid's bookmark saves it to the Current Debate's `Factoids` folder, and a search suggestion reopens the Factoids form with that query. `prepareToolHref` in `components/prepare-tools.ts` is where both tabs open a tool. - Timer mirrors `src/pages/TimerPage.tsx`: `timer.ts` holds the formats and a clock made of start timestamps, never counted ticks, so it is right after the app returns from the background (`timer.test.ts`). `timer-signals.ts` plays the bundled beeps in `assets/sounds` (made by `scripts/render-timer-sounds.ts`) with expo-audio, taps with expo-haptics and keeps the screen awake while a speech runs; `timer-signals.web.ts` only records the signals for the screenshot script. Lock-screen timers wait for the development build (`TODO(live-activity)`). -- Crossfire is the website's typed mode: `crossfire.ts` sends `crossfire` and its signal requests as `src/lib/ai/client.ts` does (Ask's model settings, 75 s and 5 s deadlines) and saves a finished round to `crossfires` in its debate, as CrossfirePage's `recordResult` call; the reducer and schemas are `packages/core/src/crossfire`. The unfinished round is a session slot, so leaving the screen keeps it. Voice rounds need the development build, so the voice switch shows disabled. The website cannot reopen a saved round, so neither can the app. +- Crossfire is the website's typed mode: `crossfire.ts` sends `crossfire` and its signal requests as `src/lib/ai/client.ts` does (Ask's model settings, 75 s and 5 s deadlines), with the round's id as `quotaSession` on turns and the debrief so the round is charged once, as the website does and saves a finished round to `crossfires` in its debate, as CrossfirePage's `recordResult` call; the reducer and schemas are `packages/core/src/crossfire`. The unfinished round is a session slot, so leaving the screen keeps it. Voice rounds need the development build, so the voice switch shows disabled. The website cannot reopen a saved round, so neither can the app. - Sparring is the website's typed mode: `sparring.ts` sends the opponent's turns and the judge as `generateSparringTurn` and `judgeSparringSession` in `src/lib/ai/client.ts` do (the Sparring model settings, a transcript-only debate context), and the round's moves (turn order, the deadline clock, judging, saved rows) are `packages/core/src/sparring/session.ts`, shared with the website's sparring store. The server saves the judged round to `sparring_sessions` when it scores it; the app then files it in its debate with `attach_to_debate` and the student's side, as the store does. The side is asked when the round starts. The unfinished round is a session slot (`sparringDraft`), so leaving the screen keeps it, and a request cut off by leaving is offered again. The flow button opens the Flowchart on the Current Debate's map, above the round, which keeps running. Practice lists the Current Debate's judged rounds and the Debate tab lists them by side; both open `sparring/[id]`, which can delete them. Voice rounds need the development build, so the voice switch shows disabled. - Flowchart mirrors the website's `FlowchartPage.tsx` and the session store's `saveFlowchart` and `deleteSavedFlowchart` without React Flow: the saved `flowcharts` row keeps the website's React Flow node and edge JSON, and `packages/core/src/flowchart.ts` reads and edits it (starter map, edge kinds from the label or stroke the website writes, role columns, the tree, and edits that keep every field they do not touch). `flowcharts.ts` reads and writes the rows; Save over an opened map updates it, Save as inserts a row as the website's Save does. `flowchart-drafts.ts` holds each debate's open map and unsaved edits in session memory, shared by the screen and its sheets. Maps follow `activeDebateId`: one-off maps are saved with no debate, as on the website. The website's PNG export is a PDF of the Map tree here (`flowchartExportHtml`). - History mirrors `src/pages/HistoryPage.tsx` and `UnsortedWork.tsx`: `history.ts` reads each table for one debate, or every kind plus Library sources outside all debates, and adds unsorted work to a debate through the website's `attach_to_debate` function. Chats reopen in Ask (`/ask?chat=`). A Sparring round opens `sparring/[id]` and swipes to delete as a case does (`deleteSparringRound`); an unsorted round is added to a debate with the side the student spoke, through `attachToDebate`, which also checks the signed-in account. - Announcements mirror the website's banner (`AppLayout.tsx`, `AnnouncementBanner.tsx`) at the top of the Debate tab: `announcements.ts` lists and dismisses through the `announcements` Edge Function, and `use-announcements.ts` keeps the website's startsAt/expiresAt filter (shared in `packages/core/src/announcements.ts`), its one-minute reload guard, and reloads on focus, foreground and account change. Dismissals are stored on the server, so they are shared with the website. -- You mirrors the website's Settings for what the app can do: Download my data (`exportAccountData` in `account.ts`, the `account` function's export written as `debatelab-data-<date>.json` and shared through `shareJson`), Delete all my data (`delete-data.tsx`, the email-typed confirmation and `clear_current_user_data`; afterwards session memory is forgotten, one-off mode turns off and the debates reload, as the website clears its stores), and Terms and Privacy, opened in the in-app browser from `app.config.ts`'s `extra.websiteUrl` (`website.ts`). Upgrade, Manage subscription and promo codes wait for in-app purchase. +- You mirrors the website's Settings for what the app can do: Download my data (`exportAccountData` in `account.ts`, the `account` function's export written as `debatelab-data-<date>.json` and shared through `shareJson`), Delete all my data (`delete-data.tsx`, the email-typed confirmation and `clear_current_user_data`; afterwards session memory is forgotten, one-off mode turns off and the debates reload, as the website clears its stores), and Terms and Privacy, opened in the in-app browser from `app.config.ts`'s `extra.websiteUrl` (`website.ts`). The plan card shows the website's billing states, dates and wording (`SubscriptionStatus.tsx`): `subscription.ts` reads `user_settings`, Stripe `subscriptions` and `app_store_subscriptions` through the shared reader (`packages/core/src/supabase/billing.ts`, via `billing-query.ts`) and resolves them with the resolver the AI functions use (`supabase/functions/_shared/billing-state.ts`); usage is the server's own counter (`ai_usage_today`). Upgrade, Manage subscription and promo codes wait for in-app purchase. - One-off mode is session-only and never written, as on the website; only maps saved in it are kept, with no debate, as the website keeps them. The switcher turns it on with or without a Current Debate, and `OneOffNotice` shows it on the Debate tab, Prepare, Ask, Library and the Generate, Attack, Brainstorm, Factoids, Crossfire and Sparring forms (through `WorkingIn`) and the Flowchart. The Debate tab, Prepare and Library still list the Current Debate's work in one-off mode, as the website's home does. Students prepare both sides of a motion, so a debate has no side: the Debate tab's In favor / Against toggle only chooses which side's work it shows, using the website's rule (`'both'` counts for either side, neutral and side-less work for neither). It starts on In favor and is never stored. - Offline reading: `offline-cache.ts` keeps a copy of every read in `debates.ts`, `sources.ts`, `results.ts`, `flowcharts.ts`, `chats.ts`, `sparring.ts` and `history.ts` (the debates list, counts and recent work, Library sources with their text, saved results, maps, chats, judged Sparring rounds and History's lists) in AsyncStorage under the user id. A read goes to the network first and saves its answer; when no answer comes back (supabase-js status 0: no connection, DNS, timeout), or `network-status.ts` (expo-network) says the device is offline, the copy answers and `OfflineNotice` shows at the top of `Screen`, `DocumentScreen` (including its iPad outline rail), the Flowchart and the iPad Library list until each copy shown is read from the server again or the screen showing it closes, moves on or leaves view (a screen passes `useCopyHolder().mark()` with its reads). A server error (an expired session, a policy, a 5xx) is thrown, never answered from a copy; a read made of several queries runs them through `readAll` (or `pickFailure`), so one query's refusal wins over another's missing answer. A slower answer never replaces a newer copy, each group keeps its most recently fetched copies only (`LIMITS`), and the auth provider calls `setCacheOwner` before it adopts a session, so another account's copies are removed before its first read. Nothing is written offline: AI and saving fail with their network errors, and there is no write queue. A read added to those modules goes through `cachedRead`, takes an optional `CopyMark`, and throws `readFailed(error, status)`. Every write that succeeds calls `afterWrite` with the copies it changed (`debateListScopes` names a debate's counts and lists; a delete uses `deletedListScopes`, which drops every debate's when no row came back), which drops them and stops older reads from saving over them, or `updateCopy` when it knows the new value. `debateListScopes` also names History's copy (`one-off` for work outside every debate); History's add-to-debate and filing a judged Sparring round into its debate both drop the item's copies on both sides, and Delete all my data drops every copy (`ALL_COPIES`). - Push: `push.ts` asks for permission and files the Expo push token under the signed-in user with the `register_push_token` RPC; the You tab's Notifications switch turns it on and off, the root layout registers again on launch and when the language changes, and sign-out releases the registration with the account's own access token, after any registration still in flight. Every registration carries the ID of the local record, and `release_push_token` withdraws that ID and deletes only the rows it wrote, so a registration the app gave up on is refused if it reaches the database after the release. Once a release is sent for an ID, the record is marked released and the next registration gets a new ID, so a late release cannot delete it. The local record is cleared only once a release succeeds; otherwise the next change on the phone, from any account, releases it first. The server side is in [supabase/README.md](../../supabase/README.md#push-notifications). `push.web.ts` registers nothing and only flips the switch for the screenshot script. - No build can get a token until `app.config.ts` has `extra.eas.projectId`, from `eas init` on build day (see [docs/development.md](../../docs/development.md#build-day)). Until then the switch shows as unavailable, on Expo Go too. Expo Go on Android has no remote push since SDK 53, and Expo recommends a development build for remote push on iOS as well. - Prebuild applies the `expo-notifications` config plugin automatically once the package is installed, even though `app.config.ts` does not list it, and the plugin sets the `aps-environment` entitlement. `DEBATELAB_BUILD=dev` removes it again (`withoutPushEntitlement`), because the borrowed Signulous profile has no push, so a development build cannot get a token even with a project ID: turning the switch on there fails. Real push needs a provisioning profile with the Push Notifications capability and an APNs key uploaded to EAS, both from Adam's own Apple Developer account; only then can `withoutPushEntitlement` go. - iPad layouts (iPad mock A, with mock C's docked claim) come from one hook, `useLayoutClass()` in `src/lib/layout.ts`, which also holds every width they use: `phone` on every iPhone and in iPad windows under 700 points, `regular` from 700, `wide` from 1100. Phone layouts must not change, so each screen keeps its phone JSX and branches before it; compare iPhone screenshots before and after. NativeTabs is `sidebarAdaptable` (the iPadOS 18 sidebar; `app-tabs.web.tsx` draws a sidebar or a top tab bar for the preview). The Library opens a source beside its list (`SourceView`, shared with the source sheet), Ask lists its conversations beside the chat (`ask-chat-list.tsx`), the Debate tab is a two-column dashboard, saved documents get an "On this page" rail from 900 points (`DocumentScreen`'s `outline`, `components/outline.tsx`), and the Flowchart docks a card's claim beside the map (`ClaimPanel` in `claim-panel.tsx`, shared with the claim sheet). A docked claim or a source open beside its list is dropped when that map, debate, account or row is no longer the one on screen. All are plain views sized from the window, so they run in Expo Go. -- `src/theme/`: colours from the mocks and the website's dark palette, Inter fonts. +- `src/theme/`: the website's DebateOwl design tokens (`src/styles/tokens.css` and the roles in `src/index.css`) in light and dark. `primary` is the blue that passes as text; `primaryFill` with `primaryLedge` is the button face and the ledge it sits on. Success is mint and always comes with a check mark. Fredoka (`weight="display"`) is for titles, the motion and button labels, Nunito for everything else, loaded with expo-font from `@expo-google-fonts`. Buttons are the website's tactile ones (`components/buttons.tsx`), which sink onto their ledge while pressed. Cards and fields have 2pt edges. +- `components/owl.tsx`: Ollie, from PNGs of the website's owl SVGs (`src/assets/owl`, `public/owl-mark.svg`), in the pose the website uses for each place: `point` for an empty start, `coach` beside Ask's answers, `arguing` for the Sparring opponent, `reading`, `surprised` and `sleepy` in empty states (`components/empty-state.tsx`), and a tool's pose while it works (`ToolProgress`). `scripts/render-owls.ts` renders them, the app icon, the splash image and the Android icons again after the website's owl changes. - `scripts/screenshots.ts`: web-export screenshots. `scripts/render-tab-icons.ts` redraws the tab bar PNGs. ## Checks diff --git a/apps/mobile/README.md b/apps/mobile/README.md index a7e66d176..dffa1857a 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -1,6 +1,6 @@ -# DebateLab mobile +# DebateOwl mobile -The DebateLab iOS app, built with Expo and Expo Router. It shares `@debatelab/core` and the translation catalogs with the website and talks to the staging Supabase project by default. +The DebateOwl iOS app, built with Expo and Expo Router. It shares `@debatelab/core` and the translation catalogs with the website and talks to the staging Supabase project by default. Agent and contributor rules are in [AGENTS.md](AGENTS.md). Running it on an iPhone with Expo Go is described in [docs/development.md](../../docs/development.md#mobile-app). diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 2e2acb2c4..14320679f 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -22,6 +22,7 @@ if (appGroup && build !== 'dev') { throw new Error('DEBATELAB_APP_GROUP only applies to a development build (DEBATELAB_BUILD=dev).'); } +// Kept from the DebateLab name until Adam decides on the store listing: a new id is a new app in App Store Connect. const bundleIdentifier = 'com.adam.debatelab'; function permissionStrings(projectRoot: string, language: 'en' | 'es' | 'hu'): PermissionStrings { @@ -53,8 +54,8 @@ const withoutPushEntitlement: ConfigPlugin = (config) => export default ({ projectRoot }: ConfigContext): ExpoConfig => { const config: ExpoConfig = { - name: 'DebateLab', - slug: 'debatelab', + name: 'DebateOwl', + slug: 'debateowl', version: '1.0.0', orientation: 'default', icon: './assets/images/icon.png', @@ -69,7 +70,7 @@ export default ({ projectRoot }: ConfigContext): ExpoConfig => { }, android: { adaptiveIcon: { - backgroundColor: '#E6F4FE', + backgroundColor: '#DDF0FF', foregroundImage: './assets/images/android-icon-foreground.png', backgroundImage: './assets/images/android-icon-background.png', monochromeImage: './assets/images/android-icon-monochrome.png', @@ -85,15 +86,15 @@ export default ({ projectRoot }: ConfigContext): ExpoConfig => { [ 'expo-splash-screen', { - backgroundColor: '#eef4fa', + backgroundColor: '#F7FAFE', image: './assets/images/splash-icon.png', - imageWidth: 76, - dark: { backgroundColor: '#0f172a' }, + imageWidth: 150, + dark: { backgroundColor: '#0B1426' }, }, ], ], extra: { - websiteUrl: 'https://debate.pi-stack.org', + websiteUrl: 'https://debateowl.com', }, experiments: { typedRoutes: true, diff --git a/apps/mobile/assets/images/android-icon-background.png b/apps/mobile/assets/images/android-icon-background.png index 5ffefc5bb..96c7e89b3 100644 Binary files a/apps/mobile/assets/images/android-icon-background.png and b/apps/mobile/assets/images/android-icon-background.png differ diff --git a/apps/mobile/assets/images/android-icon-foreground.png b/apps/mobile/assets/images/android-icon-foreground.png index 3a9e5016d..f7e8ff13a 100644 Binary files a/apps/mobile/assets/images/android-icon-foreground.png and b/apps/mobile/assets/images/android-icon-foreground.png differ diff --git a/apps/mobile/assets/images/android-icon-monochrome.png b/apps/mobile/assets/images/android-icon-monochrome.png index 77484ebdb..f129ed0c5 100644 Binary files a/apps/mobile/assets/images/android-icon-monochrome.png and b/apps/mobile/assets/images/android-icon-monochrome.png differ diff --git a/apps/mobile/assets/images/favicon.png b/apps/mobile/assets/images/favicon.png index 408bd7466..d99265611 100644 Binary files a/apps/mobile/assets/images/favicon.png and b/apps/mobile/assets/images/favicon.png differ diff --git a/apps/mobile/assets/images/icon.png b/apps/mobile/assets/images/icon.png index 67c777a45..3af3b17aa 100644 Binary files a/apps/mobile/assets/images/icon.png and b/apps/mobile/assets/images/icon.png differ diff --git a/apps/mobile/assets/images/splash-icon.png b/apps/mobile/assets/images/splash-icon.png index 6b1642a0b..a71c6e31e 100644 Binary files a/apps/mobile/assets/images/splash-icon.png and b/apps/mobile/assets/images/splash-icon.png differ diff --git a/apps/mobile/assets/owl/arguing.png b/apps/mobile/assets/owl/arguing.png new file mode 100644 index 000000000..7e14630d3 Binary files /dev/null and b/apps/mobile/assets/owl/arguing.png differ diff --git a/apps/mobile/assets/owl/celebrating.png b/apps/mobile/assets/owl/celebrating.png new file mode 100644 index 000000000..f0984e1e3 Binary files /dev/null and b/apps/mobile/assets/owl/celebrating.png differ diff --git a/apps/mobile/assets/owl/coach.png b/apps/mobile/assets/owl/coach.png new file mode 100644 index 000000000..64c4e9438 Binary files /dev/null and b/apps/mobile/assets/owl/coach.png differ diff --git a/apps/mobile/assets/owl/idea.png b/apps/mobile/assets/owl/idea.png new file mode 100644 index 000000000..5157a528a Binary files /dev/null and b/apps/mobile/assets/owl/idea.png differ diff --git a/apps/mobile/assets/owl/mark.png b/apps/mobile/assets/owl/mark.png new file mode 100644 index 000000000..45c05a01d Binary files /dev/null and b/apps/mobile/assets/owl/mark.png differ diff --git a/apps/mobile/assets/owl/point.png b/apps/mobile/assets/owl/point.png new file mode 100644 index 000000000..5ca5ae027 Binary files /dev/null and b/apps/mobile/assets/owl/point.png differ diff --git a/apps/mobile/assets/owl/reading.png b/apps/mobile/assets/owl/reading.png new file mode 100644 index 000000000..3ffafcd69 Binary files /dev/null and b/apps/mobile/assets/owl/reading.png differ diff --git a/apps/mobile/assets/owl/skeptical.png b/apps/mobile/assets/owl/skeptical.png new file mode 100644 index 000000000..16b1d0e02 Binary files /dev/null and b/apps/mobile/assets/owl/skeptical.png differ diff --git a/apps/mobile/assets/owl/sleepy.png b/apps/mobile/assets/owl/sleepy.png new file mode 100644 index 000000000..88e584ad0 Binary files /dev/null and b/apps/mobile/assets/owl/sleepy.png differ diff --git a/apps/mobile/assets/owl/surprised.png b/apps/mobile/assets/owl/surprised.png new file mode 100644 index 000000000..d75c8e7ac Binary files /dev/null and b/apps/mobile/assets/owl/surprised.png differ diff --git a/apps/mobile/assets/owl/thinking.png b/apps/mobile/assets/owl/thinking.png new file mode 100644 index 000000000..68a3855c2 Binary files /dev/null and b/apps/mobile/assets/owl/thinking.png differ diff --git a/apps/mobile/assets/owl/timekeeper.png b/apps/mobile/assets/owl/timekeeper.png new file mode 100644 index 000000000..1ac9b22f0 Binary files /dev/null and b/apps/mobile/assets/owl/timekeeper.png differ diff --git a/apps/mobile/package.json b/apps/mobile/package.json index a9f05c738..12ee988ef 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -10,7 +10,8 @@ }, "dependencies": { "@debatelab/core": "*", - "@expo-google-fonts/inter": "^0.4.2", + "@expo-google-fonts/fredoka": "^0.4.1", + "@expo-google-fonts/nunito": "^0.4.2", "@formatjs/intl-pluralrules": "^6.3.15", "@react-native-async-storage/async-storage": "2.2.0", "@supabase/supabase-js": "^2.49.1", @@ -26,7 +27,6 @@ "expo-font": "~57.0.4", "expo-haptics": "~57.0.3", "expo-keep-awake": "~57.0.2", - "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.12", "expo-localization": "~57.0.2", "expo-network": "~57.0.2", diff --git a/apps/mobile/scripts/render-owls.ts b/apps/mobile/scripts/render-owls.ts new file mode 100644 index 000000000..b97086084 --- /dev/null +++ b/apps/mobile/scripts/render-owls.ts @@ -0,0 +1,59 @@ +/** + * Renders the website's owl (Ollie) to PNGs for the app: every pose in + * src/assets/owl and the owl mark in public/owl-mark.svg, on transparent + * 512 px squares, plus the app icon, splash image and web favicon. React Native + * cannot draw the hand-traced SVGs reliably, so the app ships these. Run from + * the repository root after the website's owl changes: + * + * npx tsx apps/mobile/scripts/render-owls.ts + */ +import { chromium } from '@playwright/test'; +import { copyFile, mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +const root = join(__dirname, '../../..'); +const poseDir = join(root, 'src/assets/owl'); +const owlDir = join(__dirname, '../assets/owl'); +const imageDir = join(__dirname, '../assets/images'); +const SIZE = 512; + +async function render(page: import('@playwright/test').Page, svg: string, size: number, background = 'transparent', inset = 0, filter = 'none') { + await page.setViewportSize({ width: size, height: size }); + const inner = size - inset * 2; + await page.setContent( + `<body style="margin:0;background:${background}"><div style="width:${size}px;height:${size}px;display:flex;align-items:center;justify-content:center">` + + `<img style="width:${inner}px;height:${inner}px;filter:${filter}" src="data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}"></div></body>`, + ); + await page.locator('img').evaluate((img: HTMLImageElement) => img.decode()); + return page.screenshot({ omitBackground: background === 'transparent' }); +} + +async function main() { + const browser = await chromium.launch(); + try { + await mkdir(owlDir, { recursive: true }); + const page = await browser.newPage(); + for (const file of (await readdir(poseDir)).filter((name) => name.startsWith('owl-') && name.endsWith('.svg') && name !== 'owl-favicon.svg')) { + const svg = await readFile(join(poseDir, file), 'utf8'); + await writeFile(join(owlDir, file.replace(/^owl-/, '').replace(/\.svg$/, '.png')), await render(page, svg, SIZE)); + } + const mark = await readFile(join(root, 'public/owl-mark.svg'), 'utf8'); + await writeFile(join(owlDir, 'mark.png'), await render(page, mark, SIZE)); + // The splash shows the whole owl, pointing, on the splash background colour (app.config.ts). + const point = await readFile(join(poseDir, 'owl-point.svg'), 'utf8'); + await writeFile(join(imageDir, 'splash-icon.png'), await render(page, point, 1024)); + // Android's adaptive icon: the mark's owl without its tile, on the tile's colour. + const owlOnly = mark.replace('<rect width="1024" height="1024" fill="#DDF0FF"/>', ''); + if (owlOnly === mark) throw new Error('The owl mark no longer has the tile rect this script removes'); + await writeFile(join(imageDir, 'android-icon-foreground.png'), await render(page, owlOnly, 1024, 'transparent', 160)); + await writeFile(join(imageDir, 'android-icon-background.png'), await render(page, '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>', 1024, '#DDF0FF')); + await writeFile(join(imageDir, 'android-icon-monochrome.png'), await render(page, owlOnly, 1024, 'transparent', 160, 'brightness(0)')); + // The App Store icon is the website's: the same owl on its own background, square and opaque. + await copyFile(join(root, 'public/icon-1024.png'), join(imageDir, 'icon.png')); + await copyFile(join(root, 'public/favicon.png'), join(imageDir, 'favicon.png')); + } finally { + await browser.close(); + } +} + +void main(); diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index 710c74c73..5b19459ed 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -1,6 +1,7 @@ /** * Screenshots of the web export at iPhone and iPad sizes, light and dark, - * English and Hungarian, into .verify/mobile-shots/ at the repository root. + * English, Hungarian and Spanish, into .verify/mobile-shots/ at the repository root. + * Every shot also lists the texts it cuts off, in clipped.json. * The web export is close to the native app but not the same: it has no * native tab bar, large titles, sheets or gestures. * @@ -15,8 +16,9 @@ * account, model. Sign-in screens are always shot. * ONLY=offline runs only the offline reading check (see shootOfflineScreens). * - * The iPad sizes are portrait (1024x1366), landscape (1366x1024) and a 600-point - * window (Split View or Slide Over), which takes the phone layout. + * The iPad sizes are portrait (1024x1366), landscape (1366x1024), an 11-inch + * iPad Air (820x1180 and 1180x820) and a 600-point window (Split View or Slide + * Over), which takes the phone layout. * * Signed-in screens need a staging session. With STAGING_DEMO_CODE set, the * script signs in once through the real sign-in screen and also checks that a @@ -90,7 +92,8 @@ import { SUPABASE_PROJECTS } from '../../../src/lib/supabase/projects'; type StorageState = Exclude<BrowserContextOptions['storageState'], string | undefined>; const DIST = join(__dirname, '../dist'); -const OUT = join(__dirname, '../../../.verify/mobile-shots'); +// SHOTS_DIR moves the screenshots elsewhere, e.g. for a before/after set. +const OUT = process.env.SHOTS_DIR || join(__dirname, '../../../.verify/mobile-shots'); const PORT = Number(process.env.PORT); const DEMO_CODE = process.env.STAGING_DEMO_CODE?.trim(); const ONLY = (process.env.ONLY ?? '').split(',').map((value) => value.trim()).filter((value) => value.length > 0); @@ -106,11 +109,15 @@ const DEVICES = { ipad: { viewport: { width: 1024, height: 1366 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }, 'ipad-landscape': { viewport: { width: 1366, height: 1024 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }, 'ipad-narrow': { viewport: { width: 600, height: 1366 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }, + // An 11-inch iPad Air: portrait takes the regular layout, landscape the wide one. + 'ipad-air': { viewport: { width: 820, height: 1180 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }, + 'ipad-air-landscape': { viewport: { width: 1180, height: 820 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }, } as const; const SCHEMES = ['light', 'dark'] as const; -const LOCALES = { en: 'en-GB', hu: 'hu-HU' } as const; +// Spanish runs use the English fake model text; only the interface is Spanish. +const LOCALES = { en: 'en-GB', hu: 'hu-HU', es: 'es-ES' } as const; const SIGNED_IN_ROUTES = { prepare: '/prepare', practice: '/practice', you: '/you' }; -const NOTIFICATIONS = { en: 'Notifications', hu: 'Értesítések' } as const; +const NOTIFICATIONS = { en: 'Notifications', hu: 'Értesítések', es: 'Notificaciones' } as const; const REST = `https://${STAGING_HOST}/rest/v1`; const TYPES: Record<string, string> = { @@ -148,17 +155,52 @@ function serve(): Promise<Server> { * While `offline()` is true, the browser's own "Failed to load resource" errors are expected. */ function watchErrors(page: Page, problems: string[], label: string, offline = () => false) { + // A project without the App Store migration answers the plan's App Store + // read with a 404 (PGRST205), which the plan reader takes as no App Store + // rows. The browser logs each one; only those are expected. + let appStoreMisses = 0; + page.on('response', (response) => { + if (response.status() === 404 && response.url().includes('/rest/v1/app_store_subscriptions')) appStoreMisses += 1; + }); page.on('pageerror', (error) => problems.push(`${label}: ${error.message}`)); page.on('console', (message) => { if (message.type() !== 'error') return; if (offline() && message.text().startsWith('Failed to load resource')) return; + if (appStoreMisses > 0 && message.text().includes('status of 404')) { + appStoreMisses -= 1; + return; + } problems.push(`${label}: ${message.text()}`); }); } +/** Texts cut off in each shot (see clippedTexts), written to clipped.json at the end of the run. */ +const clipped: Record<string, string[]> = {}; + +/** + * React Native Web draws a truncated <Text> with an ellipsis or a line clamp; + * this lists every such text that does not fit, so a run reports clipping + * instead of leaving it to the eye. Some truncation is by design (a long title + * in a one-line row); the list says which. + */ +async function clippedTexts(page: Page): Promise<string[]> { + return page.evaluate(() => { + const cut: string[] = []; + for (const element of Array.from(document.querySelectorAll<HTMLElement>('div, span'))) { + const style = getComputedStyle(element); + const ellipsis = style.textOverflow === 'ellipsis' && element.scrollWidth > element.clientWidth + 1; + const clamped = style.webkitLineClamp !== 'none' && style.webkitLineClamp !== '' && element.scrollHeight > element.clientHeight + 1; + if ((ellipsis || clamped) && element.offsetParent !== null && element.textContent?.trim()) cut.push(element.textContent.trim().slice(0, 80)); + } + return Array.from(new Set(cut)); + }); +} + async function shoot(page: Page, name: string) { // Let fonts and the colour scheme settle after hydration. await page.evaluate(() => document.fonts.ready); + const cut = await clippedTexts(page); + if (cut.length > 0) clipped[name] = cut; // The Delete all my data field shows the email as its placeholder, as on the website. await page.screenshot({ path: join(OUT, `${name}.png`), mask: [page.getByTestId('account-email'), page.getByTestId('delete-data-confirmation')] }); } @@ -441,7 +483,7 @@ async function shootResultScreens(context: BrowserContext, prefix: string, langu await waiting.getByTestId('generate-motion').waitFor({ timeout: 30_000 }); await waiting.waitForFunction(() => (document.querySelector('[data-testid="generate-motion"]') as HTMLTextAreaElement | null)?.value.trim()); await shoot(waiting, `${prefix}-generate-form`); - await waiting.getByRole('button', { name: /^(Generate Debate Case|Vitaeset Generálása|Generar Caso de Debate)$/ }).click(); + await waiting.getByRole('button', { name: /^(Generate debate case|Vitaeset generálása|Generar caso de debate)$/i }).click(); await waiting.getByTestId('tool-progress').waitFor({ timeout: 15_000 }); await shoot(waiting, `${prefix}-generating`); await waiting.close(); @@ -450,14 +492,14 @@ async function shootResultScreens(context: BrowserContext, prefix: string, langu await page.goto(`${ORIGIN}/generate?from=prepare`); await page.getByTestId('generate-motion').waitFor({ timeout: 30_000 }); await page.waitForFunction(() => (document.querySelector('[data-testid="generate-motion"]') as HTMLTextAreaElement | null)?.value.trim()); - await page.getByRole('button', { name: /^(Generate Debate Case|Vitaeset Generálása|Generar Caso de Debate)$/ }).click(); + await page.getByRole('button', { name: /^(Generate debate case|Vitaeset generálása|Generar caso de debate)$/i }).click(); await page.getByTestId('case-document').waitFor({ timeout: 30_000 }); await page.waitForTimeout(400); await shoot(page, `${prefix}-case`); // A wide iPad shows the outline rail: jumping to a part scrolls that part's heading to the top of the case. const outline = page.getByTestId('document-outline'); if (await outline.isVisible()) { - const part = /^(Case Structure|Eset Struktúra|Estructura del Caso)$/; + const part = /^(Case structure|Esetstruktúra|Estructura del caso)$/i; const heading = page.getByTestId('case-document').getByRole('heading', { name: part }); const before = (await heading.boundingBox())?.y ?? 0; await outline.getByRole('menuitem').filter({ hasText: part }).click(); @@ -491,7 +533,7 @@ async function shootResultScreens(context: BrowserContext, prefix: string, langu await page.getByTestId('attack-case-text').waitFor({ timeout: 15_000 }); await page.waitForFunction(() => (document.querySelector('[data-testid="attack-case-text"]') as HTMLTextAreaElement | null)?.value.startsWith('THESIS:')); await shoot(page, `${prefix}-attack-form`); - await page.getByRole('button', { name: /^(Find Weaknesses|Gyengeségek Keresése|Encontrar Debilidades)$/ }).click(); + await page.getByRole('button', { name: /^(Find weaknesses|Gyengeségek keresése|Encontrar debilidades)$/i }).click(); await page.getByTestId('attack-document').waitFor({ timeout: 30_000 }); await page.waitForTimeout(400); await shoot(page, `${prefix}-attack-result`); @@ -1698,6 +1740,8 @@ async function main() { } console.log(`${shots} screenshots in ${OUT}`); + await writeFile(join(OUT, 'clipped.json'), JSON.stringify(clipped, null, 2)); + console.log(`${Object.keys(clipped).length} shots with clipped text (clipped.json)`); if (problems.length > 0) { console.error(problems.join('\n')); process.exitCode = 1; diff --git a/apps/mobile/src/app/(tabs)/(debate)/index.tsx b/apps/mobile/src/app/(tabs)/(debate)/index.tsx index 1192a8f32..5200728e8 100644 --- a/apps/mobile/src/app/(tabs)/(debate)/index.tsx +++ b/apps/mobile/src/app/(tabs)/(debate)/index.tsx @@ -1,4 +1,3 @@ -import { LinearGradient } from 'expo-linear-gradient'; import { useFocusEffect, useRouter } from 'expo-router'; import { useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -29,8 +28,9 @@ import { HOME_COLUMNS_WIDTH, useLayoutClass } from '@/lib/layout'; import { fetchSources, type Source } from '@/lib/sources'; import { useAnnouncements } from '@/lib/use-announcements'; import { useCopyHolder } from '@/lib/use-copy-holder'; -import { mainActionGradient, radius } from '@/theme/colors'; +import { radius } from '@/theme/colors'; import { useTheme } from '@/theme/use-theme'; +import { Owl, OwlAvatar } from '@/components/owl'; const RECENT_LIMIT = 3; /** The iPad dashboard lists more work, and the newest sources beside it (iPad mock A). */ @@ -87,8 +87,10 @@ export default function DebateScreen() { <AnnouncementBanner announcements={announcements} /> <SwitcherPill onPress={openSwitcher} /> <OneOffNotice /> + {/* Mirrored, so the owl points across at Start debate, as on the website's home. */} + <Owl pose="point" size={112} mirrored style={styles.emptyOwl} /> <View style={styles.emptyText}> - <Text accessibilityRole="header" weight="bold" style={styles.emptyTitle}> + <Text accessibilityRole="header" weight="display" style={styles.emptyTitle}> {t('debate.noCurrentTitle')} </Text> <Text tone="secondary" style={styles.body}> @@ -106,7 +108,7 @@ export default function DebateScreen() { <AnnouncementBanner announcements={announcements} style={styles.banner} /> <SwitcherPill onPress={openSwitcher} /> <OneOffNotice style={styles.notice} /> - <Text accessibilityRole="header" weight="bold" style={[styles.motion, styles.motionWide]} testID="current-debate-motion"> + <Text accessibilityRole="header" weight="display" style={[styles.motion, styles.motionWide]} testID="current-debate-motion"> {current.motion} </Text> <DebateDetails key={current.id} debateId={current.id} dashboard /> @@ -119,7 +121,7 @@ export default function DebateScreen() { <AnnouncementBanner announcements={announcements} style={styles.banner} /> <SwitcherPill onPress={openSwitcher} /> <OneOffNotice style={styles.notice} /> - <Text accessibilityRole="header" weight="bold" style={styles.motion} testID="current-debate-motion"> + <Text accessibilityRole="header" weight="display" style={styles.motion} testID="current-debate-motion"> {current.motion} </Text> <DebateDetails key={current.id} debateId={current.id} /> @@ -227,7 +229,7 @@ function DebateDetails({ debateId, dashboard = false }: { debateId: string; dash const spar = <SparCard onPress={() => router.push({ pathname: '/sparring', params: { from: 'debate' } })} />; const prepare = ( <> - <Text accessibilityRole="header" weight="bold" style={styles.section}> + <Text accessibilityRole="header" weight="bold" tone="heading" style={styles.section}> {t('mobile.tabs.prepare')} </Text> <View style={styles.grid}> @@ -246,7 +248,7 @@ function DebateDetails({ debateId, dashboard = false }: { debateId: string; dash const pickUp = ( <> <View style={[styles.sectionRow, twoColumns && styles.columnTop]}> - <Text accessibilityRole="header" weight="bold" style={[styles.section, styles.sectionTitle]}> + <Text accessibilityRole="header" weight="bold" tone="heading" style={[styles.section, styles.sectionTitle]}> {t('mobile.debate.pickUp')} </Text> <TextButton @@ -317,7 +319,7 @@ function DebateDetails({ debateId, dashboard = false }: { debateId: string; dash {pickUp} {failed ? null : ( <> - <Text accessibilityRole="header" weight="bold" style={styles.section}> + <Text accessibilityRole="header" weight="bold" tone="heading" style={styles.section}> {t('mobile.debate.recentSources')} </Text> {recentSources} @@ -340,7 +342,7 @@ function RecentSource({ source, first, onPress }: { source: Source; first: boole style={({ pressed }) => [styles.sourceRow, !first && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> <IconTile name={SOURCE_ICONS[source.type]} size={42} /> <View style={styles.sourceText}> - <Text weight="semibold" style={styles.sourceTitle} numberOfLines={1}> + <Text weight="semibold" style={styles.sourceTitle} numberOfLines={2}> {source.title || t('libraryPage.untitledSource')} </Text> <Text tone="faint" style={styles.sourceMeta} numberOfLines={1}> @@ -365,19 +367,22 @@ function Stat({ value, label }: { value: number | undefined; label: string }) { ); } +// The website's primary look, a card-sized tactile button: a blue face on its ledge, which it sinks onto while pressed. function SparCard({ onPress }: { onPress: () => void }) { const { t } = useTranslation(); + const { colors } = useTheme(); return ( <Pressable accessibilityRole="button" onPress={onPress} - style={({ pressed }) => [styles.spar, pressed && styles.pressed]}> - <LinearGradient {...mainActionGradient} style={StyleSheet.absoluteFill} /> - <View style={styles.sparIcon}> - <Icon name="swords" color="#ffffff" size={24} /> - </View> + style={({ pressed }) => [ + styles.spar, + { backgroundColor: colors.primaryFill, borderBottomColor: colors.primaryLedge }, + pressed && styles.sparPressed, + ]}> + <OwlAvatar pose="arguing" size={52} /> <View style={styles.sparText}> - <Text weight="bold" style={styles.sparTitle}> + <Text weight="display" style={styles.sparTitle}> {t('mobile.debate.spar')} </Text> <Text style={styles.sparLine}>{t('sparring.format.standard')}</Text> @@ -430,29 +435,24 @@ const styles = StyleSheet.create({ notice: { marginTop: 12 }, emptyText: { gap: 8 }, emptyTitle: { fontSize: 28, lineHeight: 34, letterSpacing: -0.6 }, + emptyOwl: { alignSelf: 'flex-start', marginTop: 8, marginBottom: -6 }, motion: { fontSize: 27, lineHeight: 32, letterSpacing: -0.6, marginTop: 12, marginBottom: 15 }, stats: { flexDirection: 'row', gap: 24, marginTop: 13, marginBottom: 15, marginHorizontal: 2 }, statValue: { fontSize: 21, lineHeight: 26, letterSpacing: -0.2 }, statLabel: { fontSize: 15 }, spar: { - borderRadius: radius.card + 2, - overflow: 'hidden', + borderRadius: radius.card, + borderBottomWidth: 5, flexDirection: 'row', alignItems: 'center', gap: 14, - paddingVertical: 17, - paddingHorizontal: 17, - }, - sparIcon: { - width: 46, - height: 46, - borderRadius: 13, - backgroundColor: 'rgba(255,255,255,0.2)', - alignItems: 'center', - justifyContent: 'center', + paddingVertical: 16, + paddingHorizontal: 16, }, + // Sinks onto its ledge, as the website's buttons do. + sparPressed: { borderBottomWidth: 0, marginTop: 5 }, sparText: { flex: 1, gap: 2 }, - sparTitle: { color: '#ffffff', fontSize: 18, lineHeight: 23 }, + sparTitle: { color: '#ffffff', fontSize: 19, lineHeight: 24 }, sparLine: { color: 'rgba(255,255,255,0.88)', fontSize: 14.5, lineHeight: 20 }, banner: { marginBottom: 14 }, section: { fontSize: 18.5, lineHeight: 24, letterSpacing: -0.2, marginTop: 20, marginBottom: 9, marginHorizontal: 2 }, @@ -465,7 +465,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', gap: 10, - borderWidth: 1, + borderWidth: 2, borderRadius: radius.card, paddingVertical: 12, paddingHorizontal: 10, diff --git a/apps/mobile/src/app/(tabs)/(library)/library.tsx b/apps/mobile/src/app/(tabs)/(library)/library.tsx index 71783e6d7..f92f66ea8 100644 --- a/apps/mobile/src/app/(tabs)/(library)/library.tsx +++ b/apps/mobile/src/app/(tabs)/(library)/library.tsx @@ -24,6 +24,7 @@ import { openRowId } from '@/lib/open-pane'; import { fetchSources, removeSource, subscribeSourcesChanged, type Source, type SourceType } from '@/lib/sources'; import { useCopyHolder } from '@/lib/use-copy-holder'; import { useTheme } from '@/theme/use-theme'; +import { EmptyState } from '@/components/empty-state'; type Filter = 'all' | SourceType; @@ -91,14 +92,17 @@ function LibrarySplit({ wide, onAdd }: { wide: boolean; onAdd: (kind: AddSourceK const clearSelected = useCallback((id: string) => { setSelected((previous) => (previous?.id === id ? null : previous)); }, []); - const listWidth = width - (wide ? SIDEBAR_WIDTH : 0) >= LIBRARY_ROOMY_WIDTH ? LIST_WIDTH.libraryRoomy : LIST_WIDTH.library; + const available = width - (wide ? SIDEBAR_WIDTH : 0); + // With nothing open the list takes the whole width, as the website's Library does; an open + // source splits it, and the list keeps 40% (at least its usual width) so titles stay readable. + const listWidth = Math.max(available >= LIBRARY_ROOMY_WIDTH ? LIST_WIDTH.libraryRoomy : LIST_WIDTH.library, Math.round(available * 0.4)); return ( <View style={styles.split}> <ScrollView - style={[styles.listPane, { width: listWidth, borderRightColor: colors.line }]} + style={shown ? [styles.listPane, { width: listWidth, borderRightColor: colors.line }] : styles.listPaneFull} contentInsetAdjustmentBehavior="automatic" - contentContainerStyle={styles.listContent}> + contentContainerStyle={[styles.listContent, !shown && styles.listContentFull]}> <OfflineNotice /> <LibraryHeader title={t('mobile.tabs.library')} onAdd={onAdd} disabled={!debateId} /> <OneOffNotice message="debate.oneOffActive" /> @@ -121,18 +125,11 @@ function LibrarySplit({ wide, onAdd }: { wide: boolean; onAdd: (kind: AddSourceK /> ) : null} </ScrollView> - <ScrollView style={styles.detailPane} contentInsetAdjustmentBehavior="automatic" contentContainerStyle={styles.detailContent} testID={shown ? 'source-sheet' : 'source-empty'}> - {shown && userId ? ( + {shown && userId ? ( + <ScrollView style={styles.detailPane} contentInsetAdjustmentBehavior="automatic" contentContainerStyle={styles.detailContent} testID="source-sheet"> <SourceView key={`${userId}:${debateId}:${shown}`} id={shown} /> - ) : ( - <View style={styles.pick}> - <Icon name="library" color={colors.tabInactive} size={30} /> - <Text tone="faint" style={styles.body}> - {t('mobile.library.pickSource')} - </Text> - </View> - )} - </ScrollView> + </ScrollView> + ) : null} </View> ); } @@ -202,8 +199,8 @@ function SourceList({ const remove = async (source: Source) => { if (!userId) return false; const confirmed = await confirmDestructive({ - title: t('libraryPage.deleteSourceTitle'), - message: t('libraryPage.deleteSourceDesc'), + title: t('mobile.library.deleteSourceTitle'), + message: t('mobile.library.deleteSourceDesc'), confirm: t('common.delete'), cancel: t('common.cancel'), }); @@ -255,23 +252,9 @@ function SourceList({ ) : null} {sources.length === 0 ? ( - <Card> - <Text weight="semibold" style={styles.emptyTitle}> - {t('libraryPage.emptyTitle')} - </Text> - <Text tone="secondary" style={styles.body}> - {t('libraryPage.emptyDesc')} - </Text> - </Card> + <EmptyState pose="reading" title={t('libraryPage.emptyTitle')} description={t('libraryPage.emptyDesc')} /> ) : visible.length === 0 ? ( - <Card> - <Text weight="semibold" style={styles.emptyTitle}> - {t('libraryPage.noMatchingSources')} - </Text> - <Text tone="secondary" style={styles.body}> - {t('libraryPage.noMatchingSourcesDesc')} - </Text> - </Card> + <EmptyState pose="surprised" title={t('libraryPage.noMatchingSources')} description={t('libraryPage.noMatchingSourcesDesc')} /> ) : ( <Card style={styles.list}> {visible.map((source, index) => ( @@ -344,10 +327,10 @@ function SourceRow({ style={({ pressed }) => [styles.row, { backgroundColor: selected ? colors.tile : colors.card }, pressed && styles.pressed]}> <IconTile name={SOURCE_ICONS[source.type]} size={42} /> <View style={styles.rowText}> - <Text weight="semibold" style={[styles.rowTitle, selected && { color: colors.chipSelectedForeground }]} numberOfLines={1}> + <Text weight="semibold" style={[styles.rowTitle, selected && { color: colors.chipSelectedForeground }]} numberOfLines={2}> {source.title || t('libraryPage.untitledSource')} </Text> - <Text tone="faint" style={styles.rowMeta} numberOfLines={1}> + <Text tone="faint" style={styles.rowMeta} numberOfLines={2}> {sourceMeta(t, source)} </Text> </View> @@ -359,7 +342,6 @@ function SourceRow({ const styles = StyleSheet.create({ loading: { marginTop: 32 }, body: { fontSize: 15, lineHeight: 21 }, - emptyTitle: { fontSize: 17 }, chipRow: { marginHorizontal: -20, flexGrow: 0 }, chips: { gap: 8, paddingHorizontal: 20 }, list: { padding: 0, gap: 0, overflow: 'hidden' }, @@ -372,8 +354,9 @@ const styles = StyleSheet.create({ deleteLabel: { color: '#ffffff', fontSize: 13 }, split: { flex: 1, flexDirection: 'row' }, listPane: { flexGrow: 0, flexShrink: 0, borderRightWidth: StyleSheet.hairlineWidth }, + listPaneFull: { flex: 1 }, + listContentFull: { width: '100%', maxWidth: 880, alignSelf: 'center' }, listContent: { paddingHorizontal: 20, paddingBottom: 32, gap: 16 }, detailPane: { flex: 1 }, detailContent: { width: '100%', maxWidth: 700, alignSelf: 'center', paddingHorizontal: 36, paddingTop: 14, paddingBottom: 48, gap: 14 }, - pick: { alignItems: 'center', gap: 10, paddingTop: 120 }, }); diff --git a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx index b57cbfdac..3d0d8b6ce 100644 --- a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx +++ b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx @@ -104,7 +104,7 @@ export default function PracticeScreen() { {/* The list appears once it has loaded, so the tools never wait behind a spinner. */} {items === null && !failed ? null : ( <> - <Text accessibilityRole="header" weight="bold" style={styles.section}> + <Text accessibilityRole="header" weight="bold" tone="heading" style={styles.section}> {t('historyPage.sparringTitle')} </Text> {items === null ? ( diff --git a/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx b/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx index 398100fe4..0bb7d8b92 100644 --- a/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx +++ b/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx @@ -94,7 +94,7 @@ export default function PrepareScreen() { ))} </Card> - <Text accessibilityRole="header" weight="bold" style={styles.section}> + <Text accessibilityRole="header" weight="bold" tone="heading" style={styles.section}> {t('mobile.prepare.saved')} </Text> {/* The Current Debate's results stay listed in one-off mode, as on the Debate tab and the website's home. */} diff --git a/apps/mobile/src/app/(tabs)/(you)/you.tsx b/apps/mobile/src/app/(tabs)/(you)/you.tsx index c06218e05..f64d3e61a 100644 --- a/apps/mobile/src/app/(tabs)/(you)/you.tsx +++ b/apps/mobile/src/app/(tabs)/(you)/you.tsx @@ -1,6 +1,8 @@ +import type { BillingDateKind, BillingStateName } from '@debatelab/core/contract/billing-state'; import { APP_LANGUAGES, type AppLanguage } from '@debatelab/core/languages'; import { useFocusEffect, useRouter } from 'expo-router'; import * as WebBrowser from 'expo-web-browser'; +import type { TFunction } from 'i18next'; import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityIndicator, AppState, Linking, Pressable, StyleSheet, View } from 'react-native'; @@ -22,19 +24,51 @@ import { shareJson } from '@/lib/export'; import { disablePush, enablePush, readPushState, type PushState } from '@/lib/push'; import { createPushSwitch, type PushSwitchView } from '@/lib/push-switch'; import { useSettings, type ThemePreference } from '@/lib/settings'; -import { fetchSubscription, type Subscription, type SubscriptionTier } from '@/lib/subscription'; +import { fetchSubscription, type Subscription } from '@/lib/subscription'; import { supabaseEnv } from '@/lib/supabase-config'; import { websitePage } from '@/lib/website'; import { useTheme } from '@/theme/use-theme'; -const TIER_LABELS: Record<SubscriptionTier, string> = { - trial: 'settings.tierTrial', - basic: 'settings.tierBasic', - pro: 'settings.tierPro', - legacy_free: 'settings.tierLegacyFree', - expired: 'settings.tierExpired', +type BadgeTone = 'plan' | 'pro' | 'good' | 'bad'; + +/** The website's plan badges (SubscriptionStatus.tsx): the state's label in its tint; mint ones carry a check mark. */ +const STATE_BADGES: Record<BillingStateName, { labelKey: string; tone: BadgeTone }> = { + trial: { labelKey: 'settings.tierTrial', tone: 'plan' }, + extended_access: { labelKey: 'settings.tierExtended', tone: 'good' }, + pro: { labelKey: 'settings.tierPro', tone: 'pro' }, + pro_canceling: { labelKey: 'settings.tierPro', tone: 'pro' }, + basic: { labelKey: 'settings.tierBasic', tone: 'plan' }, + payment_failed: { labelKey: 'settings.tierPaymentFailed', tone: 'bad' }, + legacy_free: { labelKey: 'settings.tierLegacyFree', tone: 'good' }, + trial_ended: { labelKey: 'settings.tierExpired', tone: 'bad' }, + subscription_ended: { labelKey: 'settings.tierExpired', tone: 'bad' }, +}; + +const DATE_LABELS: Partial<Record<BillingDateKind, string>> = { + ends: 'settings.endsOn', + renews: 'settings.renewsOn', + cancels: 'settings.cancelsOn', }; +/** What the state means, in the website's words. */ +function planDescription(t: TFunction, subscription: Subscription): string { + const date = subscription.billingDate ? formatDate(subscription.billingDate) : null; + switch (subscription.billingState) { + case 'extended_access': + return t('settings.extendedAccessDesc', { count: subscription.dailyLimit }); + case 'payment_failed': + return subscription.billingDateKind === 'grace_ends' && date + ? `${t('settings.paymentFailedDesc')} ${t('settings.paymentGraceUntil', { date })}` + : t('settings.paymentFailedNoAccess'); + case 'subscription_ended': + return date ? t('settings.subscriptionEndedOn', { date }) : t('settings.subscriptionEnded'); + case 'trial_ended': + return t('settings.accessExpired'); + default: + return t('settings.accessActive'); + } +} + const LANGUAGE_OPTIONS = (Object.keys(APP_LANGUAGES) as AppLanguage[]).map((value) => ({ value, label: APP_LANGUAGES[value].label, @@ -110,7 +144,7 @@ export default function YouScreen() { </Pressable> </Card> - <Text accessibilityRole="header" weight="bold" style={styles.section}> + <Text accessibilityRole="header" weight="bold" tone="heading" style={styles.section}> {t('settings.globalSettings')} </Text> <Card> @@ -148,7 +182,7 @@ export default function YouScreen() { <NotificationsCard userId={session?.user.id} /> - <Text accessibilityRole="header" weight="bold" style={styles.section}> + <Text accessibilityRole="header" weight="bold" tone="heading" style={styles.section}> {t('settings.account')} </Text> <Card style={styles.list}> @@ -190,7 +224,7 @@ export default function YouScreen() { </Pressable> </Card> - <Text accessibilityRole="header" weight="bold" style={styles.section}> + <Text accessibilityRole="header" weight="bold" tone="heading" style={styles.section}> {t('settings.dangerZone')} </Text> <Card style={styles.list}> @@ -320,6 +354,30 @@ function NotificationsCard({ userId }: { userId: string | undefined }) { ); } +function PlanBadge({ state }: { state: BillingStateName }) { + const { t } = useTranslation(); + const { colors, scheme } = useTheme(); + const dark = scheme === 'dark'; + const { labelKey, tone } = STATE_BADGES[state]; + // The website's outline badges: the state's colour at 15% behind, 30% as the edge, and a darker text. + const look = { + plan: { tint: '30,141,251', color: dark ? '#7CC0FE' : '#0342A8' }, + pro: { tint: '243,154,0', color: dark ? '#FFD95A' : '#7A4405' }, + good: { tint: dark ? '47,211,154' : '20,179,125', color: colors.success }, + bad: { tint: dark ? '255,107,112' : '210,50,57', color: colors.destructive }, + }[tone]; + const background = `rgba(${look.tint},${tone === 'bad' ? 0.1 : 0.15})`; + const border = `rgba(${look.tint},0.3)`; + return ( + <View style={[styles.badge, { backgroundColor: background, borderColor: border }]}> + {tone === 'good' ? <Icon name="check" color={look.color} size={13} /> : null} + <Text weight="bold" style={[styles.badgeText, { color: look.color }]} testID="subscription-tier"> + {t(labelKey)} + </Text> + </View> + ); +} + function SubscriptionCard({ userId }: { userId: string | undefined }) { const { t } = useTranslation(); const { colors } = useTheme(); @@ -358,16 +416,7 @@ function SubscriptionCard({ userId }: { userId: string | undefined }) { <Text weight="semibold" style={styles.planTitle}> {t('settings.subscription')} </Text> - {subscription ? ( - <View style={[styles.badge, { backgroundColor: subscription.tier === 'expired' ? colors.segmentTrack : colors.tile }]}> - <Text - weight="semibold" - style={[styles.badgeText, { color: subscription.tier === 'expired' ? colors.secondary : colors.tileForeground }]} - testID="subscription-tier"> - {t(TIER_LABELS[subscription.tier])} - </Text> - </View> - ) : null} + {subscription ? <PlanBadge state={subscription.billingState} /> : null} </View> {failed && !subscription ? ( <> @@ -385,16 +434,18 @@ function SubscriptionCard({ userId }: { userId: string | undefined }) { </View> ) : ( <View style={styles.planBody}> - {subscription.tier === 'trial' && subscription.trialEndsAt ? ( - <Text tone="secondary" style={styles.small}> - {t('settings.ends')} {formatDate(subscription.trialEndsAt)} + {subscription.billingDate && subscription.billingDateKind && DATE_LABELS[subscription.billingDateKind] ? ( + <Text tone="secondary" style={styles.small} testID="billing-date"> + {t(DATE_LABELS[subscription.billingDateKind]!, { date: formatDate(subscription.billingDate) })} + </Text> + ) : null} + {subscription.dailyUsed !== null ? ( + <Text weight="medium" style={styles.usage}> + {t('mobile.you.usage', { used: subscription.dailyUsed, limit: subscription.dailyLimit })} </Text> ) : null} - <Text weight="medium" style={styles.usage}> - {t('mobile.you.usage', { used: subscription.dailyUsed, limit: subscription.dailyLimit })} - </Text> <Text tone="secondary" style={styles.small}> - {t(subscription.tier === 'expired' ? 'settings.accessExpired' : 'settings.accessActive')} + {planDescription(t, subscription)} </Text> </View> )} @@ -410,7 +461,7 @@ const styles = StyleSheet.create({ planBody: { gap: 4 }, usage: { fontSize: 16 }, loadingRow: { flexDirection: 'row', alignItems: 'center', gap: 10 }, - badge: { alignSelf: 'flex-start', borderRadius: 999, paddingHorizontal: 10, paddingVertical: 4 }, + badge: { alignSelf: 'flex-start', flexDirection: 'row', alignItems: 'center', gap: 4, borderRadius: 999, borderWidth: 1, paddingHorizontal: 10, paddingVertical: 3 }, badgeText: { fontSize: 13 }, setting: { gap: 7 }, settingLabel: { fontSize: 14, marginLeft: 2 }, diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 1bdca3eb4..d6d0dbe62 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -14,7 +14,7 @@ import { DebateProvider } from '@/lib/debate-context'; import { watchNetwork } from '@/lib/network-status'; import { refreshPush } from '@/lib/push'; import { SettingsProvider, useSettings } from '@/lib/settings'; -import { interFonts } from '@/theme/fonts'; +import { appFonts } from '@/theme/fonts'; import { useTheme } from '@/theme/use-theme'; void SplashScreen.preventAutoHideAsync(); @@ -32,7 +32,7 @@ export default function RootLayout() { } function Root() { - const [fontsLoaded, fontError] = useFonts(interFonts); + const [fontsLoaded, fontError] = useFonts(appFonts); const { session, loading, settled } = useAuth(); const { ready: settingsReady, language } = useSettings(); const { scheme, colors } = useTheme(); diff --git a/apps/mobile/src/app/add-link.tsx b/apps/mobile/src/app/add-link.tsx index 6ea07b105..b566fbfcd 100644 --- a/apps/mobile/src/app/add-link.tsx +++ b/apps/mobile/src/app/add-link.tsx @@ -21,7 +21,7 @@ import { useTheme } from '@/theme/use-theme'; * Import from URL does. */ export default function AddLinkSheet() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const router = useRouter(); const { colors } = useTheme(); const { session } = useAuth(); @@ -76,7 +76,7 @@ export default function AddLinkSheet() { return ( <SheetScrollView contentContainerStyle={styles.content} testID="add-link-sheet"> <View style={styles.header}> - <Text accessibilityRole="header" weight="bold" style={styles.title}> + <Text accessibilityRole="header" weight="display" style={styles.title}> {t('mobile.library.pasteLink')} </Text> <TextButton label={t('common.cancel')} onPress={() => router.back()} disabled={busy === 'save'} /> @@ -115,7 +115,7 @@ export default function AddLinkSheet() { <Card> <Field label={t('libraryPage.titleLabel')} value={title} onChangeText={setTitle} editable={busy === null} /> <Text tone="faint" style={styles.meta}> - {`${draft.domain} · ${draft.wordCount} ${t('libraryPage.words')}`} + {`${draft.domain} · ${t('libraryPage.wordCount', { count: draft.wordCount, formatted: draft.wordCount.toLocaleString(i18n.language) })}`} </Text> {draft.excerpt ? ( <Text tone="secondary" style={styles.body} numberOfLines={5}> diff --git a/apps/mobile/src/app/ask.tsx b/apps/mobile/src/app/ask.tsx index 79d3e3c47..e8dd27630 100644 --- a/apps/mobile/src/app/ask.tsx +++ b/apps/mobile/src/app/ask.tsx @@ -11,6 +11,7 @@ import { AskHistory } from '@/components/ask-history'; import { TextButton } from '@/components/buttons'; import { Chip } from '@/components/chip'; import { ModelChips } from '@/components/model-picker'; +import { FadingScrollRow } from '@/components/edge-fade'; import { Icon } from '@/components/icon'; import type { IconName } from '@/components/icon-paths'; import { OneOffNotice } from '@/components/one-off-notice'; @@ -30,6 +31,7 @@ import { useCopyHolder } from '@/lib/use-copy-holder'; import { radius } from '@/theme/colors'; import { fontFamily } from '@/theme/fonts'; import { useTheme } from '@/theme/use-theme'; +import { Owl, OwlAvatar } from '@/components/owl'; const NO_OPTIONS: AskOptions = { citationMode: false, webSearch: false, prosConsMode: false, argumentMapMode: false }; const NEW_CHAT: ChatRecord = { chatId: null, debateId: null, persisted: 0 }; @@ -42,7 +44,8 @@ const OPTION_CHIPS: { key: keyof AskOptions; label: string; icon: IconName }[] = { key: 'argumentMapMode', label: 'askChat.modeArgMap', icon: 'map' }, ]; -const THINKING = ['askChat.thinking', 'askChat.researching', 'askChat.analyzing', 'askChat.generatingResponse', 'askChat.almostThere']; +// The website's one progress line while the coach answers. +const THINKING = ['askChat.working']; const PLAN_ERRORS = new Set<AIErrorKind>(['trialExpired', 'limitReached', 'modelRestricted']); const RETRYABLE_ERRORS = new Set<AIErrorKind>(['network', 'timeout', 'failed', 'rateLimit']); @@ -243,13 +246,15 @@ export default function AskScreen() { }); } catch (streamError) { if (isAbortError(streamError) || !isCurrent()) throw streamError; - if (received) { + // Whitespace alone is not an answer (main #187): a model that spent its cap on + // reasoning may send a newline first. Ask the structured endpoint then. + if (received.trim()) { setNotice(`${t('ask.streamInterrupted')} ${t('ask.streamInterruptedDesc')}`); result = { response: received.trim(), followUpQuestions: [], uncertainties: [t('ask.streamInterrupted')] }; } else if (isFinalAskError(streamError)) { throw streamError; } else { - // Nothing arrived: ask once more without streaming, as the website does. + // Nothing usable arrived: ask once more without streaming, as the website does. setStreaming(null); result = await askOnce(askRequest, controller.signal, keepExtras); } @@ -360,7 +365,7 @@ export default function AskScreen() { </Pressable> )} <View style={styles.headerText}> - <Text accessibilityRole="header" weight="bold" style={styles.headerTitle} numberOfLines={split ? 1 : undefined}> + <Text accessibilityRole="header" weight="display" style={styles.headerTitle} numberOfLines={split ? 1 : undefined}> {title} </Text> {subtitle ? ( @@ -395,7 +400,8 @@ export default function AskScreen() { <OneOffNotice /> {messages.length === 0 && streaming === null && !busy ? ( <View style={styles.empty}> - <Text weight="semibold" style={styles.emptyTitle}> + <Owl pose="point" size={112} /> + <Text weight="display" style={styles.emptyTitle}> {t('askChat.startConversation')} </Text> <Text tone="secondary" style={styles.emptyBody}> @@ -406,7 +412,7 @@ export default function AskScreen() { {messages.map((message) => message.role === 'user' ? ( - <View key={message.id} style={[styles.bubble, { backgroundColor: colors.primary }]}> + <View key={message.id} style={[styles.bubble, { backgroundColor: colors.primaryFill }]}> <Text style={[styles.bubbleText, { color: colors.onPrimary }]} selectable> {message.content} </Text> @@ -444,7 +450,9 @@ export default function AskScreen() { <View style={[styles.composer, { paddingBottom: Math.max(insets.bottom, 12), borderTopColor: colors.line, backgroundColor: colors.tabBar }]}> <View style={styles.composerColumn}> - <ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.chips} style={styles.chipRow} keyboardShouldPersistTaps="handled"> + {split ? ( + // iPad: every option shows, wrapping onto a second line when the column is narrow. + <View style={styles.chipsWrap}> {OPTION_CHIPS.map((chip) => ( <Chip key={chip.key} @@ -457,7 +465,24 @@ export default function AskScreen() { ))} {/* The next question reads the model again, after this change is saved. */} <ModelChips feature="ask" onChange={() => (config.current = null)} /> - </ScrollView> + </View> + ) : ( + // iPhone: one sideways row, faded at the edge to show it scrolls. + <FadingScrollRow color={colors.background} id="ask-chips-fade" style={styles.chipRow} contentContainerStyle={styles.chips} keyboardShouldPersistTaps="handled"> + {OPTION_CHIPS.map((chip) => ( + <Chip + key={chip.key} + label={t(chip.label)} + icon={chip.icon} + selected={options[chip.key]} + accessibilityRole="switch" + onPress={() => setOptions((previous) => ({ ...previous, [chip.key]: !previous[chip.key] }))} + /> + ))} + {/* The next question reads the model again, after this change is saved. */} + <ModelChips feature="ask" onChange={() => (config.current = null)} /> + </FadingScrollRow> + )} <View style={[styles.inputRow, { borderColor: colors.border, backgroundColor: colors.input }]}> <TextInput value={input} @@ -477,7 +502,7 @@ export default function AskScreen() { accessibilityState={{ disabled: busy || !input.trim() }} disabled={busy || !input.trim()} onPress={() => void send(input, messages)} - style={[styles.send, { backgroundColor: colors.primary }, (busy || !input.trim()) && styles.sendDisabled]} + style={[styles.send, { backgroundColor: colors.primaryFill }, (busy || !input.trim()) && styles.sendDisabled]} testID="ask-send"> {busy ? <ActivityIndicator color={colors.onPrimary} size="small" /> : <Icon name="arrowUp" color={colors.onPrimary} size={19} />} </Pressable> @@ -536,7 +561,7 @@ function Thinking() { }, []); return ( <View style={styles.thinking} accessibilityRole="progressbar" accessibilityLiveRegion="polite"> - <ActivityIndicator size="small" /> + <OwlAvatar pose="coach" size={32} /> <Text tone="secondary" style={styles.thinkingText}> {t(THINKING[index])} </Text> @@ -555,8 +580,8 @@ const styles = StyleSheet.create({ headerSubtitle: { fontSize: 13.5, lineHeight: 18 }, messages: { flex: 1 }, messagesContent: { paddingHorizontal: 20, paddingTop: 18, paddingBottom: 24, gap: 16, width: '100%', maxWidth: 720, alignSelf: 'center' }, - empty: { alignItems: 'center', gap: 6, paddingTop: 48, paddingHorizontal: 12 }, - emptyTitle: { fontSize: 18 }, + empty: { alignItems: 'center', gap: 6, paddingTop: 24, paddingHorizontal: 12 }, + emptyTitle: { fontSize: 20 }, emptyBody: { fontSize: 15.5, lineHeight: 22, textAlign: 'center' }, bubble: { alignSelf: 'flex-end', maxWidth: '80%', paddingHorizontal: 16, paddingVertical: 11, borderTopLeftRadius: 20, borderTopRightRadius: 20, borderBottomLeftRadius: 20, borderBottomRightRadius: 6 }, bubbleText: { fontSize: 17, lineHeight: 24 }, @@ -564,19 +589,20 @@ const styles = StyleSheet.create({ thinking: { flexDirection: 'row', alignItems: 'center', gap: 10 }, thinkingText: { fontSize: 15 }, notice: { fontSize: 14, lineHeight: 19 }, - error: { borderWidth: 1, borderRadius: radius.card, padding: 14, gap: 4 }, + error: { borderWidth: 2, borderRadius: radius.card, padding: 14, gap: 4 }, errorTitle: { fontSize: 16 }, errorBody: { fontSize: 15, lineHeight: 21 }, errorActions: { flexDirection: 'row', gap: 20 }, composer: { borderTopWidth: StyleSheet.hairlineWidth, paddingTop: 10 }, // The same centred column as the messages, so the composer lines up on iPad. composerColumn: { width: '100%', maxWidth: 720, alignSelf: 'center', paddingHorizontal: 16, gap: 10 }, - chipRow: { flexGrow: 0, marginHorizontal: -16 }, + chipRow: { marginHorizontal: -16 }, + chipsWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, chips: { gap: 8, paddingHorizontal: 16 }, inputRow: { flexDirection: 'row', alignItems: 'center', - borderWidth: 1, + borderWidth: 2, borderRadius: 24, paddingLeft: 16, paddingRight: 6, diff --git a/apps/mobile/src/app/attack/index.tsx b/apps/mobile/src/app/attack/index.tsx index 0658c071a..c07b7b1b0 100644 --- a/apps/mobile/src/app/attack/index.tsx +++ b/apps/mobile/src/app/attack/index.tsx @@ -35,7 +35,8 @@ const DEFAULT_FORM: AttackInput = { }; const lastForm = sessionSlot<AttackInput>(() => DEFAULT_FORM); -const PROGRESS = ['attackForm.loadingMsg1', 'attackForm.loadingMsg2', 'attackForm.loadingMsg3', 'attackForm.loadingMsg4', 'attackForm.loadingMsg5']; +// The website's one progress line while the tool runs. +const PROGRESS = ['attack.working']; /** * Attack, as the website's AttackPage. Opened from a case, the case's text @@ -168,10 +169,11 @@ export default function AttackScreen() { testID="attack-screen" header={<DocumentHeader backLabel={backLabel(t, caseKey ? 'case' : from, 'practice')} actions={[]} />} actions={attacking ? undefined : <MainActionButton label={t('attackForm.submit')} onPress={() => void submit()} />}> - <ToolTitle title={t('nav.attack')} subtitle={t('attackForm.subtitle')} /> + <ToolTitle title={t('nav.attack')} subtitle={t('attack.emptyDesc')} /> <WorkingIn /> {attacking ? ( <ToolProgress + pose="skeptical" messages={PROGRESS.map((key) => t(key))} onCancel={() => { abortActive(); diff --git a/apps/mobile/src/app/brainstorm/index.tsx b/apps/mobile/src/app/brainstorm/index.tsx index 3b67cc18a..f416a6f97 100644 --- a/apps/mobile/src/app/brainstorm/index.tsx +++ b/apps/mobile/src/app/brainstorm/index.tsx @@ -31,7 +31,8 @@ const DEFAULT_FORM: BrainstormInput = { motion: '', side: 'both', context: '', o // The last form stays for the session, as on the website; session memory forgets it when the account changes. const lastForm = sessionSlot<BrainstormInput>(() => DEFAULT_FORM); -const PROGRESS = ['brainstormForm.loadingMsg1', 'brainstormForm.loadingMsg2', 'brainstormForm.loadingMsg3', 'brainstormForm.loadingMsg4', 'brainstormForm.loadingMsg5']; +// The website's one progress line while the tool runs. +const PROGRESS = ['brainstorm.working']; /** Brainstorm, as the website's BrainstormPage: a native form, a calm wait, then the ideas as a document. */ export default function BrainstormScreen() { @@ -202,10 +203,10 @@ export default function BrainstormScreen() { testID="brainstorm-screen" header={<DocumentHeader backLabel={backLabel(t, from, 'prepare')} actions={[]} />} actions={working ? undefined : <MainActionButton label={t('brainstormForm.submit')} onPress={() => void submit()} />}> - <ToolTitle title={t('nav.brainstorm')} subtitle={t('brainstormForm.subtitle')} /> + <ToolTitle title={t('nav.brainstorm')} subtitle={t('brainstorm.emptyDesc')} /> <WorkingIn /> {working ? ( - <ToolProgress messages={PROGRESS.map((key) => t(key))} detail={form.motion} onCancel={cancel} /> + <ToolProgress pose="idea" messages={PROGRESS.map((key) => t(key))} detail={form.motion} onCancel={cancel} /> ) : ( <> <Field diff --git a/apps/mobile/src/app/case/[id].tsx b/apps/mobile/src/app/case/[id].tsx index 1d7ecf878..805e979b3 100644 --- a/apps/mobile/src/app/case/[id].tsx +++ b/apps/mobile/src/app/case/[id].tsx @@ -74,6 +74,8 @@ export default function CaseScreen() { outline notice={doc.notice} header={<DocumentHeader backLabel={back} onShare={() => run(() => shareText(title, caseToText(output)))} actions={actions} />} + // "Attack this case" is the longer label, so it gets the wider button. + actionWidths={[1.3, 1]} actions={ <> <SecondaryButton diff --git a/apps/mobile/src/app/crossfire.tsx b/apps/mobile/src/app/crossfire.tsx index ccb38b211..38947dccd 100644 --- a/apps/mobile/src/app/crossfire.tsx +++ b/apps/mobile/src/app/crossfire.tsx @@ -217,7 +217,7 @@ export default function CrossfireScreen() { if (signal.aborted) throw abortError(); const turn = await crossfireTurn( { operation: 'next-turn', config: snapshot.config, transcript: snapshot.turns, expectedReply }, - { config, language: outputLanguage, debateContext: crossfireContext(context, snapshot.config), signal }, + { config, language: outputLanguage, debateContext: crossfireContext(context, snapshot.config), signal, quotaSession: snapshot.sessionId }, ); return { config, ...turn }; }); @@ -256,7 +256,7 @@ export default function CrossfireScreen() { if (signal.aborted) throw abortError(); return crossfireDebrief( { operation: 'debrief', config: snapshot.config, transcript: snapshot.turns }, - { config, language: outputLanguage, debateContext: crossfireContext(context, snapshot.config), signal }, + { config, language: outputLanguage, debateContext: crossfireContext(context, snapshot.config), signal, quotaSession: snapshot.sessionId }, ); }); if (controller.signal.aborted) return; @@ -653,7 +653,7 @@ export default function CrossfireScreen() { accessibilityState={{ disabled: !canSend }} disabled={!canSend} onPress={submitTurn} - style={[styles.send, { backgroundColor: colors.primary }, !canSend && styles.sendDisabled]} + style={[styles.send, { backgroundColor: colors.primaryFill }, !canSend && styles.sendDisabled]} testID="crossfire-send"> <Icon name="arrowUp" color={colors.onPrimary} size={19} /> </Pressable> @@ -688,18 +688,18 @@ const styles = StyleSheet.create({ notice: { fontSize: 14, lineHeight: 19 }, thinking: { flexDirection: 'row', alignItems: 'center', gap: 10 }, thinkingText: { fontSize: 15 }, - error: { borderWidth: 1, borderRadius: radius.card, padding: 14, gap: 4 }, + error: { borderWidth: 2, borderRadius: radius.card, padding: 14, gap: 4 }, errorTitle: { fontSize: 16, textAlign: 'center' }, errorBody: { fontSize: 15, lineHeight: 21 }, errorActions: { flexDirection: 'row', gap: 20 }, - debriefWait: { borderWidth: 1, borderStyle: 'dashed', borderRadius: radius.card + 2, paddingVertical: 30, paddingHorizontal: 20, alignItems: 'center', gap: 10 }, + debriefWait: { borderWidth: 2, borderStyle: 'dashed', borderRadius: radius.card + 2, paddingVertical: 30, paddingHorizontal: 20, alignItems: 'center', gap: 10 }, waitBody: { fontSize: 15, lineHeight: 21, textAlign: 'center' }, composer: { borderTopWidth: StyleSheet.hairlineWidth, paddingTop: 10 }, composerColumn: { width: '100%', maxWidth: 720, alignSelf: 'center', paddingHorizontal: 16, gap: 6 }, composerLabelRow: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, marginHorizontal: 6 }, composerLabel: { fontSize: 13.5 }, composerCount: { fontSize: 12.5 }, - inputRow: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 24, paddingLeft: 16, paddingRight: 6, paddingVertical: 6, minHeight: 48 }, + inputRow: { flexDirection: 'row', alignItems: 'center', borderWidth: 2, borderRadius: 24, paddingLeft: 16, paddingRight: 6, paddingVertical: 6, minHeight: 48 }, input: { flex: 1, fontSize: 17, fontFamily: fontFamily.regular, paddingTop: 7, paddingBottom: 7, maxHeight: 140 }, send: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' }, sendDisabled: { opacity: 0.45 }, diff --git a/apps/mobile/src/app/debates.tsx b/apps/mobile/src/app/debates.tsx index 1dad451a9..3bd11fc62 100644 --- a/apps/mobile/src/app/debates.tsx +++ b/apps/mobile/src/app/debates.tsx @@ -5,6 +5,7 @@ import { ActivityIndicator, Pressable, StyleSheet, Switch, TextInput, View } fro import { PrimaryButton, TextButton } from '@/components/buttons'; import { Card } from '@/components/card'; +import { WEB_SWITCH_THUMB } from '@/components/form-rows'; import { Icon } from '@/components/icon'; import { SheetScrollView } from '@/components/sheet'; import { Text } from '@/components/text'; @@ -79,7 +80,7 @@ export default function DebatesSheet() { return ( <SheetScrollView contentContainerStyle={styles.content} testID="debates-sheet"> <View style={styles.header}> - <Text accessibilityRole="header" weight="bold" style={styles.title}> + <Text accessibilityRole="header" weight="display" style={styles.title}> {t('debate.yourDebates')} </Text> <TextButton label={t('mobile.common.done')} onPress={() => router.back()} /> @@ -178,9 +179,10 @@ export default function DebatesSheet() { value={oneOff} onValueChange={setOneOff} accessibilityLabel={t('mobile.debates.oneOff')} - trackColor={{ false: colors.border, true: colors.primary }} + trackColor={{ false: colors.border, true: colors.primaryFill }} ios_backgroundColor={colors.border} thumbColor="#ffffff" + {...WEB_SWITCH_THUMB} /> </View> </Card> diff --git a/apps/mobile/src/app/delete-account.tsx b/apps/mobile/src/app/delete-account.tsx index d9a1fa9e9..fb84ce149 100644 --- a/apps/mobile/src/app/delete-account.tsx +++ b/apps/mobile/src/app/delete-account.tsx @@ -52,7 +52,7 @@ export default function DeleteAccountSheet() { return ( <SheetScrollView> <View style={styles.header}> - <Text accessibilityRole="header" weight="bold" style={styles.title}> + <Text accessibilityRole="header" weight="display" style={styles.title}> {t('settings.deleteAccountDialogTitle')} </Text> <TextButton label={t('common.cancel')} onPress={() => router.back()} disabled={deleting} /> diff --git a/apps/mobile/src/app/delete-data.tsx b/apps/mobile/src/app/delete-data.tsx index aeeaf9bd9..cc5812af0 100644 --- a/apps/mobile/src/app/delete-data.tsx +++ b/apps/mobile/src/app/delete-data.tsx @@ -71,7 +71,7 @@ export default function DeleteDataSheet() { return ( <SheetScrollView> <View style={styles.header}> - <Text accessibilityRole="header" weight="bold" style={styles.title}> + <Text accessibilityRole="header" weight="display" style={styles.title}> {t('settings.deleteDialogTitle')} </Text> <TextButton label={t('common.cancel')} onPress={() => router.back()} disabled={deleting} /> diff --git a/apps/mobile/src/app/factoids/index.tsx b/apps/mobile/src/app/factoids/index.tsx index b2be095d8..b62f5c511 100644 --- a/apps/mobile/src/app/factoids/index.tsx +++ b/apps/mobile/src/app/factoids/index.tsx @@ -31,7 +31,8 @@ const SOURCE_COUNTS = Array.from({ length: 13 }, (_, index) => String(index + 3) // The last form stays for the session, as on the website; session memory forgets it when the account changes. const lastForm = sessionSlot<FactoidsInput>(() => DEFAULT_FORM); -const PROGRESS = ['factoidsForm.loadingMsg1', 'factoidsForm.loadingMsg2', 'factoidsForm.loadingMsg3', 'factoidsForm.loadingMsg4', 'factoidsForm.loadingMsg5']; +// The website's one progress line while the tool runs. +const PROGRESS = ['factoids.working']; /** * Factoids, as the website's FactoidsPage. Factoids research a claim, not @@ -181,10 +182,10 @@ export default function FactoidsScreen() { testID="factoids-screen" header={<DocumentHeader backLabel={backLabel(t, from, 'prepare')} actions={[]} />} actions={working ? undefined : <MainActionButton label={t('factoidsForm.submit')} onPress={() => void submit({ ...form, claim: form.claim.trim() })} />}> - <ToolTitle title={t('nav.factoids')} subtitle={t('factoidsForm.subtitle')} /> + <ToolTitle title={t('nav.factoids')} subtitle={t('factoids.emptyDesc')} /> <WorkingIn /> {working ? ( - <ToolProgress messages={PROGRESS.map((key) => t(key))} detail={form.claim} onCancel={cancel} /> + <ToolProgress pose="reading" messages={PROGRESS.map((key) => t(key))} detail={form.claim} onCancel={cancel} /> ) : ( <> <Field diff --git a/apps/mobile/src/app/flowchart/card.tsx b/apps/mobile/src/app/flowchart/card.tsx index b41f22446..94e5ba8d7 100644 --- a/apps/mobile/src/app/flowchart/card.tsx +++ b/apps/mobile/src/app/flowchart/card.tsx @@ -22,7 +22,7 @@ export default function AddCardSheet() { return ( <SheetScrollView testID="card-sheet"> <View style={styles.header}> - <Text accessibilityRole="header" weight="bold" style={styles.title}> + <Text accessibilityRole="header" weight="display" style={styles.title}> {t('flowchart.addNodeDialog')} </Text> <TextButton label={t('common.cancel')} onPress={() => router.back()} /> diff --git a/apps/mobile/src/app/flowchart/index.tsx b/apps/mobile/src/app/flowchart/index.tsx index 8f61ead77..82a957213 100644 --- a/apps/mobile/src/app/flowchart/index.tsx +++ b/apps/mobile/src/app/flowchart/index.tsx @@ -252,7 +252,7 @@ export default function FlowchartScreen() { await deleteFlowchart(forUser, savedId); if (owner.current !== forUser) return; forgetDraft(at.scope, at.key); - setNotice(t('flowchart.deletedToast')); + setNotice(t('flowchart.deletedNamed', { title: at.draft.title })); // The screen then opens the debate's next newest map, or the starter map. if (requestedId) setRequestedId(null); else setAttempt((n) => n + 1); @@ -293,7 +293,7 @@ export default function FlowchartScreen() { const page = ( <ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={[styles.content, { paddingBottom: insets.bottom + 32 }]}> <View style={styles.head}> - <Text accessibilityRole="header" weight="bold" style={styles.title}> + <Text accessibilityRole="header" weight="display" style={styles.title}> {t('nav.flowchart')} </Text> {draft && scope ? ( diff --git a/apps/mobile/src/app/flowchart/maps.tsx b/apps/mobile/src/app/flowchart/maps.tsx index fae0f1976..7ebe8e90a 100644 --- a/apps/mobile/src/app/flowchart/maps.tsx +++ b/apps/mobile/src/app/flowchart/maps.tsx @@ -144,7 +144,7 @@ export default function SavedMapsSheet() { return ( <SheetScrollView testID="maps-sheet"> <View style={styles.header}> - <Text accessibilityRole="header" weight="bold" style={styles.title}> + <Text accessibilityRole="header" weight="display" style={styles.title}> {t('flowchart.savedFlowcharts')} </Text> <TextButton label={t('common.close')} onPress={() => router.back()} /> @@ -195,7 +195,7 @@ const styles = StyleSheet.create({ header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, title: { fontSize: 21, lineHeight: 26, letterSpacing: -0.3, flexShrink: 1 }, body: { fontSize: 15, lineHeight: 21 }, - list: { borderWidth: 1, borderRadius: radius.field, overflow: 'hidden' }, + list: { borderWidth: 2, borderRadius: radius.field, overflow: 'hidden' }, row: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 15, paddingVertical: 11, minHeight: 56 }, rowText: { flex: 1, gap: 2 }, rowTitle: { fontSize: 16 }, diff --git a/apps/mobile/src/app/generate.tsx b/apps/mobile/src/app/generate.tsx index 51befc778..c093e53be 100644 --- a/apps/mobile/src/app/generate.tsx +++ b/apps/mobile/src/app/generate.tsx @@ -38,7 +38,8 @@ const DEFAULT_FORM: GenerateInput = { }; const lastForm = sessionSlot<GenerateInput>(() => DEFAULT_FORM); -const PROGRESS = ['generateForm.loadingMsg1', 'generateForm.loadingMsg2', 'generateForm.loadingMsg3', 'generateForm.loadingMsg4', 'generateForm.loadingMsg5']; +// The website's one progress line while the case is written. +const PROGRESS = ['generate.working']; /** Generate, as the website's GeneratePage: a native form, a calm wait, then the case as a document. */ export default function GenerateScreen() { @@ -194,7 +195,7 @@ export default function GenerateScreen() { testID="generate-screen" header={<DocumentHeader backLabel={backLabel(t, from, 'prepare')} actions={[]} />} actions={generating ? undefined : <MainActionButton label={t('generateForm.submit')} onPress={() => void submit()} />}> - <ToolTitle title={t('nav.generate')} subtitle={t('generateForm.subtitle')} /> + <ToolTitle title={t('nav.generate')} subtitle={t('generate.emptyDesc')} /> <WorkingIn /> {generating ? ( <ToolProgress messages={PROGRESS.map((key) => t(key))} detail={form.motion} onCancel={cancel} /> diff --git a/apps/mobile/src/app/history.tsx b/apps/mobile/src/app/history.tsx index 24ca7c1d4..71b7a1816 100644 --- a/apps/mobile/src/app/history.tsx +++ b/apps/mobile/src/app/history.tsx @@ -35,6 +35,7 @@ import { deleteResult } from '@/lib/results'; import { deleteSparringRound, sparringDraft } from '@/lib/sparring'; import { useCopyHolder } from '@/lib/use-copy-holder'; import { useTheme } from '@/theme/use-theme'; +import { EmptyState } from '@/components/empty-state'; /** What the History lists delete, with the website's confirmation and failure copy. */ const DELETIONS: Record<'case' | 'sparring', { tab: HistoryTab; title: string; desc: string; failed: string; label: string; run: (userId: string, id: string) => Promise<void> }> = { @@ -206,7 +207,7 @@ export default function HistoryScreen() { <View style={styles.root}> <DocumentHeader backLabel={backLabel(t, from, 'debate')} actions={[]} /> <Screen> - <Text accessibilityRole="header" weight="bold" style={styles.title}> + <Text accessibilityRole="header" weight="display" style={styles.title}> {t('historyPage.title')} </Text> <ScopePicker label={scopeLabel} onPress={() => setScopeOpen(true)} /> @@ -290,15 +291,9 @@ function TabList({ tab, items, onDelete }: { tab: HistoryTab; items: HistoryItem if (items.length === 0) { const empty = EMPTY[tab]; return ( - <Card> - <Text weight="semibold" style={styles.rowTitle}> - {t(empty.title)} - </Text> - <Text tone="secondary" style={styles.body}> - {t(empty.desc)} - </Text> + <EmptyState pose="sleepy" title={t(empty.title)} description={t(empty.desc)}> {empty.href ? <TextButton label={t(empty.action)} onPress={() => router.push(empty.href!)} /> : null} - </Card> + </EmptyState> ); } return ( @@ -349,7 +344,7 @@ function HistoryRow({ item, first, onPress, onDelete }: { item: HistoryItem; fir const body = ( <Pressable accessibilityRole={onPress ? 'button' : undefined} - accessibilityLabel={onPress ? t('debate.openItemAria', { title }) : undefined} + accessibilityLabel={onPress ? t('mobile.common.openItem', { title }) : undefined} accessibilityActions={onDelete ? [{ name: 'delete', label: t('common.delete') }] : undefined} onAccessibilityAction={(event) => { if (event.nativeEvent.actionName === 'delete') void remove(); @@ -543,7 +538,7 @@ const styles = StyleSheet.create({ body: { fontSize: 15, lineHeight: 21 }, loading: { marginTop: 32 }, pressed: { opacity: 0.7 }, - scope: { flexDirection: 'row', alignItems: 'center', gap: 12, borderWidth: 1, borderRadius: 14, paddingVertical: 11, paddingHorizontal: 14 }, + scope: { flexDirection: 'row', alignItems: 'center', gap: 12, borderWidth: 2, borderRadius: 14, paddingVertical: 11, paddingHorizontal: 14 }, tabRow: { marginHorizontal: -20, flexGrow: 0 }, tabs: { gap: 8, paddingHorizontal: 20 }, list: { padding: 0, gap: 0, overflow: 'hidden' }, @@ -558,6 +553,6 @@ const styles = StyleSheet.create({ suggestedMotion: { flex: 1 }, badge: { borderRadius: 999, paddingHorizontal: 9, paddingVertical: 3 }, badgeText: { fontSize: 12.5 }, - target: { flexDirection: 'row', alignItems: 'center', gap: 10, borderWidth: 1, borderRadius: 12, paddingVertical: 11, paddingHorizontal: 13 }, + target: { flexDirection: 'row', alignItems: 'center', gap: 10, borderWidth: 2, borderRadius: 12, paddingVertical: 11, paddingHorizontal: 13 }, targetText: { flex: 1 }, }); diff --git a/apps/mobile/src/app/new-debate.tsx b/apps/mobile/src/app/new-debate.tsx index 3d1f46a26..9f77eecdd 100644 --- a/apps/mobile/src/app/new-debate.tsx +++ b/apps/mobile/src/app/new-debate.tsx @@ -44,7 +44,7 @@ export default function NewDebateSheet() { return ( <SheetScrollView testID="new-debate-sheet"> <View style={styles.header}> - <Text accessibilityRole="header" weight="bold" style={styles.title}> + <Text accessibilityRole="header" weight="display" style={styles.title}> {t('debate.startNew')} </Text> <TextButton label={t('common.cancel')} onPress={() => router.back()} disabled={saving} /> diff --git a/apps/mobile/src/app/new-note.tsx b/apps/mobile/src/app/new-note.tsx index 22874dd59..1b29035f8 100644 --- a/apps/mobile/src/app/new-note.tsx +++ b/apps/mobile/src/app/new-note.tsx @@ -44,7 +44,7 @@ export default function NewNoteSheet() { return ( <SheetScrollView testID="new-note-sheet"> <View style={styles.header}> - <Text accessibilityRole="header" weight="bold" style={styles.title}> + <Text accessibilityRole="header" weight="display" style={styles.title}> {t('mobile.library.writeNote')} </Text> <TextButton label={t('common.cancel')} onPress={() => router.back()} disabled={saving} /> diff --git a/apps/mobile/src/app/sign-in.tsx b/apps/mobile/src/app/sign-in.tsx index de33369fc..14965dc35 100644 --- a/apps/mobile/src/app/sign-in.tsx +++ b/apps/mobile/src/app/sign-in.tsx @@ -1,4 +1,3 @@ -import { LinearGradient } from 'expo-linear-gradient'; import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, View, useWindowDimensions, type TextInput } from 'react-native'; @@ -6,12 +5,12 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { MainActionButton, TextButton } from '@/components/buttons'; import { Field } from '@/components/field'; -import { Icon } from '@/components/icon'; import { SegmentedControl } from '@/components/segmented-control'; import { Text } from '@/components/text'; import { useAuth } from '@/lib/auth'; import { signInErrorKey } from '@/lib/sign-in-errors'; -import { mainActionGradient } from '@/theme/colors'; +import { Icon } from '@/components/icon'; +import { OwlMark } from '@/components/owl'; import { useTheme } from '@/theme/use-theme'; type Mode = 'email' | 'demo'; @@ -112,11 +111,9 @@ export default function SignInScreen() { centred && styles.centred, { paddingTop: insets.top + 32, paddingBottom: insets.bottom + 24 }, ]}> - <LinearGradient {...mainActionGradient} style={styles.logo}> - <Icon name="sparkles" color="#ffffff" size={30} /> - </LinearGradient> + <OwlMark size={64} /> <View style={styles.heading}> - <Text accessibilityRole="header" weight="bold" style={styles.title}> + <Text accessibilityRole="header" weight="display" style={styles.title}> {t('login.title')} </Text> <Text tone="secondary" style={styles.subtitle}> @@ -221,9 +218,15 @@ export default function SignInScreen() { ) : null} {status === 'code-sent' ? ( - <Text accessibilityRole="alert" weight="medium" style={[styles.message, { color: colors.success }]}> - {t('login.codeSent')} - </Text> + // Success is mint and always comes with a check mark, as on the website. + <View style={styles.success} accessibilityRole="alert"> + <View style={[styles.successMark, { backgroundColor: colors.successFill }]}> + <Icon name="check" color="#FFFFFF" size={14} /> + </View> + <Text weight="medium" style={[styles.message, styles.successText, { color: colors.success }]}> + {t('login.codeSent')} + </Text> + </View> ) : null} {status === 'error' && error ? ( <Text accessibilityRole="alert" weight="medium" style={[styles.message, { color: colors.destructive }]}> @@ -236,11 +239,13 @@ export default function SignInScreen() { } const styles = StyleSheet.create({ + success: { flexDirection: 'row', alignItems: 'flex-start', gap: 8 }, + successMark: { width: 22, height: 22, borderRadius: 11, alignItems: 'center', justifyContent: 'center', marginTop: 1 }, + successText: { flex: 1 }, root: { flex: 1 }, // Phone-width on iPad, centred. content: { paddingHorizontal: 24, gap: 24, width: '100%', maxWidth: 460, alignSelf: 'center' }, centred: { flexGrow: 1, justifyContent: 'center' }, - logo: { width: 60, height: 60, borderRadius: 17, alignItems: 'center', justifyContent: 'center' }, heading: { gap: 8 }, title: { fontSize: 32, lineHeight: 38, letterSpacing: -0.8 }, subtitle: { fontSize: 17, lineHeight: 24 }, diff --git a/apps/mobile/src/app/sparring/index.tsx b/apps/mobile/src/app/sparring/index.tsx index d95a5279f..b5faa4201 100644 --- a/apps/mobile/src/app/sparring/index.tsx +++ b/apps/mobile/src/app/sparring/index.tsx @@ -17,7 +17,7 @@ import { import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, AppState, Keyboard, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, TextInput, useWindowDimensions, View } from 'react-native'; +import { AppState, Keyboard, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, TextInput, useWindowDimensions, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { ActionMenu } from '@/components/action-menu'; @@ -47,6 +47,7 @@ import { tapTurnStart, useKeepAwakeWhile } from '@/lib/timer-signals'; import { radius } from '@/theme/colors'; import { fontFamily } from '@/theme/fonts'; import { useTheme } from '@/theme/use-theme'; +import { OwlAvatar, OwlLoader } from '@/components/owl'; const TICK_MS = 250; const MIN_MOTION_CHARS = 8; @@ -93,6 +94,8 @@ export default function SparringScreen() { const [session, setSession] = useState<SparringSession | null>(() => sparringDraft.get()); // Moves read the latest round from here, so a reply that lands after a keystroke keeps the keystroke. const sessionRef = useRef(session); + // A pause after starting must stay paused when the student edits the draft. + const startedTurn = useRef<string | null>(null); const [request, setRequest] = useState<RequestState>(() => needsRequest(sparringDraft.get()) ? { status: 'failed', failure: { message: 'mobile.sparring.interrupted', plan: false } } : { status: 'idle' }); const [motion, setMotion] = useState(''); @@ -437,10 +440,23 @@ export default function SparringScreen() { notice={notice} submissionError={submissionError} onClose={() => router.back()} - onDraft={(draft) => move((round, now) => ({ ...round, draft: draft.slice(0, MAX_SPARRING_SPEECH_CHARS), updatedAt: now }))} + onDraft={(draft) => move((round, now) => { + const turn = currentSparringTurn(round); + const key = `${round.id}:${turn?.id ?? ''}`; + const awaitingStart = startedTurn.current !== key && !round.timerRunning + && round.timerRemainingSeconds === turn?.timeLimitSeconds; + if (awaitingStart) startedTurn.current = key; + return { ...(awaitingStart ? resumeSparringTimer(round, now) : round), draft: draft.slice(0, MAX_SPARRING_SPEECH_CHARS), updatedAt: now }; + })} onPause={() => move(pauseSparringTimer)} - onResume={() => move(resumeSparringTimer)} - onReset={() => move(resetSparringTurnTimer)} + onResume={() => move((round, now) => { + startedTurn.current = `${round.id}:${currentSparringTurn(round)?.id ?? ''}`; + return resumeSparringTimer(round, now); + })} + onReset={() => { + startedTurn.current = null; + move(resetSparringTurnTimer); + }} onSubmit={submit} onRetry={retry} /> @@ -581,7 +597,7 @@ function LiveRound({ </View> ) : waiting ? ( <View style={[styles.caption, styles.centredCard, { backgroundColor: colors.card, borderColor: colors.border }]} testID={judging ? 'sparring-judging' : 'sparring-thinking'}> - <ActivityIndicator color={colors.primary} /> + <OwlLoader pose="arguing" size={88} /> <Text weight="semibold" style={styles.captionTitle} accessibilityRole="progressbar" accessibilityLiveRegion="polite"> {judging ? t('sparring.round.judgeWeighing') : t('sparring.round.opponentThinking')} </Text> @@ -598,9 +614,12 @@ function LiveRound({ onPress={() => setExpanded((value) => !value)} style={[styles.caption, { backgroundColor: colors.card, borderColor: colors.border }]} testID="sparring-opponent-speech"> - <Text tone="faint" weight="medium" style={styles.captionLabel}> - {`${t('sparring.opponent')} · ${t(`sparring.turns.${lastAi.turnId}`)}`} - </Text> + <View style={styles.speaker}> + <OwlAvatar pose="arguing" size={28} label={t('sparring.opponentAvatarAlt')} /> + <Text tone="faint" weight="medium" style={[styles.captionLabel, styles.speakerLabel]}> + {`${t('sparring.opponent')} · ${t(`sparring.turns.${lastAi.turnId}`)}`} + </Text> + </View> <Text style={styles.speechText} numberOfLines={expanded ? undefined : keyboard ? 2 : 4}> {lastAi.content} </Text> @@ -665,6 +684,8 @@ function LiveRound({ } const styles = StyleSheet.create({ + speaker: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + speakerLabel: { flex: 1 }, root: { flex: 1 }, flex: { flex: 1 }, motion: { minHeight: 96, paddingTop: 13, paddingBottom: 13, textAlignVertical: 'top' }, @@ -680,14 +701,14 @@ const styles = StyleSheet.create({ status: { fontSize: 14, lineHeight: 19, textAlign: 'center', maxWidth: 200 }, time: { letterSpacing: -2, fontVariant: ['tabular-nums'] }, notice: { fontSize: 14.5, lineHeight: 20 }, - caption: { borderWidth: 1, borderRadius: radius.card, paddingHorizontal: 16, paddingVertical: 14, gap: 6 }, + caption: { borderWidth: 2, borderRadius: radius.card, paddingHorizontal: 16, paddingVertical: 14, gap: 6 }, centredCard: { alignItems: 'center', paddingVertical: 20, gap: 8 }, captionTitle: { fontSize: 16.5, textAlign: 'center' }, captionText: { fontSize: 15, lineHeight: 21 }, centred: { textAlign: 'center' }, captionLabel: { fontSize: 13.5 }, speechText: { fontSize: 17, lineHeight: 25 }, - composer: { borderWidth: 1, borderRadius: radius.card, paddingHorizontal: 16, paddingTop: 12, paddingBottom: 10, gap: 6 }, + composer: { borderWidth: 2, borderRadius: radius.card, paddingHorizontal: 16, paddingTop: 12, paddingBottom: 10, gap: 6 }, guidance: { fontSize: 14, lineHeight: 19 }, input: { minHeight: 150, fontSize: 17, lineHeight: 24, fontFamily: fontFamily.regular, textAlignVertical: 'top', paddingTop: 4 }, words: { fontSize: 13, alignSelf: 'flex-end', fontVariant: ['tabular-nums'] }, diff --git a/apps/mobile/src/app/timer.tsx b/apps/mobile/src/app/timer.tsx index 99e990b27..675823cee 100644 --- a/apps/mobile/src/app/timer.tsx +++ b/apps/mobile/src/app/timer.tsx @@ -222,7 +222,7 @@ const styles = StyleSheet.create({ speechRow: { flexGrow: 0, alignSelf: 'stretch', marginHorizontal: -20 }, // Centred when they fit, as on iPad; scrolling from the start when they don't. speeches: { gap: 8, paddingHorizontal: 20, flexGrow: 1, justifyContent: 'center' }, - signals: { alignSelf: 'center', maxWidth: 520, width: '100%', borderWidth: 1, borderRadius: 14, paddingHorizontal: 14, paddingVertical: 11, gap: 3 }, + signals: { alignSelf: 'center', maxWidth: 520, width: '100%', borderWidth: 2, borderRadius: 14, paddingHorizontal: 14, paddingVertical: 11, gap: 3 }, signalTitle: { flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 2 }, signalText: { fontSize: 14, lineHeight: 19 }, controls: { position: 'absolute', left: 0, right: 0, bottom: 0, flexDirection: 'row', alignItems: 'flex-end', justifyContent: 'space-evenly', paddingTop: 12 }, diff --git a/apps/mobile/src/components/action-menu.web.tsx b/apps/mobile/src/components/action-menu.web.tsx index 90c87d23b..40f33cecd 100644 --- a/apps/mobile/src/components/action-menu.web.tsx +++ b/apps/mobile/src/components/action-menu.web.tsx @@ -41,10 +41,10 @@ export function ActionMenu({ visible, title, items, cancelLabel, onSelect, onClo } const styles = StyleSheet.create({ - backdrop: { flex: 1, backgroundColor: 'rgba(15,23,42,0.18)', paddingHorizontal: 14 }, + backdrop: { flex: 1, backgroundColor: 'rgba(11,42,110,0.18)', paddingHorizontal: 14 }, top: { alignItems: 'flex-end', paddingTop: 58 }, bottom: { justifyContent: 'flex-end', paddingBottom: 28 }, - menu: { borderRadius: 16, borderWidth: StyleSheet.hairlineWidth, overflow: 'hidden', boxShadow: '0 14px 40px -10px rgba(15,23,42,0.4)' }, + menu: { borderRadius: 16, borderWidth: StyleSheet.hairlineWidth, overflow: 'hidden', boxShadow: '0 14px 40px -10px rgba(11,42,110,0.4)' }, menuTop: { width: 268 }, menuBottom: { width: '100%', maxWidth: 520, alignSelf: 'center' }, title: { fontSize: 13.5, textAlign: 'center', paddingVertical: 12, paddingHorizontal: 16 }, diff --git a/apps/mobile/src/components/announcement-banner.tsx b/apps/mobile/src/components/announcement-banner.tsx index 5f06d5a1d..81853e14e 100644 --- a/apps/mobile/src/components/announcement-banner.tsx +++ b/apps/mobile/src/components/announcement-banner.tsx @@ -58,7 +58,7 @@ export function AnnouncementBanner({ announcements, style }: { announcements: An } const styles = StyleSheet.create({ - banner: { flexDirection: 'row', alignItems: 'flex-start', gap: 12, borderWidth: 1, borderRadius: radius.card, padding: 14 }, + banner: { flexDirection: 'row', alignItems: 'flex-start', gap: 12, borderWidth: 2, borderRadius: radius.card, padding: 14 }, text: { flex: 1, gap: 3 }, titleRow: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'baseline', columnGap: 8 }, title: { fontSize: 16, lineHeight: 21 }, diff --git a/apps/mobile/src/components/app-tabs.tsx b/apps/mobile/src/components/app-tabs.tsx index d0949ad20..ce5a32e43 100644 --- a/apps/mobile/src/components/app-tabs.tsx +++ b/apps/mobile/src/components/app-tabs.tsx @@ -16,8 +16,8 @@ export default function AppTabs() { tintColor={colors.primary} iconColor={{ default: colors.tabInactive, selected: colors.primary }} labelStyle={{ - default: { color: colors.tabInactive, fontFamily: fontFamily.medium, fontSize: 10 }, - selected: { color: colors.primary, fontFamily: fontFamily.medium, fontSize: 10 }, + default: { color: colors.tabInactive, fontFamily: fontFamily.semibold, fontSize: 10 }, + selected: { color: colors.primary, fontFamily: fontFamily.semibold, fontSize: 10 }, }}> {TABS.map((tab) => ( <NativeTabs.Trigger key={tab.name} name={tab.name}> diff --git a/apps/mobile/src/components/app-tabs.web.tsx b/apps/mobile/src/components/app-tabs.web.tsx index 2328a83f3..a54738e28 100644 --- a/apps/mobile/src/components/app-tabs.web.tsx +++ b/apps/mobile/src/components/app-tabs.web.tsx @@ -30,8 +30,8 @@ export default function AppTabs() { <TabList accessibilityRole="tablist" style={[styles.sidebar, { backgroundColor: colors.card, borderRightColor: colors.line, paddingTop: insets.top + 18 }]}> - <Text accessibilityRole="header" weight="bold" style={styles.sidebarTitle}> - {t('home.title')} + <Text accessibilityRole="header" weight="display" style={styles.sidebarTitle}> + DebateOwl </Text> {TABS.map((tab) => ( <TabTrigger key={tab.name} name={tab.name} href={tab.href} asChild> @@ -109,7 +109,7 @@ const SidebarButton = forwardRef<View, TabTriggerSlotProps & { icon: IconName; l {...props} accessibilityRole="tab" accessibilityState={{ selected: isFocused }} - style={[styles.sidebarRow, isFocused && { backgroundColor: colors.primary }]}> + style={[styles.sidebarRow, isFocused && { backgroundColor: colors.primaryFill }]}> <Icon name={icon} color={isFocused ? colors.onPrimary : colors.primary} size={22} /> <Text weight={isFocused ? 'semibold' : 'regular'} style={[styles.sidebarLabel, { color }]} numberOfLines={1}> {label} diff --git a/apps/mobile/src/components/ask-answer.tsx b/apps/mobile/src/components/ask-answer.tsx index 8015100e5..6a6c50032 100644 --- a/apps/mobile/src/components/ask-answer.tsx +++ b/apps/mobile/src/components/ask-answer.tsx @@ -3,13 +3,17 @@ import { useRouter } from 'expo-router'; import * as WebBrowser from 'expo-web-browser'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { ScrollView, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import { Chip, CiteBadge } from './chip'; +import { FadingScrollRow } from './edge-fade'; import { firstUrl, hostOf, openCitation, shorten, type CitationTarget } from './citation-target'; import { MarkdownView } from './markdown-view'; +import { OwlAvatar } from './owl'; import { Text } from './text'; +import { useTheme } from '@/theme/use-theme'; + import type { Source } from '@/lib/sources'; /** Each citation as a chip: a Library source opens its detail, a web address opens the in-app browser. */ @@ -33,6 +37,7 @@ function citationTargets(output: AskOutput | undefined, library: Source[]): Cita /** One coach answer, as in mock 07, with the website's extra sections when the answer has them. */ export function AskAnswer({ content, output, streaming, library }: { content: string; output?: AskOutput; streaming?: boolean; library: Source[] }) { const { t } = useTranslation(); + const { colors } = useTheme(); const router = useRouter(); const targets = useMemo(() => citationTargets(output, library), [output, library]); const claims = useMemo(() => (output?.citations ?? []).map((citation) => citation.claim), [output]); @@ -43,19 +48,21 @@ export function AskAnswer({ content, output, streaming, library }: { content: st return ( <View style={styles.answer} testID={streaming ? 'ask-streaming' : 'ask-answer'}> + <OwlAvatar pose="coach" size={30} label={t('askChat.coachAvatarAlt')} /> {content || streaming ? ( <MarkdownView source={content} claims={claims} streaming={streaming} onCite={(n) => open(targets[n - 1])} /> ) : null} + {/* Ask's answers sit on the card colour, so the row fades into it. */} {targets.length > 0 || webSources.length > 0 ? ( - <ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.chipRow} contentContainerStyle={styles.chips}> + <FadingScrollRow color={colors.card} id={`ask-cites-fade-${streaming ? 'live' : 'saved'}`} style={styles.chipRow} contentContainerStyle={styles.chips}> {targets.map((target) => ( <Chip key={target.n} label={target.label} leading={<CiteBadge n={target.n} />} onPress={() => open(target)} /> ))} {webSources.map((item) => ( <Chip key={item.url} icon="globe" label={shorten(item.title || hostOf(item.url))} onPress={() => void WebBrowser.openBrowserAsync(item.url)} /> ))} - </ScrollView> + </FadingScrollRow> ) : null} {output?.prosConsAnalysis ? ( @@ -101,7 +108,7 @@ function Section({ title, items, marker = '•' }: { title: string; items: strin const styles = StyleSheet.create({ answer: { gap: 12 }, - chipRow: { marginHorizontal: -20, flexGrow: 0 }, + chipRow: { marginHorizontal: -20 }, chips: { gap: 8, paddingHorizontal: 20 }, section: { gap: 10 }, sectionBody: { gap: 3 }, diff --git a/apps/mobile/src/components/ask-chat-list.tsx b/apps/mobile/src/components/ask-chat-list.tsx index 3709ba3d4..94b1139ce 100644 --- a/apps/mobile/src/components/ask-chat-list.tsx +++ b/apps/mobile/src/components/ask-chat-list.tsx @@ -152,7 +152,7 @@ export function AskChatPane({ </Pressable> </View> <ScrollView contentContainerStyle={[styles.paneContent, { paddingBottom: insets.bottom + 24 }]}> - <Text accessibilityRole="header" weight="bold" style={styles.paneTitle}> + <Text accessibilityRole="header" weight="display" style={styles.paneTitle}> {t('nav.ask')} </Text> <ChatRows chats={mine ? state.chats : null} failed={mine && state.failed} openChatId={openChatId} onPick={onPick} highlightOpen /> diff --git a/apps/mobile/src/components/ask-history.tsx b/apps/mobile/src/components/ask-history.tsx index 50f2fafe6..ebed11e45 100644 --- a/apps/mobile/src/components/ask-history.tsx +++ b/apps/mobile/src/components/ask-history.tsx @@ -65,7 +65,7 @@ export function AskHistory({ contentContainerStyle={[styles.content, { paddingBottom: insets.bottom + 24 }]} testID="ask-history"> <View style={styles.header}> - <Text accessibilityRole="header" weight="bold" style={styles.title}> + <Text accessibilityRole="header" weight="display" style={styles.title}> {t('askChat.savedChats')} </Text> <TextButton label={t('mobile.common.done')} onPress={onClose} /> diff --git a/apps/mobile/src/components/attack-document.tsx b/apps/mobile/src/components/attack-document.tsx index 2062386d9..d1fe2c8d4 100644 --- a/apps/mobile/src/components/attack-document.tsx +++ b/apps/mobile/src/components/attack-document.tsx @@ -147,7 +147,7 @@ export function AttackDocument({ output, sources }: { output: AttackOutput; sour } const styles = StyleSheet.create({ - score: { borderWidth: 1, borderRadius: 17, paddingHorizontal: 16, paddingVertical: 14, gap: 10 }, + score: { borderWidth: 2, borderRadius: 17, paddingHorizontal: 16, paddingVertical: 14, gap: 10 }, scoreRow: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'space-between' }, scoreLabel: { fontSize: 15 }, scoreValue: { fontSize: 22 }, diff --git a/apps/mobile/src/components/buttons.tsx b/apps/mobile/src/components/buttons.tsx index ba990759b..c6e6fb1d2 100644 --- a/apps/mobile/src/components/buttons.tsx +++ b/apps/mobile/src/components/buttons.tsx @@ -1,14 +1,69 @@ -import { LinearGradient } from 'expo-linear-gradient'; -import { ActivityIndicator, Pressable, StyleSheet } from 'react-native'; +import type { ReactNode } from 'react'; +import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; import { Icon } from './icon'; import type { IconName } from './icon-paths'; import { Text } from './text'; -import { mainActionGradient, radius } from '@/theme/colors'; +import { LEDGE, radius } from '@/theme/colors'; import { useTheme } from '@/theme/use-theme'; -/** The one main action on a screen, in the website's Start Trial gradient. */ +type Look = { face: string; ledge: string; foreground: string; edge?: string; ledgeSize?: number }; + +/** + * The website's tactile button (`.btn` in src/index.css): a face on a solid + * ledge, which sinks onto the ledge while pressed. Disabled buttons go flat and + * grey; a busy one keeps its face. The ledge sits inside the button's height, + * so nothing moves around it. + */ +function Tactile({ + look, + onPress, + disabled, + busy, + floating, + testID, + accessibilityLabel, + children, +}: { + look: Look; + onPress: () => void; + disabled?: boolean; + busy?: boolean; + floating?: boolean; + testID?: string; + accessibilityLabel?: string; + children: (foreground: string) => ReactNode; +}) { + const { colors, scheme } = useTheme(); + const flat = disabled && !busy; + const ledge = flat ? 0 : (look.ledgeSize ?? LEDGE); + const face = flat ? colors.disabledFace : look.face; + const foreground = flat ? colors.disabledText : look.foreground; + return ( + <Pressable + accessibilityRole="button" + accessibilityLabel={accessibilityLabel} + accessibilityState={{ disabled: disabled || busy, busy }} + disabled={disabled || busy} + onPress={onPress} + testID={testID} + style={[styles.tactile, { backgroundColor: flat ? 'transparent' : look.ledge }, floating && scheme === 'light' && !flat && styles.floating]}> + {({ pressed }) => ( + <View + style={[ + styles.face, + { backgroundColor: face, marginTop: pressed ? ledge : 0, marginBottom: pressed ? 0 : ledge }, + look.edge && !flat ? { borderWidth: 2, borderColor: look.edge } : null, + ]}> + {children(foreground)} + </View> + )} + </Pressable> + ); +} + +/** The one main action on a screen: the website's primary button, a size up. */ export function MainActionButton({ label, onPress, @@ -20,24 +75,22 @@ export function MainActionButton({ busy?: boolean; disabled?: boolean; }) { - const inactive = disabled || busy; + const { colors } = useTheme(); return ( - <Pressable - accessibilityRole="button" - accessibilityState={{ disabled: inactive, busy }} - disabled={inactive} - onPress={onPress} - style={({ pressed }) => [styles.main, (pressed || inactive) && styles.dimmed]}> - <LinearGradient {...mainActionGradient} style={StyleSheet.absoluteFill} /> - {busy ? <ActivityIndicator color="#ffffff" /> : null} - <Text weight="semibold" style={styles.mainLabel}> - {label} - </Text> - </Pressable> + <Tactile look={{ face: colors.primaryFill, ledge: colors.primaryLedge, foreground: colors.onPrimary, ledgeSize: 5 }} onPress={onPress} busy={busy} disabled={disabled}> + {(foreground) => ( + <> + {busy ? <ActivityIndicator color={foreground} /> : null} + <Text weight="display" style={[styles.mainLabel, { color: foreground }]}> + {label} + </Text> + </> + )} + </Tactile> ); } -/** A solid button for a screen's action when the gradient is taken or would be too loud, as in the mocks' sheets. */ +/** A screen's action when it is not the one main action, or a destructive one. */ export function PrimaryButton({ label, icon, @@ -59,30 +112,24 @@ export function PrimaryButton({ testID?: string; }) { const { colors, scheme } = useTheme(); - const inactive = disabled || busy; - const foreground = destructive ? '#ffffff' : colors.onPrimary; + const look = destructive + ? { face: colors.destructive, ledge: colors.destructiveLedge, foreground: scheme === 'dark' ? colors.background : '#FFFFFF' } + : { face: colors.primaryFill, ledge: colors.primaryLedge, foreground: colors.onPrimary }; return ( - <Pressable - accessibilityRole="button" - accessibilityState={{ disabled: inactive, busy }} - disabled={inactive} - onPress={onPress} - testID={testID} - style={({ pressed }) => [ - styles.primary, - { backgroundColor: destructive ? colors.destructive : colors.primary }, - floating && scheme === 'light' && styles.primaryShadow, - (pressed || inactive) && styles.dimmed, - ]}> - {busy ? <ActivityIndicator color={foreground} /> : icon ? <Icon name={icon} color={foreground} size={20} /> : null} - <Text weight="semibold" style={[styles.primaryLabel, { color: foreground }]}> - {label} - </Text> - </Pressable> + <Tactile look={look} onPress={onPress} busy={busy} disabled={disabled} floating={floating} testID={testID}> + {(foreground) => ( + <> + {busy ? <ActivityIndicator color={foreground} /> : icon ? <Icon name={icon} color={foreground} size={20} /> : null} + <Text weight="display" style={[styles.label, { color: foreground }]} numberOfLines={2}> + {label} + </Text> + </> + )} + </Tactile> ); } -/** The outlined button beside a primary one, as "Attack this case" in mock 03. */ +/** The website's outline button: a white face with a strong edge and blue text, beside a primary one. */ export function SecondaryButton({ label, icon, @@ -99,25 +146,24 @@ export function SecondaryButton({ testID?: string; }) { const { colors, scheme } = useTheme(); + const look = { + face: scheme === 'dark' ? '#1C2A44' : colors.card, + ledge: colors.neutralLedge, + edge: colors.borderStrong, + foreground: colors.primary, + ledgeSize: 3, + }; return ( - <Pressable - accessibilityRole="button" - accessibilityState={{ disabled }} - disabled={disabled} - onPress={onPress} - testID={testID} - style={({ pressed }) => [ - styles.primary, - styles.secondary, - { backgroundColor: colors.input, borderColor: colors.border }, - floating && scheme === 'light' && styles.secondaryShadow, - (pressed || disabled) && styles.dimmed, - ]}> - {icon ? <Icon name={icon} color={colors.foreground} size={20} /> : null} - <Text weight="semibold" style={styles.primaryLabel} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.8}> - {label} - </Text> - </Pressable> + <Tactile look={look} onPress={onPress} disabled={disabled} floating={floating} testID={testID}> + {(foreground) => ( + <> + {icon ? <Icon name={icon} color={foreground} size={20} /> : null} + <Text weight="display" style={[styles.label, { color: foreground }]} numberOfLines={2}> + {label} + </Text> + </> + )} + </Tactile> ); } @@ -154,31 +200,22 @@ export function TextButton({ } const styles = StyleSheet.create({ - main: { - minHeight: 52, - borderRadius: radius.card - 2, - overflow: 'hidden', - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 8, - paddingHorizontal: 20, - }, - mainLabel: { color: '#ffffff', fontSize: 17 }, - primary: { - minHeight: 52, - borderRadius: radius.card - 2, + tactile: { borderRadius: radius.card, minHeight: 52 }, + face: { + flexGrow: 1, + minHeight: 48, + borderRadius: radius.card, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, - paddingHorizontal: 20, + paddingHorizontal: 14, + paddingVertical: 6, }, - primaryLabel: { fontSize: 17, flexShrink: 1 }, - primaryShadow: { boxShadow: '0 6px 18px -8px rgba(2,132,199,0.6)' }, - // The mock's outlined button has almost no side padding, so its label fits half the width. - secondary: { borderWidth: 1, paddingHorizontal: 6, gap: 6 }, - secondaryShadow: { boxShadow: '0 6px 18px -8px rgba(15,23,42,0.35)' }, + floating: { boxShadow: '0 8px 20px -10px rgba(11,42,110,0.45)' }, + mainLabel: { fontSize: 18, lineHeight: 22, letterSpacing: 0.2, flexShrink: 1, textAlign: 'center' }, + // A long label wraps to a second centred line; a primary action is never cut off. + label: { fontSize: 17, lineHeight: 21, letterSpacing: 0.2, flexShrink: 1, textAlign: 'center' }, dimmed: { opacity: 0.6 }, text: { minHeight: 44, alignItems: 'center', justifyContent: 'center' }, textLabel: { fontSize: 16 }, diff --git a/apps/mobile/src/components/card.tsx b/apps/mobile/src/components/card.tsx index 42e7a7dfe..df47d0949 100644 --- a/apps/mobile/src/components/card.tsx +++ b/apps/mobile/src/components/card.tsx @@ -23,6 +23,6 @@ export function IconTile({ name, size = 40 }: { name: IconName; size?: number }) } const styles = StyleSheet.create({ - card: { borderWidth: 1, borderRadius: radius.card, padding: 16, gap: 12 }, + card: { borderWidth: 2, borderRadius: radius.card, padding: 16, gap: 12 }, tile: { borderRadius: radius.tile, alignItems: 'center', justifyContent: 'center' }, }); diff --git a/apps/mobile/src/components/claim-panel.tsx b/apps/mobile/src/components/claim-panel.tsx index d9c890713..b1dc0c1be 100644 --- a/apps/mobile/src/components/claim-panel.tsx +++ b/apps/mobile/src/components/claim-panel.tsx @@ -98,10 +98,10 @@ export function ClaimPanel({ const remove = async () => { const confirmed = await confirmDestructive({ - title: t('flowchart.deleteNodeTitle'), - message: t('flowchart.deleteNodeDescription', { label: node.data.label }), - confirm: t('flowchart.deleteNodeConfirm'), - cancel: t('flowchart.deleteNodeCancel'), + title: t('mobile.flowchart.deleteNodeTitle'), + message: t('mobile.flowchart.deleteNodeDescription', { label: node.data.label }), + confirm: t('mobile.flowchart.deleteNodeConfirm'), + cancel: t('common.cancel'), }); if (!confirmed) return; change((current) => ({ ...current, doc: deleteCard(current.doc, node.id), edited: true })); @@ -114,7 +114,7 @@ export function ClaimPanel({ <Text weight="semibold" style={[styles.who, { color: flow.type[node.data.type] }]}> {cardKind(t, node)} </Text> - <Text accessibilityRole="header" weight="bold" style={styles.title} selectable> + <Text accessibilityRole="header" weight="display" style={styles.title} selectable> {node.data.label} </Text> </View> @@ -285,7 +285,7 @@ export function ClaimInspector({ claim, width, onClose }: { claim: { scope: stri return ( <View style={[styles.inspector, { width, paddingTop: covered, borderLeftColor: colors.line, backgroundColor: colors.background }]} testID="claim-inspector"> <View style={[styles.inspectorHead, { borderBottomColor: colors.line }]}> - <Text accessibilityRole="header" weight="bold" style={styles.inspectorTitle}> + <Text accessibilityRole="header" weight="display" style={styles.inspectorTitle}> {t('export.claim')} </Text> <Pressable accessibilityRole="button" accessibilityLabel={t('common.close')} hitSlop={8} onPress={onClose} style={styles.remove} testID="claim-close"> @@ -310,7 +310,7 @@ const styles = StyleSheet.create({ section: { fontSize: 16 }, block: { gap: 8 }, chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, - list: { borderWidth: 1, borderRadius: radius.field, overflow: 'hidden' }, + list: { borderWidth: 2, borderRadius: radius.field, overflow: 'hidden' }, row: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 14, paddingVertical: 11, minHeight: 50 }, rowText: { flex: 1, gap: 2 }, rowLabel: { fontSize: 15.5, lineHeight: 21 }, diff --git a/apps/mobile/src/components/crossfire-views.tsx b/apps/mobile/src/components/crossfire-views.tsx index 1cb314d03..9cc3734b2 100644 --- a/apps/mobile/src/components/crossfire-views.tsx +++ b/apps/mobile/src/components/crossfire-views.tsx @@ -211,7 +211,7 @@ export function CrossfireDebriefDocument({ debrief }: { debrief: CrossfireDebrie const styles = StyleSheet.create({ group: { gap: 7 }, groupLabel: { fontSize: 14, marginLeft: 4 }, - cards: { borderWidth: 1, borderRadius: radius.field, overflow: 'hidden' }, + cards: { borderWidth: 2, borderRadius: radius.field, overflow: 'hidden' }, card: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 15, paddingVertical: 11, minHeight: 56 }, cardText: { flex: 1, gap: 2 }, cardLabel: { fontSize: 16.5 }, @@ -219,7 +219,7 @@ const styles = StyleSheet.create({ checkSpace: { width: 20 }, pressed: { opacity: 0.7 }, tags: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 4 }, - empty: { borderWidth: 1, borderStyle: 'dashed', borderRadius: radius.card, padding: 22 }, + empty: { borderWidth: 2, borderStyle: 'dashed', borderRadius: radius.card, padding: 22 }, emptyText: { fontSize: 15, lineHeight: 21, textAlign: 'center' }, turns: { gap: 10 }, turn: { borderWidth: 1, borderRadius: 18, paddingHorizontal: 14, paddingVertical: 11, gap: 5, maxWidth: '92%' }, diff --git a/apps/mobile/src/components/document.tsx b/apps/mobile/src/components/document.tsx index 1af7be7ef..435a800f2 100644 --- a/apps/mobile/src/components/document.tsx +++ b/apps/mobile/src/components/document.tsx @@ -1,5 +1,6 @@ import { Children, Fragment, isValidElement, useRef, useState, type ReactNode } from 'react'; import { Pressable, ScrollView, StyleSheet, View, useWindowDimensions } from 'react-native'; +import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Chip, CiteBadge } from './chip'; @@ -23,6 +24,44 @@ import { useTheme } from '@/theme/use-theme'; const BODY = 15.5; const PINNED_HEIGHT = 54; +// The page fades out over this height above the pinned bar, so text never shows between or behind the buttons. +const PINNED_FADE = 28; + +/** The pinned buttons on a solid strip of the page colour, with the page fading out above it. */ +function PinnedBar({ + actions, + actionWidths, + bottom, + rail, + onHeight, +}: { + actions: ReactNode; + actionWidths?: number[]; + bottom: number; + rail?: boolean; + onHeight: (height: number) => void; +}) { + const { colors } = useTheme(); + return ( + <View pointerEvents="box-none" style={styles.pinned} onLayout={(event) => onHeight(event.nativeEvent.layout.height)}> + <Svg pointerEvents="none" width="100%" height={PINNED_FADE}> + <Defs> + <LinearGradient id="pinned-fade" x1="0" y1="0" x2="0" y2="1"> + <Stop offset="0" stopColor={colors.card} stopOpacity={0} /> + <Stop offset="1" stopColor={colors.card} stopOpacity={1} /> + </LinearGradient> + </Defs> + <Rect width="100%" height={PINNED_FADE} fill="url(#pinned-fade)" /> + </Svg> + <View style={[styles.pinnedBody, rail && styles.pinnedRail, { backgroundColor: colors.card, paddingBottom: bottom }]}> + {/* The pinned buttons share the width equally unless told otherwise, as in the mocks, within the document's column on iPad. */} + <View style={[styles.pinnedRow, rail && styles.pinnedRowRail]}> + {Children.map(unwrapFragment(actions), (action, index) => (action ? <View style={[styles.pinnedItem, actionWidths?.[index] ? { flex: actionWidths[index] } : null]}>{action}</View> : null))} + </View> + </View> + </View> + ); +} /** The scrolling page under the header, leaving room for the pinned actions. */ export function DocumentScreen({ @@ -49,6 +88,8 @@ export function DocumentScreen({ const layout = useLayoutClass(); const { width } = useWindowDimensions(); const bottom = Math.max(insets.bottom - 6, 14); + // The bar grows when a label wraps, so the page and the notice clear its measured height. + const [barHeight, setBarHeight] = useState(PINNED_FADE + PINNED_HEIGHT + bottom); if (outline && layout !== 'phone' && width >= RAIL_MIN_WIDTH) { return ( <RailDocument header={header} actions={actions} actionWidths={actionWidths} notice={notice} testID={testID}> @@ -61,7 +102,7 @@ export function DocumentScreen({ {header} <ScrollView contentInsetAdjustmentBehavior="automatic" - contentContainerStyle={[styles.content, { paddingBottom: (actions ? PINNED_HEIGHT + 24 : 16) + bottom }]}> + contentContainerStyle={[styles.content, { paddingBottom: actions ? barHeight + 8 : 16 + bottom }]}> <OfflineNotice /> {children} </ScrollView> @@ -69,19 +110,12 @@ export function DocumentScreen({ <View accessibilityRole="alert" accessibilityLiveRegion="polite" - style={[styles.notice, { bottom: bottom + (actions ? PINNED_HEIGHT + 12 : 0), backgroundColor: colors.foreground }]} + style={[styles.notice, { bottom: actions ? barHeight - PINNED_FADE + 8 : bottom, backgroundColor: colors.foreground }]} testID="document-notice"> <Text style={[styles.noticeText, { color: colors.background }]}>{notice}</Text> </View> ) : null} - {actions ? ( - <View style={[styles.pinned, { bottom }]}> - {/* The pinned buttons share the width equally unless told otherwise, as in the mocks, within the document's column on iPad. */} - <View style={styles.pinnedRow}> - {Children.map(unwrapFragment(actions), (action, index) => (action ? <View style={[styles.pinnedItem, actionWidths?.[index] ? { flex: actionWidths[index] } : null]}>{action}</View> : null))} - </View> - </View> - ) : null} + {actions ? <PinnedBar actions={actions} actionWidths={actionWidths} bottom={bottom} onHeight={setBarHeight} /> : null} </View> ); } @@ -105,6 +139,7 @@ function RailDocument({ const { colors } = useTheme(); const insets = useSafeAreaInsets(); const bottom = Math.max(insets.bottom - 6, 14); + const [barHeight, setBarHeight] = useState(PINNED_FADE + PINNED_HEIGHT + bottom); const scroll = useRef<ScrollView>(null); const content = useRef<View>(null); const rail = useOutlineRail(content, scroll, useHeaderCover()); @@ -122,7 +157,7 @@ function RailDocument({ scrollEventThrottle={32} onScroll={(event) => rail.onScroll(event.nativeEvent.contentOffset.y)} onContentSizeChange={rail.measure}> - <View ref={content} style={[styles.content, styles.contentRail, { paddingBottom: (actions ? PINNED_HEIGHT + 24 : 16) + bottom }]}> + <View ref={content} style={[styles.content, styles.contentRail, { paddingBottom: actions ? barHeight + 8 : 16 + bottom }]}> <OfflineNotice /> <OutlineProvider outline={rail.outline}>{children}</OutlineProvider> </View> @@ -131,18 +166,12 @@ function RailDocument({ <View accessibilityRole="alert" accessibilityLiveRegion="polite" - style={[styles.notice, { bottom: bottom + (actions ? PINNED_HEIGHT + 12 : 0), backgroundColor: colors.foreground }]} + style={[styles.notice, { bottom: actions ? barHeight - PINNED_FADE + 8 : bottom, backgroundColor: colors.foreground }]} testID="document-notice"> <Text style={[styles.noticeText, { color: colors.background }]}>{notice}</Text> </View> ) : null} - {actions ? ( - <View style={[styles.pinned, styles.pinnedRail, { bottom }]}> - <View style={[styles.pinnedRow, styles.pinnedRowRail]}> - {Children.map(unwrapFragment(actions), (action, index) => (action ? <View style={[styles.pinnedItem, actionWidths?.[index] ? { flex: actionWidths[index] } : null]}>{action}</View> : null))} - </View> - </View> - ) : null} + {actions ? <PinnedBar actions={actions} actionWidths={actionWidths} bottom={bottom} rail onHeight={setBarHeight} /> : null} </View> </View> </View> @@ -156,7 +185,7 @@ function unwrapFragment(node: ReactNode): ReactNode { export function DocumentTitle({ title, meta }: { title: string; meta?: string }) { return ( <View style={styles.titleBlock}> - <Text accessibilityRole="header" weight="bold" style={styles.title} selectable> + <Text accessibilityRole="header" weight="display" style={styles.title} selectable> {title} </Text> {meta ? ( @@ -240,11 +269,11 @@ export function NumberedItems({ items, testID, outline = false }: { items: Numbe {n} </Text> <View style={styles.itemHeaderText}> - <Text weight={group.open ? 'bold' : 'semibold'} style={group.open ? styles.itemTitleOpen : styles.itemTitle} numberOfLines={group.open ? undefined : 1}> + <Text weight={group.open ? 'bold' : 'semibold'} style={group.open ? styles.itemTitleOpen : styles.itemTitle} numberOfLines={group.open ? undefined : 2}> {item.title} </Text> {group.open ? null : ( - <Text tone="faint" style={styles.itemSummary} numberOfLines={1}> + <Text tone="faint" style={styles.itemSummary} numberOfLines={2}> {item.summary} </Text> )} @@ -313,7 +342,7 @@ export function SectionHeading({ title, description }: { title: string; descript const anchor = useOutlineAnchor(title); return ( <View ref={anchor} style={[styles.sectionHead, styles.sectionHeading]}> - <Text accessibilityRole="header" weight="bold" style={styles.sectionTitle}> + <Text accessibilityRole="header" weight="bold" tone="heading" style={styles.sectionTitle}> {title} </Text> {description ? ( @@ -332,7 +361,7 @@ export function DocSection({ title, description, children, testID }: { title: st return ( <View ref={anchor} style={styles.section} testID={testID}> <View style={styles.sectionHead}> - <Text accessibilityRole="header" weight="bold" style={styles.sectionTitle}> + <Text accessibilityRole="header" weight="bold" tone="heading" style={styles.sectionTitle}> {title} </Text> {description ? ( @@ -409,7 +438,8 @@ export function Callout({ title, children }: { title: string; children: ReactNod const styles = StyleSheet.create({ root: { flex: 1 }, content: { paddingHorizontal: 18, paddingTop: 4, gap: 12, width: '100%', maxWidth: 680, alignSelf: 'center' }, - pinned: { position: 'absolute', left: 0, right: 0, alignItems: 'center' }, + pinned: { position: 'absolute', left: 0, right: 0, bottom: 0 }, + pinnedBody: { width: '100%', alignItems: 'center', paddingTop: 2 }, pinnedRow: { flexDirection: 'row', gap: 10, width: '100%', maxWidth: 680, paddingHorizontal: 14 }, pinnedItem: { flex: 1 }, railRow: { flex: 1, flexDirection: 'row' }, @@ -424,12 +454,12 @@ const styles = StyleSheet.create({ titleBlock: { gap: 6, marginBottom: 4 }, title: { fontSize: 30, lineHeight: 36, letterSpacing: -0.8 }, meta: { fontSize: 15, lineHeight: 20 }, - framing: { borderWidth: 1, borderRadius: radius.card + 1, paddingHorizontal: 16, paddingVertical: 14, gap: 12 }, + framing: { borderWidth: 2, borderRadius: radius.card + 1, paddingHorizontal: 16, paddingVertical: 14, gap: 12 }, framingBlock: { gap: 4 }, framingLabel: { fontSize: 15 }, framingText: { fontSize: 15.5, lineHeight: 22.5 }, items: { gap: 10 }, - itemCard: { borderWidth: 1, borderRadius: radius.card + 1, overflow: 'hidden' }, + itemCard: { borderWidth: 2, borderRadius: radius.card + 1, overflow: 'hidden' }, itemHeader: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 13, minHeight: 64 }, itemNumber: { fontSize: 17, minWidth: 14 }, itemNumberOpen: { alignSelf: 'flex-start', marginTop: 1 }, @@ -449,7 +479,7 @@ const styles = StyleSheet.create({ sectionHeading: { marginTop: 8 }, sectionTitle: { fontSize: 19, lineHeight: 25, letterSpacing: -0.2 }, sectionDescription: { fontSize: 14, lineHeight: 19 }, - sectionCard: { borderWidth: 1, borderRadius: radius.card + 1, padding: 16, gap: 12 }, + sectionCard: { borderWidth: 2, borderRadius: radius.card + 1, padding: 16, gap: 12 }, subHeading: { fontSize: 14, lineHeight: 19 }, bullets: { gap: 6 }, bullet: { flexDirection: 'row', gap: 8 }, diff --git a/apps/mobile/src/components/edge-fade.tsx b/apps/mobile/src/components/edge-fade.tsx new file mode 100644 index 000000000..a82e7c74f --- /dev/null +++ b/apps/mobile/src/components/edge-fade.tsx @@ -0,0 +1,60 @@ +import { useState, type ReactNode } from 'react'; +import { ScrollView, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native'; +import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg'; + +const FADE_WIDTH = 36; + +/** + * A sideways-scrolling row that fades out at its trailing edge while it + * continues off screen, so it reads as scrollable rather than cut off. When + * everything fits there is no fade. `color` is the surface behind the row. + */ +export function FadingScrollRow({ + color, + id, + style, + contentContainerStyle, + keyboardShouldPersistTaps, + children, +}: { + color: string; + /** Unique on the screen: the fade's SVG gradient id. */ + id: string; + style?: StyleProp<ViewStyle>; + contentContainerStyle?: StyleProp<ViewStyle>; + keyboardShouldPersistTaps?: 'handled' | 'always' | 'never'; + children: ReactNode; +}) { + const [frame, setFrame] = useState(0); + const [content, setContent] = useState(0); + const overflows = content > frame + 1; + return ( + <View style={style} onLayout={(event) => setFrame(event.nativeEvent.layout.width)}> + <ScrollView + horizontal + showsHorizontalScrollIndicator={false} + keyboardShouldPersistTaps={keyboardShouldPersistTaps} + contentContainerStyle={contentContainerStyle} + onContentSizeChange={(width) => setContent(width)}> + {children} + </ScrollView> + {overflows ? ( + <View pointerEvents="none" style={styles.fade}> + <Svg width={FADE_WIDTH} height="100%"> + <Defs> + <LinearGradient id={id} x1="0" y1="0" x2="1" y2="0"> + <Stop offset="0" stopColor={color} stopOpacity={0} /> + <Stop offset="1" stopColor={color} stopOpacity={1} /> + </LinearGradient> + </Defs> + <Rect width={FADE_WIDTH} height="100%" fill={`url(#${id})`} /> + </Svg> + </View> + ) : null} + </View> + ); +} + +const styles = StyleSheet.create({ + fade: { position: 'absolute', top: 0, bottom: 0, right: 0, width: FADE_WIDTH }, +}); diff --git a/apps/mobile/src/components/empty-state.tsx b/apps/mobile/src/components/empty-state.tsx new file mode 100644 index 000000000..119579234 --- /dev/null +++ b/apps/mobile/src/components/empty-state.tsx @@ -0,0 +1,33 @@ +import type { ReactNode } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { Card } from './card'; +import { Owl, type OwlPose } from './owl'; +import { Text } from './text'; + +/** The website's EmptyState: the owl in the place's pose above a title, a line and an optional action. */ +export function EmptyState({ pose, title, description, children }: { pose: OwlPose; title: string; description?: string; children?: ReactNode }) { + return ( + <Card style={styles.card}> + <Owl pose={pose} size={88} /> + <View style={styles.text}> + <Text weight="bold" tone="heading" style={styles.title}> + {title} + </Text> + {description ? ( + <Text tone="secondary" style={styles.description}> + {description} + </Text> + ) : null} + </View> + {children} + </Card> + ); +} + +const styles = StyleSheet.create({ + card: { alignItems: 'center', paddingVertical: 22 }, + text: { alignItems: 'center', gap: 4 }, + title: { fontSize: 17, lineHeight: 22, textAlign: 'center' }, + description: { fontSize: 15, lineHeight: 21, textAlign: 'center', maxWidth: 420 }, +}); diff --git a/apps/mobile/src/components/factoids-document.tsx b/apps/mobile/src/components/factoids-document.tsx index 8187127f2..197fe076b 100644 --- a/apps/mobile/src/components/factoids-document.tsx +++ b/apps/mobile/src/components/factoids-document.tsx @@ -121,7 +121,7 @@ export function FactoidsDocument({ accessibilityLabel={t('factoidsResults.verificationStatus')} accessibilityValue={{ min: 0, max: 100, now: share }} style={[styles.track, { backgroundColor: colors.line }]}> - <View style={[styles.fill, { width: `${share}%`, backgroundColor: colors.primary }]} /> + <View style={[styles.fill, { width: `${share}%`, backgroundColor: colors.primaryFill }]} /> </View> <Text weight="semibold" style={styles.percent}>{`${share}%`}</Text> </View> @@ -133,7 +133,7 @@ export function FactoidsDocument({ </DocSection> ) : null} - <SectionHeading title={`${t('factoidsResults.found')} ${shown.length} ${t('factoidsResults.factoids')}`} description={t('factoidsResults.factoidsDesc')} /> + <SectionHeading title={t('factoidsResults.foundCount', { count: shown.length })} description={t('factoidsResults.factoidsDesc')} /> {items.length > 0 ? ( <NumberedItems items={items} testID="factoids-items" /> ) : ( diff --git a/apps/mobile/src/components/field.tsx b/apps/mobile/src/components/field.tsx index dc11e8561..2a57d27d2 100644 --- a/apps/mobile/src/components/field.tsx +++ b/apps/mobile/src/components/field.tsx @@ -1,4 +1,4 @@ -import { forwardRef } from 'react'; +import { forwardRef, useState } from 'react'; import { StyleSheet, TextInput, View, type TextInputProps } from 'react-native'; import { Text } from './text'; @@ -10,8 +10,10 @@ import { useTheme } from '@/theme/use-theme'; type Props = TextInputProps & { label: string; hint?: string; invalid?: boolean }; /** A labelled text field in an inset card, iOS form style. */ -export const Field = forwardRef<TextInput, Props>(function Field({ label, hint, invalid, style, ...props }, ref) { +export const Field = forwardRef<TextInput, Props>(function Field({ label, hint, invalid, style, onFocus, onBlur, ...props }, ref) { const { colors } = useTheme(); + // The website's field: a 2px ink edge that turns brand blue while focused. + const [focused, setFocused] = useState(false); return ( <View style={styles.wrap}> <Text weight="medium" tone="secondary" style={styles.label}> @@ -21,13 +23,21 @@ export const Field = forwardRef<TextInput, Props>(function Field({ label, hint, ref={ref} accessibilityLabel={label} accessibilityHint={hint} - placeholderTextColor={colors.tabInactive} + placeholderTextColor={colors.faint} + onFocus={(event) => { + setFocused(true); + onFocus?.(event); + }} + onBlur={(event) => { + setFocused(false); + onBlur?.(event); + }} {...props} style={[ styles.input, { backgroundColor: colors.card, - borderColor: invalid ? colors.destructive : colors.border, + borderColor: invalid ? colors.destructive : focused ? colors.primaryFill : colors.inputBorder, color: colors.foreground, }, style, @@ -47,7 +57,7 @@ const styles = StyleSheet.create({ label: { fontSize: 14, marginLeft: 4 }, input: { minHeight: 50, - borderWidth: 1, + borderWidth: 2, borderRadius: radius.field, paddingHorizontal: 15, fontSize: 17, diff --git a/apps/mobile/src/components/flowchart-views.tsx b/apps/mobile/src/components/flowchart-views.tsx index e7ddcdbf5..7f7a4fffe 100644 --- a/apps/mobile/src/components/flowchart-views.tsx +++ b/apps/mobile/src/components/flowchart-views.tsx @@ -20,21 +20,21 @@ export function useFlowColors() { return { type: { thesis: colors.primary, - contention: dark ? '#34d399' : '#10b981', - attack: dark ? '#f87171' : '#ef4444', - rebuttal: dark ? '#60a5fa' : '#3b82f6', - dropped: dark ? '#fbbf24' : '#f59e0b', + contention: dark ? '#2FD39A' : '#14B37D', + attack: dark ? '#FF6B70' : '#F0454B', + rebuttal: dark ? '#4CA6FD' : '#1E8DFB', + dropped: dark ? '#FFC414' : '#F39A00', } satisfies Record<FlowNodeType, string>, /** Column headings and link labels, a shade darker than the rules for contrast. */ heading: { yourCase: colors.primary, - attacks: dark ? '#f87171' : '#dc2626', - rebuttals: dark ? '#60a5fa' : '#2563eb', + attacks: dark ? '#FF6B70' : '#D23239', + rebuttals: dark ? '#4CA6FD' : '#0A6FF0', }, via: { support: colors.faint, - attack: dark ? '#f87171' : '#dc2626', - rebuttal: dark ? '#60a5fa' : '#2563eb', + attack: dark ? '#FF6B70' : '#D23239', + rebuttal: dark ? '#4CA6FD' : '#0A6FF0', } satisfies Record<FlowEdgeKind, string>, cardFill: dark ? colors.card : colors.input, }; @@ -68,7 +68,7 @@ export function FlowCard({ node, meta, onPress, testID, selected = false }: { no {node.data.label} </Text> {meta ? ( - <Text tone="faint" style={styles.cardMeta} numberOfLines={1}> + <Text tone="faint" style={styles.cardMeta} numberOfLines={2}> {meta} </Text> ) : null} @@ -164,7 +164,7 @@ export function MapTreeView({ doc, onOpen, selectedId }: { doc: FlowchartDocumen } const styles = StyleSheet.create({ - card: { borderWidth: 1, borderLeftWidth: 4, borderRadius: radius.field, paddingHorizontal: 11, paddingVertical: 10, marginBottom: 10 }, + card: { borderWidth: 2, borderLeftWidth: 4, borderRadius: radius.field, paddingHorizontal: 11, paddingVertical: 10, marginBottom: 10 }, cardLabel: { fontSize: 14.5, lineHeight: 19 }, cardMeta: { fontSize: 12.5, lineHeight: 17, marginTop: 3 }, pressed: { opacity: 0.7 }, diff --git a/apps/mobile/src/components/form-rows.tsx b/apps/mobile/src/components/form-rows.tsx index 67eafbb4a..75e4e542c 100644 --- a/apps/mobile/src/components/form-rows.tsx +++ b/apps/mobile/src/components/form-rows.tsx @@ -1,6 +1,6 @@ import { useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { Pressable, StyleSheet, Switch, View } from 'react-native'; +import { Platform, Pressable, StyleSheet, Switch, View } from 'react-native'; import { ActionMenu } from './action-menu'; import { Icon } from './icon'; @@ -67,10 +67,11 @@ export function SelectRow<T extends string>({ testID={testID} style={({ pressed }) => [pressed && styles.pressed, disabled && styles.disabled]}> <RowFrame first={first}> - <Text style={styles.rowLabel} numberOfLines={1}> + <Text style={styles.rowLabel}> {label} </Text> - <Text tone="secondary" style={styles.rowValue} numberOfLines={1}> + {/* A long choice (Hungarian, Spanish) wraps onto a second line rather than being cut off. */} + <Text tone="secondary" style={styles.rowValue} numberOfLines={2}> {chosen} </Text> <Icon name="chevronDown" color={colors.tabInactive} size={18} /> @@ -106,16 +107,25 @@ export function SwitchRow({ label, description, value, onChange, first, disabled value={value} onValueChange={onChange} disabled={disabled} - trackColor={{ true: colors.primary, false: colors.segmentTrack }} + trackColor={{ true: colors.primaryFill, false: colors.segmentTrack }} + thumbColor="#FFFFFF" + {...WEB_SWITCH_THUMB} /> </RowFrame> ); } +/** + * React Native Web draws a switched-on thumb in its own teal unless told + * otherwise; iOS takes thumbColor for both states. Web-only, so not in the + * native types. + */ +export const WEB_SWITCH_THUMB = (Platform.OS === 'web' ? { activeThumbColor: '#FFFFFF' } : {}) as object; + const styles = StyleSheet.create({ group: { gap: 7 }, groupLabel: { fontSize: 14, marginLeft: 4 }, - groupCard: { borderWidth: 1, borderRadius: radius.field, overflow: 'hidden' }, + groupCard: { borderWidth: 2, borderRadius: radius.field, overflow: 'hidden' }, footer: { fontSize: 13, lineHeight: 18, marginLeft: 4 }, row: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 15, minHeight: 50, paddingVertical: 8 }, rowLabel: { fontSize: 16.5, flexShrink: 1 }, diff --git a/apps/mobile/src/components/library-header.web.tsx b/apps/mobile/src/components/library-header.web.tsx index a5bdda338..55ba59a8e 100644 --- a/apps/mobile/src/components/library-header.web.tsx +++ b/apps/mobile/src/components/library-header.web.tsx @@ -20,7 +20,7 @@ export function LibraryHeader({ title, onAdd, disabled }: { title: string; onAdd ]; return ( <View style={styles.header}> - <Text accessibilityRole="header" weight="bold" style={styles.title}> + <Text accessibilityRole="header" weight="display" style={styles.title}> {title} </Text> <Pressable @@ -30,7 +30,7 @@ export function LibraryHeader({ title, onAdd, disabled }: { title: string; onAdd disabled={disabled} onPress={() => setOpen((value) => !value)} testID="library-add" - style={[styles.plus, { backgroundColor: colors.primary }, disabled && styles.disabled]}> + style={[styles.plus, { backgroundColor: colors.primaryFill }, disabled && styles.disabled]}> <Icon name="plus" color={colors.onPrimary} size={24} /> </Pressable> {open ? ( @@ -71,7 +71,7 @@ const styles = StyleSheet.create({ borderRadius: 16, borderWidth: StyleSheet.hairlineWidth, overflow: 'hidden', - boxShadow: '0 14px 40px -10px rgba(15,23,42,0.4)', + boxShadow: '0 14px 40px -10px rgba(11,42,110,0.4)', }, item: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 17, minHeight: 52 }, itemLabel: { fontSize: 17 }, diff --git a/apps/mobile/src/components/owl.tsx b/apps/mobile/src/components/owl.tsx new file mode 100644 index 000000000..611e675e5 --- /dev/null +++ b/apps/mobile/src/components/owl.tsx @@ -0,0 +1,160 @@ +import { useEffect, useRef, useState } from 'react'; +import { AccessibilityInfo, Animated, Easing, Image, type ImageSourcePropType, type ImageStyle, StyleSheet, View } from 'react-native'; + +import { useTheme } from '@/theme/use-theme'; + +/** + * Ollie, the website's owl (src/components/brand/Owl.tsx), from PNGs that + * scripts/render-owls.ts renders from the website's SVGs. Each place uses the + * pose the website uses there: point (an empty start), coach (the Ask coach), + * arguing (the Sparring opponent), reading (the Library), sleepy (nothing + * saved yet), surprised (no match, an error), celebrating (done), timekeeper. + */ +export type OwlPose = + | 'point' + | 'idea' + | 'skeptical' + | 'surprised' + | 'celebrating' + | 'thinking' + | 'reading' + | 'timekeeper' + | 'arguing' + | 'coach' + | 'sleepy'; + +const POSES: Record<OwlPose, ImageSourcePropType> = { + point: require('@/assets/owl/point.png'), + idea: require('@/assets/owl/idea.png'), + skeptical: require('@/assets/owl/skeptical.png'), + surprised: require('@/assets/owl/surprised.png'), + celebrating: require('@/assets/owl/celebrating.png'), + thinking: require('@/assets/owl/thinking.png'), + reading: require('@/assets/owl/reading.png'), + timekeeper: require('@/assets/owl/timekeeper.png'), + arguing: require('@/assets/owl/arguing.png'), + coach: require('@/assets/owl/coach.png'), + sleepy: require('@/assets/owl/sleepy.png'), +}; + +const MARK: ImageSourcePropType = require('@/assets/owl/mark.png'); + +type OwlProps = { + pose: OwlPose; + size: number; + /** Faces the other way, as the website mirrors the owl to point across at an action. */ + mirrored?: boolean; + /** Read out by VoiceOver; without it the owl is decoration. */ + label?: string; + style?: ImageStyle; +}; + +export function Owl({ pose, size, mirrored, label, style }: OwlProps) { + return ( + <Image + source={POSES[pose]} + accessible={Boolean(label)} + accessibilityRole={label ? 'image' : undefined} + accessibilityLabel={label} + accessibilityElementsHidden={!label} + importantForAccessibility={label ? 'yes' : 'no-hide-descendants'} + style={[{ width: size, height: size }, mirrored && styles.mirrored, style]} + resizeMode="contain" + /> + ); +} + +/** The owl's head in a tinted circle: the Ask coach and the Sparring opponent beside their messages. */ +export function OwlAvatar({ pose, size, label }: { pose: OwlPose; size: number; label?: string }) { + const { scheme } = useTheme(); + // The website zooms the full pose 1.6x about 52% across and 32% down, onto the head and shoulders. + const zoomed = size * 1.6; + return ( + <View + accessible={Boolean(label)} + accessibilityRole={label ? 'image' : undefined} + accessibilityLabel={label} + accessibilityElementsHidden={!label} + importantForAccessibility={label ? 'yes' : 'no-hide-descendants'} + style={[ + styles.avatar, + { width: size, height: size, borderRadius: size / 2 }, + scheme === 'dark' ? styles.avatarDark : styles.avatarLight, + ]}> + <Image + source={POSES[pose]} + resizeMode="contain" + style={{ position: 'absolute', width: zoomed, height: zoomed, left: -0.52 * (zoomed - size), top: -0.32 * (zoomed - size) }} + /> + </View> + ); +} + +/** The owl mark on its tile, as the website's sign-in and header show it. */ +export function OwlMark({ size }: { size: number }) { + const { scheme } = useTheme(); + return ( + <View + accessibilityElementsHidden + importantForAccessibility="no-hide-descendants" + style={[styles.mark, { width: size, height: size + 2, borderRadius: size * 0.28, backgroundColor: scheme === 'dark' ? '#061442' : '#B3D8FC' }]}> + <Image source={MARK} style={{ width: size, height: size, borderRadius: size * 0.28 }} /> + </View> + ); +} + +/** Whether iOS's Reduce Motion is on, kept current while the screen shows. */ +function useReduceMotion(): boolean { + const [reduce, setReduce] = useState(false); + useEffect(() => { + let active = true; + void AccessibilityInfo.isReduceMotionEnabled().then((value) => { + if (active) setReduce(value); + }); + const subscription = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce); + return () => { + active = false; + subscription.remove(); + }; + }, []); + return reduce; +} + +/** The owl bobbing gently while a request runs, where the website shows OwlLoader; still under Reduce Motion. */ +export function OwlLoader({ pose, size, label }: { pose: OwlPose; size: number; label?: string }) { + const bob = useRef(new Animated.Value(0)).current; + const reduceMotion = useReduceMotion(); + useEffect(() => { + if (reduceMotion) { + bob.setValue(0); + return; + } + const loop = Animated.loop( + Animated.sequence([ + Animated.timing(bob, { toValue: 1, duration: 600, easing: Easing.inOut(Easing.quad), useNativeDriver: true }), + Animated.timing(bob, { toValue: 0, duration: 600, easing: Easing.inOut(Easing.quad), useNativeDriver: true }), + ]), + ); + loop.start(); + return () => loop.stop(); + }, [bob, reduceMotion]); + return ( + // Labelled, it is the progress indicator; unlabelled, a progress line beside it speaks and the owl is decoration. + <Animated.View + accessibilityRole={label ? 'progressbar' : undefined} + accessibilityLabel={label} + accessibilityElementsHidden={!label} + importantForAccessibility={label ? 'yes' : 'no-hide-descendants'} + style={{ transform: [{ translateY: bob.interpolate({ inputRange: [0, 1], outputRange: [0, -size * 0.05] }) }] }}> + <Owl pose={pose} size={size} /> + </Animated.View> + ); +} + +const styles = StyleSheet.create({ + mirrored: { transform: [{ scaleX: -1 }] }, + avatar: { overflow: 'hidden', borderWidth: 2 }, + avatarLight: { backgroundColor: '#D6EAFE', borderColor: '#FFFFFF' }, + avatarDark: { backgroundColor: '#0B2A6E', borderColor: '#131E33' }, + mark: { overflow: 'hidden' }, +}); diff --git a/apps/mobile/src/components/round-controls.tsx b/apps/mobile/src/components/round-controls.tsx index 6d19cfd32..419607a90 100644 --- a/apps/mobile/src/components/round-controls.tsx +++ b/apps/mobile/src/components/round-controls.tsx @@ -1,4 +1,3 @@ -import { LinearGradient } from 'expo-linear-gradient'; import type { ReactNode } from 'react'; import { Pressable, StyleSheet, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -7,7 +6,6 @@ import { Icon } from './icon'; import type { IconName } from './icon-paths'; import { Text } from './text'; -import { mainActionGradient } from '@/theme/colors'; import { useTheme } from '@/theme/use-theme'; /** The round button in a live screen's top bar, as mock 05's close and flow buttons. */ @@ -33,7 +31,7 @@ export function LiveTopBar({ left, right, title, subtitle }: { left: ReactNode; <View style={[styles.bar, { paddingTop: insets.top + 6 }]}> <View style={styles.side}>{left}</View> <View style={styles.titles}> - <Text accessibilityRole="header" weight="bold" style={styles.title} numberOfLines={1}> + <Text accessibilityRole="header" weight="display" style={styles.title} numberOfLines={1}> {title} </Text> {subtitle ? ( @@ -69,8 +67,9 @@ export function SideControl({ icon, label, onPress, disabled, testID }: { icon: ); } -/** The one main action of a live screen, in the gradient, with its label under it. */ +/** The one main action of a live screen, a round tactile button on its ledge, with its label under it. */ export function MainRoundControl({ icon, label, onPress, disabled, testID }: { icon: IconName; label: string; onPress: () => void; disabled?: boolean; testID?: string }) { + const { colors } = useTheme(); return ( <Pressable accessibilityRole="button" @@ -79,20 +78,24 @@ export function MainRoundControl({ icon, label, onPress, disabled, testID }: { i disabled={disabled} onPress={onPress} testID={testID} - style={({ pressed }) => [styles.control, pressed && styles.pressed]}> - <View style={[styles.mainHalo, disabled && styles.mainDisabled]}> - <View style={styles.mainCircle}> - {/* The gradient carries the rounding, so the circle's shadow is not clipped. */} - <LinearGradient {...mainActionGradient} style={[StyleSheet.absoluteFill, styles.mainGradient]} /> - {/* Above the gradient: the web preview paints positioned views over static ones. */} - <View style={styles.mainIcon}> - <Icon name={icon} color="#ffffff" size={32} /> + style={styles.control}> + {({ pressed }) => ( + <> + <View style={[styles.mainHalo, disabled && styles.mainDisabled]}> + <View + style={[ + styles.mainCircle, + { backgroundColor: colors.primaryFill, borderBottomColor: colors.primaryLedge }, + pressed && styles.mainPressed, + ]}> + <Icon name={icon} color="#ffffff" size={32} /> + </View> </View> - </View> - </View> - <Text tone="faint" style={[styles.controlLabel, styles.mainLabel]} numberOfLines={2}> - {label} - </Text> + <Text tone="faint" style={[styles.controlLabel, styles.mainLabel]} numberOfLines={2}> + {label} + </Text> + </> + )} </Pressable> ); } @@ -112,16 +115,17 @@ const styles = StyleSheet.create({ mainLabel: { maxWidth: 150, textAlign: 'center' }, sideCircle: { width: 56, height: 56, borderRadius: 28, alignItems: 'center', justifyContent: 'center' }, sideBorder: { borderWidth: 1 }, - mainHalo: { width: 100, height: 100, borderRadius: 50, alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(56,189,248,0.14)' }, - mainGradient: { borderRadius: 42 }, + mainHalo: { width: 100, height: 100, borderRadius: 50, alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(10,111,240,0.12)' }, mainDisabled: { opacity: 0.45 }, - mainIcon: { position: 'relative' }, mainCircle: { width: 84, height: 84, borderRadius: 42, + borderBottomWidth: 5, alignItems: 'center', justifyContent: 'center', - boxShadow: '0 10px 30px -6px rgba(20,184,166,0.6)', + boxShadow: '0 10px 26px -8px rgba(11,42,110,0.45)', }, + // Sinks onto its ledge, as the website's buttons do. + mainPressed: { borderBottomWidth: 0, marginTop: 5, boxShadow: 'none' }, }); diff --git a/apps/mobile/src/components/screen-title.web.tsx b/apps/mobile/src/components/screen-title.web.tsx index 812696fab..ed03697d4 100644 --- a/apps/mobile/src/components/screen-title.web.tsx +++ b/apps/mobile/src/components/screen-title.web.tsx @@ -5,12 +5,12 @@ import { Text } from './text'; /** The web preview has no native header, so the large title is drawn in the content. */ export function ScreenTitle({ title }: { title: string }) { return ( - <Text accessibilityRole="header" weight="bold" style={styles.title}> + <Text accessibilityRole="header" weight="display" style={styles.title}> {title} </Text> ); } const styles = StyleSheet.create({ - title: { fontSize: 34, letterSpacing: -1, lineHeight: 41, marginTop: 8, marginBottom: 8 }, + title: { fontSize: 34, letterSpacing: -0.5, lineHeight: 41, marginTop: 8, marginBottom: 8 }, }); diff --git a/apps/mobile/src/components/segmented-control.tsx b/apps/mobile/src/components/segmented-control.tsx index c7e7d556a..5500a568f 100644 --- a/apps/mobile/src/components/segmented-control.tsx +++ b/apps/mobile/src/components/segmented-control.tsx @@ -49,6 +49,6 @@ const styles = StyleSheet.create({ track: { flexDirection: 'row', borderRadius: 11, padding: 2 }, segment: { flex: 1, alignItems: 'center', justifyContent: 'center', minHeight: 36, borderRadius: 9 }, selected: {}, - selectedShadow: { boxShadow: '0 1px 3px rgba(15,23,42,0.12)' }, + selectedShadow: { boxShadow: '0 1px 3px rgba(11,42,110,0.12)' }, label: { fontSize: 14 }, }); diff --git a/apps/mobile/src/components/source-view.tsx b/apps/mobile/src/components/source-view.tsx index 941208c31..25844ff42 100644 --- a/apps/mobile/src/components/source-view.tsx +++ b/apps/mobile/src/components/source-view.tsx @@ -86,7 +86,7 @@ export function SourceView({ id, onDone }: { id: string; onDone?: () => void }) </Text> ) : ( <> - <Text accessibilityRole="header" weight="bold" style={styles.title} selectable> + <Text accessibilityRole="header" weight="display" style={styles.title} selectable> {source.title || t('libraryPage.untitledSource')} </Text> {url ? ( @@ -125,7 +125,7 @@ const styles = StyleSheet.create({ loading: { marginTop: 32 }, body: { fontSize: 15, lineHeight: 21 }, title: { fontSize: 23, lineHeight: 29, letterSpacing: -0.4 }, - origin: { flexDirection: 'row', alignItems: 'center', gap: 9, borderWidth: 1, borderRadius: 12, paddingHorizontal: 13, minHeight: 46 }, + origin: { flexDirection: 'row', alignItems: 'center', gap: 9, borderWidth: 2, borderRadius: 12, paddingHorizontal: 13, minHeight: 46 }, originText: { flex: 1, fontSize: 15 }, pressed: { opacity: 0.7 }, text: { fontSize: 16, lineHeight: 24 }, diff --git a/apps/mobile/src/components/sparring-views.tsx b/apps/mobile/src/components/sparring-views.tsx index 5b73d1ac3..c1461e4fd 100644 --- a/apps/mobile/src/components/sparring-views.tsx +++ b/apps/mobile/src/components/sparring-views.tsx @@ -2,7 +2,6 @@ import type { SparringScorecard, SparringSpeech } from '@debatelab/core/ai/schem import { formatRoundTime } from '@debatelab/core/sparring/formats'; import type { SparringSession } from '@debatelab/core/sparring/session'; import type { TFunction } from 'i18next'; -import { LinearGradient } from 'expo-linear-gradient'; import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { StyleSheet, View } from 'react-native'; @@ -17,7 +16,7 @@ import { backLabel } from '@/lib/back-label'; import { confirmDestructive } from '@/lib/confirm'; import { shareText } from '@/lib/export'; import { deleteSparringRound } from '@/lib/sparring'; -import { mainActionGradient, radius } from '@/theme/colors'; +import { radius } from '@/theme/colors'; import { useTheme } from '@/theme/use-theme'; const CATEGORY_MAX = 25; @@ -134,7 +133,7 @@ export function SparringScorecardView({ session, scorecard, transcriptOpen }: { <Text tone="faint" style={styles.eyebrow}> {t('sparring.scorecard.eyebrow')} </Text> - <Text accessibilityRole="header" weight="bold" style={styles.title}> + <Text accessibilityRole="header" weight="display" style={styles.title}> {winnerText(t, scorecard.winner)} </Text> <Text tone="faint" style={styles.motion} numberOfLines={3}> @@ -143,8 +142,10 @@ export function SparringScorecardView({ session, scorecard, transcriptOpen }: { </View> <View style={styles.totals}> - <View style={[styles.total, styles.totalOwn]} accessible accessibilityLabel={`${t('sparring.scorecard.yourScore')}: ${scorecard.userScore}`}> - <LinearGradient {...mainActionGradient} style={StyleSheet.absoluteFill} /> + <View + style={[styles.total, styles.totalOwn, { backgroundColor: colors.primaryFill, borderBottomColor: colors.primaryLedge }]} + accessible + accessibilityLabel={`${t('sparring.scorecard.yourScore')}: ${scorecard.userScore}`}> <Text style={[styles.totalLabel, styles.onGradient]}>{t('sparring.scorecard.yourScore')}</Text> <Text weight="bold" style={[styles.totalValue, styles.onGradient]} testID="sparring-user-score"> {scorecard.userScore} @@ -184,7 +185,7 @@ export function SparringScorecardView({ session, scorecard, transcriptOpen }: { </Text> </View> <View style={[styles.track, { backgroundColor: colors.line }]}> - <View style={[styles.fill, { width: `${(category.userScore / CATEGORY_MAX) * 100}%`, backgroundColor: colors.primary }]} /> + <View style={[styles.fill, { width: `${(category.userScore / CATEGORY_MAX) * 100}%`, backgroundColor: colors.primaryFill }]} /> {/* The opponent's score is the tick on the same bar. */} <View style={[styles.tick, { left: `${(category.aiScore / CATEGORY_MAX) * 100}%`, backgroundColor: colors.secondary }]} /> </View> @@ -281,12 +282,12 @@ const styles = StyleSheet.create({ motion: { fontSize: 15, lineHeight: 20, marginTop: 2 }, totals: { flexDirection: 'row', gap: 10 }, total: { flex: 1, borderRadius: radius.card + 2, paddingHorizontal: 15, paddingVertical: 13, overflow: 'hidden', gap: 2 }, - totalOwn: { borderWidth: 0 }, - totalBorder: { borderWidth: 1 }, + totalOwn: { borderWidth: 0, borderBottomWidth: 4 }, + totalBorder: { borderWidth: 2 }, totalLabel: { fontSize: 14.5 }, - onGradient: { color: '#ffffff' }, + onGradient: { color: '#FFFFFF' }, totalValue: { fontSize: 38, lineHeight: 44, letterSpacing: -1, fontVariant: ['tabular-nums'] }, - card: { borderWidth: 1, borderRadius: radius.card + 1, paddingHorizontal: 16, paddingVertical: 15, gap: 10 }, + card: { borderWidth: 2, borderRadius: radius.card + 1, paddingHorizontal: 16, paddingVertical: 15, gap: 10 }, cardHead: { gap: 2, marginBottom: 2 }, cardTitle: { fontSize: 16.5 }, cardDescription: { fontSize: 14, lineHeight: 19 }, diff --git a/apps/mobile/src/components/tab-stack.tsx b/apps/mobile/src/components/tab-stack.tsx index 4bb6e1cf7..734828b2f 100644 --- a/apps/mobile/src/components/tab-stack.tsx +++ b/apps/mobile/src/components/tab-stack.tsx @@ -21,8 +21,8 @@ export function TabStack({ largeTitle = true }: { largeTitle?: boolean }) { headerShadowVisible: false, headerLargeTitleShadowVisible: false, headerLargeStyle: { backgroundColor: 'transparent' }, - headerLargeTitleStyle: { fontFamily: fontFamily.bold, color: colors.foreground }, - headerTitleStyle: { fontFamily: fontFamily.semibold, color: colors.foreground }, + headerLargeTitleStyle: { fontFamily: fontFamily.display, color: colors.heading }, + headerTitleStyle: { fontFamily: fontFamily.display, color: colors.heading }, headerTintColor: colors.primary, contentStyle: { backgroundColor: colors.background }, }} diff --git a/apps/mobile/src/components/text.tsx b/apps/mobile/src/components/text.tsx index 330ef92cb..b814f78fd 100644 --- a/apps/mobile/src/components/text.tsx +++ b/apps/mobile/src/components/text.tsx @@ -3,9 +3,11 @@ import { Text as NativeText, type TextProps } from 'react-native'; import { fontFamily, type FontWeight } from '@/theme/fonts'; import { useTheme } from '@/theme/use-theme'; -type Props = TextProps & { weight?: FontWeight; tone?: 'foreground' | 'secondary' | 'faint' }; +type Props = TextProps & { weight?: FontWeight; tone?: 'foreground' | 'heading' | 'secondary' | 'faint' }; -export function Text({ weight = 'regular', tone = 'foreground', style, ...props }: Props) { +/** Body text in Nunito; a display weight (Fredoka) is a heading and takes the heading navy unless a tone is given. */ +export function Text({ weight = 'regular', tone, style, ...props }: Props) { const { colors } = useTheme(); - return <NativeText {...props} style={[{ color: colors[tone], fontFamily: fontFamily[weight] }, style]} />; + const color = colors[tone ?? (weight.startsWith('display') ? 'heading' : 'foreground')]; + return <NativeText {...props} style={[{ color, fontFamily: fontFamily[weight] }, style]} />; } diff --git a/apps/mobile/src/components/timer-ring.tsx b/apps/mobile/src/components/timer-ring.tsx index 7819c470d..f788ac518 100644 --- a/apps/mobile/src/components/timer-ring.tsx +++ b/apps/mobile/src/components/timer-ring.tsx @@ -15,7 +15,8 @@ export function TimerRing({ size, fraction, warning, children }: { size: number; const radius = (size - STROKE) / 2; const circumference = 2 * Math.PI * radius; const left = Math.min(1, Math.max(0, fraction)); - const [from, to] = scheme === 'dark' ? ['#38bdf8', '#2dd4bf'] : ['#0284c7', '#14b8a6']; + // Plume, from the button face to its lighter tint: the website has no gradient, so the ring stays in one hue. + const [from, to] = scheme === 'dark' ? ['#4CA6FD', '#7CC0FE'] : ['#0A6FF0', '#4CA6FD']; return ( <View style={{ width: size, height: size }}> <Svg width={size} height={size} style={styles.rotated}> diff --git a/apps/mobile/src/components/tool-form.tsx b/apps/mobile/src/components/tool-form.tsx index e100c4eab..97ea80b1e 100644 --- a/apps/mobile/src/components/tool-form.tsx +++ b/apps/mobile/src/components/tool-form.tsx @@ -1,7 +1,7 @@ import { useRouter } from 'expo-router'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import { TextButton } from './buttons'; import { OneOffNotice } from './one-off-notice'; @@ -11,12 +11,13 @@ import type { AIErrorKind } from '@/lib/ai-request'; import { useDebates } from '@/lib/debate-context'; import { radius } from '@/theme/colors'; import { useTheme } from '@/theme/use-theme'; +import { OwlLoader, type OwlPose } from '@/components/owl'; /** The tool's large title and the line under it. */ export function ToolTitle({ title, subtitle }: { title: string; subtitle: string }) { return ( <View style={styles.titleBlock}> - <Text accessibilityRole="header" weight="bold" style={styles.title}> + <Text accessibilityRole="header" weight="display" style={styles.title}> {title} </Text> <Text tone="secondary" style={styles.subtitle}> @@ -77,7 +78,8 @@ export function ToolError({ title, error, onRetry }: { title: string; error: AIE } /** A calm progress state that names what is happening, cycling the website's messages. */ -export function ToolProgress({ messages, detail, onCancel }: { messages: string[]; detail?: string; onCancel: () => void }) { +/** A tool at work: the owl in the tool's pose, the website's progress line, the request's subject and Cancel. */ +export function ToolProgress({ messages, detail, onCancel, pose = 'thinking' }: { messages: string[]; detail?: string; onCancel: () => void; pose?: OwlPose }) { const { t } = useTranslation(); const { colors } = useTheme(); const [index, setIndex] = useState(0); @@ -87,8 +89,8 @@ export function ToolProgress({ messages, detail, onCancel }: { messages: string[ }, [messages.length]); return ( <View style={[styles.progress, { borderColor: colors.border, backgroundColor: colors.card }]} testID="tool-progress"> - <ActivityIndicator size="large" color={colors.primary} /> - <Text weight="semibold" style={styles.progressText} accessibilityRole="progressbar" accessibilityLiveRegion="polite"> + <OwlLoader pose={pose} size={96} /> + <Text weight="display" style={styles.progressText} accessibilityRole="progressbar" accessibilityLiveRegion="polite"> {messages[index]} </Text> {detail ? ( @@ -105,13 +107,13 @@ const styles = StyleSheet.create({ titleBlock: { gap: 4, marginBottom: 2 }, title: { fontSize: 30, lineHeight: 36, letterSpacing: -0.8 }, subtitle: { fontSize: 16, lineHeight: 22 }, - strip: { borderWidth: 1, borderRadius: radius.field, paddingHorizontal: 14, paddingVertical: 10 }, + strip: { borderWidth: 2, borderRadius: radius.field, paddingHorizontal: 14, paddingVertical: 10 }, stripText: { fontSize: 14.5, lineHeight: 20 }, - error: { borderWidth: 1, borderRadius: radius.card, padding: 14, gap: 4 }, + error: { borderWidth: 2, borderRadius: radius.card, padding: 14, gap: 4 }, errorTitle: { fontSize: 16 }, errorBody: { fontSize: 15, lineHeight: 21 }, errorActions: { flexDirection: 'row', gap: 20 }, - progress: { borderWidth: 1, borderStyle: 'dashed', borderRadius: radius.card + 2, paddingVertical: 36, paddingHorizontal: 20, alignItems: 'center', gap: 12, marginTop: 8 }, + progress: { borderWidth: 2, borderStyle: 'dashed', borderRadius: radius.card + 2, paddingVertical: 36, paddingHorizontal: 20, alignItems: 'center', gap: 12, marginTop: 8 }, progressText: { fontSize: 18, lineHeight: 24, textAlign: 'center' }, progressDetail: { fontSize: 15, lineHeight: 21, textAlign: 'center' }, }); diff --git a/apps/mobile/src/components/work-row.tsx b/apps/mobile/src/components/work-row.tsx index 2feeb3191..d471a29ac 100644 --- a/apps/mobile/src/components/work-row.tsx +++ b/apps/mobile/src/components/work-row.tsx @@ -56,13 +56,13 @@ export function WorkRow({ item, first, onPress }: { item: DebateItem; first: boo return ( <Pressable accessibilityRole="button" - accessibilityLabel={t('debate.openItemAria', { title: item.title || t(`debate.kinds.${item.kind}`) })} + accessibilityLabel={t('mobile.common.openItem', { title: item.title || t(`debate.kinds.${item.kind}`) })} onPress={onPress} testID="work-row" style={({ pressed }) => [styles.row, !first && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> <IconTile name={KIND_ICONS[item.kind]} size={42} /> <View style={styles.rowText}> - <Text weight="semibold" style={styles.rowTitle} numberOfLines={1}> + <Text weight="semibold" style={styles.rowTitle} numberOfLines={2}> {item.title || t(`debate.kinds.${item.kind}`)} </Text> <Text tone="faint" style={styles.rowMeta} numberOfLines={1}> diff --git a/apps/mobile/src/lib/ask.test.ts b/apps/mobile/src/lib/ask.test.ts index b232d1acd..f336eca63 100644 --- a/apps/mobile/src/lib/ask.test.ts +++ b/apps/mobile/src/lib/ask.test.ts @@ -17,7 +17,7 @@ describe('askOnce', () => { message: 'Why ban homework?', history: [], options: { citationMode: false, webSearch: false, prosConsMode: false, argumentMapMode: false }, - config: { model: 'test-model', temperature: 0.3, reasoningEffort: 'low' }, + config: { model: 'test-model', reasoningEffort: 'low' }, language: 'en', }, controller.signal); controller.abort(); diff --git a/apps/mobile/src/lib/ask.ts b/apps/mobile/src/lib/ask.ts index d912a4f68..43c67c799 100644 --- a/apps/mobile/src/lib/ask.ts +++ b/apps/mobile/src/lib/ask.ts @@ -110,7 +110,6 @@ export function streamAsk(request: AskRequest, handlers: { onDelta: (text: strin ...(request.debateContext ? { debateContext: request.debateContext } : {}), provider: PROVIDER, model: request.config.model, - temperature: request.config.temperature, reasoningEffort: request.config.reasoningEffort, }; return postFunction('ai-stream', body, handlers.signal, async (response) => { @@ -171,7 +170,6 @@ export function askOnce(request: AskRequest, signal?: AbortSignal, onExtras?: (e input: requestInput(request), provider: PROVIDER, model: request.config.model, - temperature: request.config.temperature, reasoningEffort: request.config.reasoningEffort, // Servers from before Current Debate context read only this field. sourceIds: request.debateContext?.sourceIds ?? [], diff --git a/apps/mobile/src/lib/billing-query.ts b/apps/mobile/src/lib/billing-query.ts new file mode 100644 index 000000000..3f7fadc95 --- /dev/null +++ b/apps/mobile/src/lib/billing-query.ts @@ -0,0 +1,6 @@ +import { supabase } from './supabase'; + +/** One user's rows of a table, in the shape the shared billing reader takes (packages/core/src/supabase/billing.ts). */ +export function billingQuery(userId: string) { + return (table: string, columns: string) => supabase.from(table).select(columns).eq('user_id', userId); +} diff --git a/apps/mobile/src/lib/crossfire-quota.test.ts b/apps/mobile/src/lib/crossfire-quota.test.ts new file mode 100644 index 000000000..09c9ff63c --- /dev/null +++ b/apps/mobile/src/lib/crossfire-quota.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest'; + +const postFunction = vi.hoisted(() => vi.fn(async () => undefined)); +vi.mock('./ai-request', async (original) => ({ ...(await original<typeof import('./ai-request')>()), postFunction })); +vi.mock('./supabase', () => ({ supabase: {} })); +vi.mock('expo/fetch', () => ({ fetch: vi.fn() })); +vi.mock('./offline-cache', () => ({ afterWrite: vi.fn(), cachedRead: vi.fn(), readFailed: vi.fn(), updateCopy: vi.fn() })); + +import { crossfireDebrief, crossfireSignal, crossfireTurn } from './crossfire'; + +const options = { config: { model: 'openai/gpt-6-luna', reasoningEffort: 'low' as const }, language: 'en' }; +const config = { resolution: 'This House would ban private cars', userRole: 'questioner', difficulty: 'medium', turnLimit: 6 } as never; +const body = (call: number) => (postFunction.mock.calls[call] as unknown[])[1] as Record<string, unknown>; + +// Main (#201) charges a Crossfire round once: every turn and the debrief carry +// the round's id, so the server's quota ledger charges only the first. +describe('Crossfire quota sessions', () => { + it("sends the round's id with every turn and the debrief, and none with a signal", async () => { + await crossfireTurn({ operation: 'next-turn', config, transcript: [], expectedReply: 'question' }, { ...options, quotaSession: 'crossfire-1793180000000' }); + await crossfireDebrief({ operation: 'debrief', config, transcript: [] }, { ...options, quotaSession: 'crossfire-1793180000000' }); + await crossfireSignal({ config, transcript: [], reply: { type: 'answer', text: 'An answer' }, signalToken: 'token' }, options); + + expect(body(0).quotaSession).toBe('crossfire-1793180000000'); + expect(body(1).quotaSession).toBe('crossfire-1793180000000'); + expect(body(2)).not.toHaveProperty('quotaSession'); + }); + + it('leaves out a key the server would refuse, so the round is charged per call instead of failing', async () => { + postFunction.mockClear(); + await crossfireTurn({ operation: 'next-turn', config, transcript: [], expectedReply: 'question' }, { ...options, quotaSession: 'not a key!' }); + expect(body(0)).not.toHaveProperty('quotaSession'); + }); +}); diff --git a/apps/mobile/src/lib/crossfire.ts b/apps/mobile/src/lib/crossfire.ts index ac5b3b14c..0d4b8ec44 100644 --- a/apps/mobile/src/lib/crossfire.ts +++ b/apps/mobile/src/lib/crossfire.ts @@ -1,3 +1,4 @@ +import { QUOTA_SESSION_KEY } from '@debatelab/core/ai/session-quota'; import { collectDebriefTurnIds, CrossfireDebriefSchema, @@ -45,6 +46,12 @@ interface RequestOptions { /** Absent for one-off work. */ debateContext?: DebateContextRequest; signal?: AbortSignal; + /** + * The round's id, sent beside `input` as the website's crossfire request + * does (src/lib/ai/request.ts), so the server charges the round once + * (SESSION_QUOTA) instead of every turn. Signals are not charged per round. + */ + quotaSession?: string; } // Turn signals and measured delivery facts are client annotations. The Edge @@ -56,10 +63,10 @@ function serverTranscript(turns: CrossfireTurn[]) { function requestBody(input: Record<string, unknown> & { transcript: CrossfireTurn[] }, options: RequestOptions) { return { feature: 'crossfire', + ...(input.operation !== 'signal' && options.quotaSession && QUOTA_SESSION_KEY.test(options.quotaSession) ? { quotaSession: options.quotaSession } : {}), input: { ...input, transcript: serverTranscript(input.transcript), language: options.language }, provider: PROVIDER, model: options.config.model, - temperature: options.config.temperature, reasoningEffort: options.config.reasoningEffort, // Servers from before Current Debate context read only this field. sourceIds: options.debateContext?.sourceIds ?? [], @@ -145,7 +152,7 @@ export function crossfireDebrief( */ export function crossfireSignal( input: { config: CrossfireConfig; transcript: CrossfireTurn[]; reply: CrossfireReply; signalToken: string }, - options: Omit<RequestOptions, 'debateContext'>, + options: Omit<RequestOptions, 'debateContext' | 'quotaSession'>, ): Promise<CrossfireTurnSignal | undefined> { return postFunction('ai', requestBody({ operation: 'signal', ...input }, options), options.signal, async (response) => { const { output } = await functionOutput(response); diff --git a/apps/mobile/src/lib/export-html.ts b/apps/mobile/src/lib/export-html.ts index 8e85490d3..92a36dd74 100644 --- a/apps/mobile/src/lib/export-html.ts +++ b/apps/mobile/src/lib/export-html.ts @@ -11,7 +11,7 @@ type Translate = (key: string, options?: Record<string, unknown>) => string; /** * The PDF pages for expo-print, as HTML. They carry the same sections and * labels as the website's PDF export (src/lib/pdf-export-utils.ts) and keep - * its file names, in DebateLab's colours. + * its file names, in DebateOwl's colours. */ function escape(value: string | undefined | null): string { @@ -20,26 +20,26 @@ function escape(value: string | undefined | null): string { const STYLE = ` @page { margin: 18mm 16mm; } - body { font-family: -apple-system, 'Inter', 'Helvetica Neue', Arial, sans-serif; color: #0f172a; font-size: 11pt; line-height: 1.5; margin: 0; } - header { border-bottom: 3px solid #0284c7; padding-bottom: 10px; margin-bottom: 18px; } + body { font-family: 'Nunito', -apple-system, 'Helvetica Neue', Arial, sans-serif; color: #1F2B40; font-size: 11pt; line-height: 1.5; margin: 0; } + header { border-bottom: 3px solid #0556D6; padding-bottom: 10px; margin-bottom: 18px; } h1 { font-size: 20pt; line-height: 1.2; margin: 0 0 4px; letter-spacing: -0.02em; } - .meta { color: #64748b; font-size: 10pt; } - h2 { font-size: 13pt; margin: 22px 0 8px; padding-bottom: 4px; border-bottom: 1px solid #e2e8f0; color: #0f172a; } + .meta { color: #5F708A; font-size: 10pt; } + h2 { font-size: 13pt; margin: 22px 0 8px; padding-bottom: 4px; border-bottom: 1px solid #E2E9F2; color: #1F2B40; } h3 { font-size: 11pt; margin: 12px 0 4px; } p { margin: 0 0 8px; } - .label { font-weight: 700; color: #475569; } - .framing { background: #e0f2fe; border: 1px solid #bae6fd; border-radius: 10px; padding: 10px 14px; margin-bottom: 14px; color: #0c4a6e; } - .framing .label { color: #075985; } - .block { border-left: 3px solid #0284c7; padding: 2px 0 2px 12px; margin: 0 0 14px; break-inside: avoid; } + .label { font-weight: 700; color: #46566F; } + .framing { background: #EEF7FF; border: 1px solid #B3D8FC; border-radius: 10px; padding: 10px 14px; margin-bottom: 14px; color: #0B2A6E; } + .framing .label { color: #0342A8; } + .block { border-left: 3px solid #0556D6; padding: 2px 0 2px 12px; margin: 0 0 14px; break-inside: avoid; } .block h3 { margin-top: 0; } - .number { color: #0284c7; margin-right: 6px; } + .number { color: #0556D6; margin-right: 6px; } ul { margin: 0 0 8px 18px; padding: 0; } li { margin-bottom: 3px; } - .tag { display: inline-block; border: 1px solid #cbd5e1; border-radius: 6px; padding: 0 6px; font-size: 9pt; font-weight: 700; margin-right: 4px; } - .critical { color: #dc2626; border-color: #dc2626; } - .major { color: #d97706; border-color: #d97706; } - .minor { color: #0284c7; border-color: #0284c7; } - footer { margin-top: 26px; color: #94a3b8; font-size: 9pt; text-align: center; } + .tag { display: inline-block; border: 1px solid #CDD7E4; border-radius: 6px; padding: 0 6px; font-size: 9pt; font-weight: 700; margin-right: 4px; } + .critical { color: #D23239; border-color: #D23239; } + .major { color: #A15C06; border-color: #A15C06; } + .minor { color: #0556D6; border-color: #0556D6; } + footer { margin-top: 26px; color: #8494AB; font-size: 9pt; text-align: center; } `; function page(title: string, meta: string, body: string, t: Translate): string { @@ -133,8 +133,8 @@ export function attackExportHtml(result: AttackOutput, t: Translate): string { // The website's Brainstorm and Factoids PDFs open with a table; these pages draw it the same way. const TABLE_STYLE = `<style> table { width: 100%; border-collapse: collapse; margin: 0 0 14px; font-size: 9.5pt; break-inside: auto; } - th { background: #0284c7; color: #fff; text-align: left; padding: 6px 8px; font-weight: 700; } - td { border-bottom: 1px solid #e2e8f0; padding: 6px 8px; vertical-align: top; } + th { background: #0556D6; color: #fff; text-align: left; padding: 6px 8px; font-weight: 700; } + td { border-bottom: 1px solid #E2E9F2; padding: 6px 8px; vertical-align: top; } tr { break-inside: avoid; } </style>`; @@ -200,15 +200,15 @@ export function factoidsExportHtml(result: FactoidsOutput, t: Translate): string export const FLOWCHART_EXPORT_FILE_NAME = 'debate-flowchart.pdf'; /** The website's node colours (nodeColors). */ -const FLOW_COLORS: Record<FlowNodeType, string> = { thesis: '#0284c7', contention: '#10b981', attack: '#ef4444', rebuttal: '#3b82f6', dropped: '#f59e0b' }; +const FLOW_COLORS: Record<FlowNodeType, string> = { thesis: '#0556D6', contention: '#14B37D', attack: '#F0454B', rebuttal: '#1E8DFB', dropped: '#F39A00' }; const FLOW_STYLE = `<style> - .card { border: 1px solid #cbd5e1; border-left-width: 4px; border-radius: 10px; padding: 7px 11px; margin: 0 0 10px; break-inside: avoid; } + .card { border: 1px solid #CDD7E4; border-left-width: 4px; border-radius: 10px; padding: 7px 11px; margin: 0 0 10px; break-inside: avoid; } .card .kind { font-size: 9pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; } .card .text { font-weight: 600; } - .card .description { color: #475569; font-size: 10pt; margin: 2px 0 0; } - .card .meta { color: #64748b; font-size: 9pt; margin-top: 2px; } - .via { color: #64748b; font-size: 9pt; font-weight: 600; margin: 0 0 3px; } + .card .description { color: #46566F; font-size: 10pt; margin: 2px 0 0; } + .card .meta { color: #5F708A; font-size: 9pt; margin-top: 2px; } + .via { color: #5F708A; font-size: 9pt; font-weight: 600; margin: 0 0 3px; } </style>`; /** The Map tab's tree as a PDF page: every card under the one it answers, with its link. */ diff --git a/apps/mobile/src/lib/feature-config-read.test.ts b/apps/mobile/src/lib/feature-config-read.test.ts index 7acc3744e..2f979b22e 100644 --- a/apps/mobile/src/lib/feature-config-read.test.ts +++ b/apps/mobile/src/lib/feature-config-read.test.ts @@ -6,7 +6,7 @@ import type { FeatureConfig } from './feature-config'; import { readFeatureConfigAfterWrites } from './feature-config-read'; import { createSerialQueue } from './serial-queue'; -const saved: FeatureConfig = { model: 'google/gemini-3.8-flash', temperature: 0.7, reasoningEffort: 'low' }; +const saved: FeatureConfig = { model: 'openai/gpt-6-luna', reasoningEffort: 'low' }; describe('readFeatureConfigAfterWrites', () => { beforeEach(() => vi.useFakeTimers()); diff --git a/apps/mobile/src/lib/feature-config.test.ts b/apps/mobile/src/lib/feature-config.test.ts index c8d0ff4cc..eb5e766ed 100644 --- a/apps/mobile/src/lib/feature-config.test.ts +++ b/apps/mobile/src/lib/feature-config.test.ts @@ -12,9 +12,9 @@ describe('featureConfigsWith', () => { }); it('changes one tool and keeps what the student saved for the others', () => { - const saved = { attack: { provider: 'openrouter', model: 'google/gemini-3.8-flash', temperature: 0.4, reasoningEffort: 'high' } }; + const saved = { attack: { provider: 'openrouter', model: 'openai/gpt-6-luna', reasoningEffort: 'high' } }; const configs = featureConfigsWith(saved, false, 'generate', { model: 'deepseek/deepseek-v4-flash-0731' }); - expect(configs.generate).toEqual({ provider: 'openrouter', model: 'deepseek/deepseek-v4-flash-0731', temperature: 0.7, reasoningEffort: 'low' }); + expect(configs.generate).toEqual({ provider: 'openrouter', model: 'deepseek/deepseek-v4-flash-0731', reasoningEffort: 'low' }); expect(configs.attack).toEqual(saved.attack); // The website reads an empty feature_configs as never saved, so every tool gets its defaults. expect(configs.ask.model).toBe(DEFAULT_MODEL); diff --git a/apps/mobile/src/lib/feature-config.ts b/apps/mobile/src/lib/feature-config.ts index 62110abc4..b8acf1aff 100644 --- a/apps/mobile/src/lib/feature-config.ts +++ b/apps/mobile/src/lib/feature-config.ts @@ -1,82 +1,46 @@ -import { getDefaultFeatureConfigs, type FeatureTab } from '@debatelab/core/ai/feature-configs'; -import { PROVIDER_ID, type ProviderConfig } from '@debatelab/core/ai/providers/types'; -import { readEntitlement } from '@debatelab/core/supabase/entitlement'; +import { getDefaultFeatureConfigs, normalizeFeatureConfig, type FeatureTab } from '@debatelab/core/ai/feature-configs'; +import { type ProviderConfig } from '@debatelab/core/ai/providers/types'; +import { readProModelAccess } from '@debatelab/core/supabase/billing'; import { - AUTO_FAST, AUTO_PRO, DEFAULT_MODEL, DEFAULT_PRO_MODEL, getModelCatalogEntry, - isAutoModelSelection, - isReasoningEffort, type ReasoningEffort, } from '@debatelab/core/contract/model-catalog'; +import { billingQuery } from './billing-query'; import { supabase } from './supabase'; /** * A tool's model and its settings, picked exactly as the website's settings * store (src/lib/stores/settings-store.ts) picks them from - * user_settings.feature_configs. The model picker writes them back through the - * settings provider; the migration that rewrites old configs stays on the website. + * user_settings.feature_configs, with the same normalizer + * (packages/core/src/ai/feature-configs.ts). The model picker writes them back + * through the settings provider; the migration that rewrites old configs stays + * on the website. */ export interface FeatureConfig { model: string; - temperature: number; reasoningEffort: ReasoningEffort; } -function defaultConfig(feature: FeatureTab, model: string = DEFAULT_MODEL): FeatureConfig { - const { temperature, reasoningEffort } = getDefaultFeatureConfigs(model)[feature]; - return { model, temperature, reasoningEffort: reasoningEffort ?? 'low' }; +function defaultConfig(feature: FeatureTab, model: string = DEFAULT_MODEL): ProviderConfig { + return getDefaultFeatureConfigs(model)[feature]; } -const LEGACY_MODEL_SUCCESSORS: Record<string, string> = { - 'gpt-5.5': DEFAULT_MODEL, - 'gpt-5.4': DEFAULT_MODEL, - 'gpt-5.4-mini': DEFAULT_MODEL, - 'gpt-4o': DEFAULT_MODEL, - 'gpt-4o-mini': DEFAULT_MODEL, - 'claude-opus-4-7': DEFAULT_PRO_MODEL, - 'claude-sonnet-4-6': DEFAULT_PRO_MODEL, - 'claude-haiku-4-5-20251001': DEFAULT_MODEL, - 'gemini-3.1-pro-preview': 'google/gemini-3.8-flash', - 'gemini-3.5-flash': 'google/gemini-3.8-flash', - 'gemini-3-flash-preview': 'google/gemini-3.8-flash', - 'gemini-3.1-flash-lite-preview': 'google/gemini-3.8-flash', -}; - function asRecord(value: unknown): Record<string, unknown> | null { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null; } -function normalizeModelId(value: unknown, fallback: string): string { - if (typeof value !== 'string' || !value.trim()) return fallback; - const model = value.trim(); - if (isAutoModelSelection(model)) return model; - if (getModelCatalogEntry(model)) return model; - return LEGACY_MODEL_SUCCESSORS[model] ?? DEFAULT_MODEL; -} - -function normalizeConfig(value: unknown, fallback: FeatureConfig, allowProModels: boolean): FeatureConfig { - const candidate = asRecord(value); - const model = normalizeModelId(candidate?.model, fallback.model); - return { - model: !allowProModels && model === AUTO_PRO - ? AUTO_FAST - : !allowProModels && getModelCatalogEntry(model)?.isProOnly - ? DEFAULT_MODEL - : model, - temperature: typeof candidate?.temperature === 'number' && Number.isFinite(candidate.temperature) - ? candidate.temperature - : fallback.temperature, - reasoningEffort: isReasoningEffort(candidate?.reasoningEffort) ? candidate.reasoningEffort : fallback.reasoningEffort, - }; +function normalizeConfig(value: unknown, fallback: ProviderConfig, allowProModels: boolean): FeatureConfig { + const { model, reasoningEffort } = normalizeFeatureConfig(value, fallback, allowProModels); + return { model, reasoningEffort: reasoningEffort ?? 'low' }; } -/** Whether the student may pick Pro models, from effective_entitlement; null when the plan cannot be read. */ +/** Whether the student may pick Pro models, by the website's billing resolver; null when the plan cannot be read. */ export async function canUseProModels(userId: string): Promise<boolean | null> { - return (await readEntitlement(supabase, userId))?.canAccessProModels ?? null; + return readProModelAccess(billingQuery(userId)); } /** A tool's settings and whether the student has Pro access (false when it cannot be told). */ @@ -86,10 +50,10 @@ export async function fetchFeatureSettings(userId: string, feature: FeatureTab): .select('feature_configs') .eq('user_id', userId) .maybeSingle(); - if (error || !data) return { config: defaultConfig(feature), pro: false }; + if (error || !data) return { config: normalizeConfig(null, defaultConfig(feature), false), pro: false }; // Null when the plan cannot be read, as on the website, which then keeps its defaults. const allowProModels = await canUseProModels(userId); - if (allowProModels === null) return { config: defaultConfig(feature), pro: false }; + if (allowProModels === null) return { config: normalizeConfig(null, defaultConfig(feature), false), pro: false }; const fallback = defaultConfig(feature, allowProModels ? DEFAULT_PRO_MODEL : DEFAULT_MODEL); return { config: normalizeConfig(asRecord(data.feature_configs)?.[feature], fallback, allowProModels), pro: allowProModels }; } @@ -117,7 +81,7 @@ export function featureConfigsWith(saved: unknown, pro: boolean, feature: Featur const fallbackModel = pro ? DEFAULT_PRO_MODEL : DEFAULT_MODEL; const features = Object.keys(getDefaultFeatureConfigs()) as FeatureTab[]; return Object.fromEntries(features.map((name) => { - const config = normalizeConfig(record?.[name], defaultConfig(name, fallbackModel), pro); - return [name, { provider: PROVIDER_ID, ...config, ...(name === feature ? change : {}) }]; + const config = normalizeFeatureConfig(record?.[name], defaultConfig(name, fallbackModel), pro); + return [name, { ...config, ...(name === feature ? change : {}) }]; })) as Record<FeatureTab, ProviderConfig>; } diff --git a/apps/mobile/src/lib/results.test.ts b/apps/mobile/src/lib/results.test.ts index 0ec8300db..d9eee394b 100644 --- a/apps/mobile/src/lib/results.test.ts +++ b/apps/mobile/src/lib/results.test.ts @@ -1,41 +1,59 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -const addSource = vi.hoisted(() => vi.fn()); +const saveResearchedSource = vi.hoisted(() => vi.fn()); vi.mock('./supabase', () => ({ supabase: {} })); vi.mock('expo/fetch', () => ({ fetch: vi.fn() })); vi.mock('./offline-cache', () => ({ cachedRead: vi.fn(), afterWrite: vi.fn(), updateCopy: vi.fn(), readFailed: vi.fn() })); -vi.mock('./sources', () => ({ addSource, applySourceAssessments: vi.fn(), fetchSourceIdsInFolder: vi.fn(), removeSource: vi.fn() })); +vi.mock('./sources', () => ({ saveResearchedSource, applySourceAssessments: vi.fn(), fetchSourceIdsInFolder: vi.fn(), removeSource: vi.fn() })); import { autosaveSources, readBrainstorm, readFactoids } from './results'; describe('autosaveSources', () => { + beforeEach(() => saveResearchedSource.mockReset()); it('writes no further source once Generate or Attack is cancelled during a write', async () => { const controller = new AbortController(); let finishFirst = () => {}; - addSource.mockImplementationOnce(() => new Promise<void>((resolve) => (finishFirst = resolve))); - addSource.mockResolvedValue(undefined); + saveResearchedSource.mockImplementationOnce(() => new Promise<boolean>((resolve) => (finishFirst = () => resolve(true)))); + saveResearchedSource.mockResolvedValue(true); const sources = ['a', 'b', 'c'].map((name) => ({ title: name, url: `https://example.com/${name}` })); const saving = autosaveSources('user-1', sources, 'generate', 'debate-1', controller.signal); + await vi.waitFor(() => expect(saveResearchedSource).toHaveBeenCalledOnce()); controller.abort(); finishFirst(); await expect(saving).resolves.toEqual({ saved: 1, failed: 0 }); - expect(addSource).toHaveBeenCalledTimes(1); + expect(saveResearchedSource).toHaveBeenCalledTimes(1); }); + + it('serializes tool batches and saves normalized page variants once per answer', async () => { + let finishFirst = () => {}; + saveResearchedSource.mockImplementationOnce(() => new Promise<boolean>((resolve) => (finishFirst = () => resolve(true)))); + saveResearchedSource.mockResolvedValue(false); + const first = autosaveSources('user-1', [ + { title: 'Page', url: 'https://www.example.com/report/' }, + { title: 'Same page', url: 'http://example.com/report?utm_source=research#results' }, + ], 'generate', 'debate-1'); + const second = autosaveSources('user-1', [{ title: 'Page again', url: 'https://example.com/report' }], 'brainstorm', 'debate-1'); + await vi.waitFor(() => expect(saveResearchedSource).toHaveBeenCalledOnce()); + finishFirst(); + await expect(first).resolves.toEqual({ saved: 1, failed: 0 }); + await expect(second).resolves.toEqual({ saved: 0, failed: 0 }); + expect(saveResearchedSource.mock.calls.map((args) => args[3])).toEqual(['generate', 'brainstorm']); + }); + }); describe('reading Brainstorm and Factoids output', () => { - it('keeps only text in a brainstorm list, so an object never reaches the document or the export', () => { + it('rejects a brainstorm with a non-text list entry before rendering or exporting', () => { const output = readBrainstorm({ needsMoreInfo: false, topFive: [{ rank: 1, idea: { title: 'Cleaner air', description: 'Fewer cars in the centre' }, whyTop: 'Health' }], nextSteps: [{ action: 'Find local air-quality data' }, 'Ask the council'], warningsAndTradeoffs: [], }); - expect(output?.nextSteps).toEqual(['Ask the council']); - expect(output?.topFive?.[0].idea.title).toBe('Cleaner air'); + expect(output).toBeNull(); }); it('rejects a saved factoids row whose factoid does not fit the schema', () => { diff --git a/apps/mobile/src/lib/results.ts b/apps/mobile/src/lib/results.ts index c03b44cd9..b3e46059e 100644 --- a/apps/mobile/src/lib/results.ts +++ b/apps/mobile/src/lib/results.ts @@ -1,6 +1,5 @@ import { AUTO_SAVED_FOLDER, - autoSavedSourceRow, parseDiscoveredSources, retainedDiscoveredSources, type DiscoveredSource, @@ -20,7 +19,7 @@ import { AIError, functionOutput, postFunction, PROVIDER, responseExtras, type R import type { FeatureConfig } from './feature-config'; import { debateListScopes, deletedListScopes } from './debates'; import { afterWrite, cachedRead, readFailed, type CopyMark } from './offline-cache'; -import { addSource, applySourceAssessments, fetchSourceIdsInFolder, removeSource } from './sources'; +import { saveResearchedSource, applySourceAssessments, fetchSourceIdsInFolder, removeSource } from './sources'; import { supabase } from './supabase'; /** @@ -70,7 +69,6 @@ function requestBody(feature: Feature, input: Record<string, unknown>, options: input: { ...input, language: options.language }, provider: PROVIDER, model: options.config.model, - temperature: options.config.temperature, reasoningEffort: options.config.reasoningEffort, // Servers from before Current Debate context read only this field. sourceIds: options.debateContext?.sourceIds ?? [], @@ -130,13 +128,28 @@ export function findFactoids(input: FactoidsInput, options: RequestOptions): Pro }); } +// Batches see the rows written by earlier tools before matching existing pages. +let autosaveQueue: Promise<unknown> = Promise.resolve(); + /** * Keeps the researched sources worth keeping in the debate's Auto-saved * folder, one at a time, as the website does. One-off research is not kept. * A source that fails to save is skipped; the count of failures is returned. * Once the request is cancelled or replaced, no further source is written. */ -export async function autosaveSources( +export function autosaveSources( + userId: string, + sources: DiscoveredSource[], + capturedVia: Feature, + debateId: string | null, + signal?: AbortSignal, +): Promise<{ saved: number; failed: number }> { + const run = autosaveQueue.then(() => autosaveSourcesBatch(userId, sources, capturedVia, debateId, signal)); + autosaveQueue = run.catch(() => undefined); + return run; +} + +async function autosaveSourcesBatch( userId: string, sources: DiscoveredSource[], capturedVia: Feature, @@ -149,8 +162,7 @@ export async function autosaveSources( for (const source of retainedDiscoveredSources(sources)) { if (signal?.aborted) break; try { - await addSource(userId, { ...autoSavedSourceRow(source, capturedVia), debateId }); - saved += 1; + if (await saveResearchedSource(userId, debateId, source, capturedVia, signal)) saved += 1; } catch (cause) { console.warn('Could not save a researched source:', cause instanceof Error ? cause.message : cause); failed += 1; diff --git a/apps/mobile/src/lib/settings.tsx b/apps/mobile/src/lib/settings.tsx index 332735592..71135c94e 100644 --- a/apps/mobile/src/lib/settings.tsx +++ b/apps/mobile/src/lib/settings.tsx @@ -1,5 +1,5 @@ import { getDefaultFeatureConfigs, type FeatureTab } from '@debatelab/core/ai/feature-configs'; -import { readEntitlement } from '@debatelab/core/supabase/entitlement'; +import { readProModelAccess } from '@debatelab/core/supabase/billing'; import { DEFAULT_MODEL, DEFAULT_PRO_MODEL } from '@debatelab/core/contract/model-catalog'; import { isAppLanguage, type AppLanguage } from '@debatelab/core/languages'; import AsyncStorage from '@react-native-async-storage/async-storage'; @@ -17,6 +17,7 @@ import { import { readFeatureConfigAfterWrites } from './feature-config-read'; import i18n, { deviceLanguage } from './i18n'; import { createSerialQueue } from './serial-queue'; +import { billingQuery } from './billing-query'; import { supabase } from './supabase'; import { applyColorScheme } from '@/theme/apply-color-scheme'; @@ -88,9 +89,9 @@ function isEmptyObject(value: unknown): boolean { * their Pro access can't be read right now. */ async function defaultModelFor(userId: string): Promise<string | null> { - const entitlement = await readEntitlement(supabase, userId); - if (!entitlement) return null; - return entitlement.canAccessProModels ? DEFAULT_PRO_MODEL : DEFAULT_MODEL; + const pro = await readProModelAccess(billingQuery(userId)); + if (pro === null) return null; + return pro ? DEFAULT_PRO_MODEL : DEFAULT_MODEL; } /** diff --git a/apps/mobile/src/lib/sources.test.ts b/apps/mobile/src/lib/sources.test.ts index 3c08a7d52..3eca193a9 100644 --- a/apps/mobile/src/lib/sources.test.ts +++ b/apps/mobile/src/lib/sources.test.ts @@ -27,3 +27,34 @@ describe('applySourceAssessments', () => { expect(update).not.toHaveBeenCalled(); }); }); + +describe('researched pages', () => { + it.each(['Student excerpt', ''])('refreshes a normalized URL and fills only empty content (%j)', async (content) => { + const existing = { id: 'saved-page', origin: 'http://www.example.com/report/?utm_source=student', content, metadata: { capturedVia: 'import', note: 'Keep me' } }; + const read = { eq: vi.fn(() => read), then: (done: (value: unknown) => unknown) => done({ data: [existing], error: null }) }; + const written = { eq: vi.fn(() => written), then: (done: (value: unknown) => unknown) => done({ error: null }) }; + const update = vi.fn(() => written); + const insert = vi.fn(); + supabase.from.mockReturnValue({ select: () => read, update, insert }); + const { saveResearchedSource } = await import('./sources'); + await expect(saveResearchedSource('user-1', 'debate-1', { title: 'Research title', url: 'https://example.com/report#results', content: 'Research excerpt', qualityLabel: 'strong' }, 'generate')).resolves.toBe(false); + expect(insert).not.toHaveBeenCalled(); + expect(update).toHaveBeenCalledWith({ metadata: { capturedVia: 'generate', note: 'Keep me', url: 'https://example.com/report#results', qualityLabel: 'strong' }, ...(content ? {} : { content: 'Research excerpt' }) }); + expect(written.eq.mock.calls).toEqual([['user_id', 'user-1'], ['debate_id', 'debate-1'], ['id', 'saved-page']]); + }); + + it('writes nothing when cancelled while looking for an existing page', async () => { + const controller = new AbortController(); + const read = { eq: () => read, then: (done: (value: unknown) => unknown) => { + controller.abort(); + return done({ data: [], error: null }); + } }; + const insert = vi.fn(); + const update = vi.fn(); + supabase.from.mockReturnValue({ select: () => read, insert, update }); + const { saveResearchedSource } = await import('./sources'); + await expect(saveResearchedSource('user-1', 'debate-1', { title: 'Page', url: 'https://example.com' }, 'generate', controller.signal)).resolves.toBe(false); + expect(insert).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/lib/sources.ts b/apps/mobile/src/lib/sources.ts index eaa5b6f94..6f40215e6 100644 --- a/apps/mobile/src/lib/sources.ts +++ b/apps/mobile/src/lib/sources.ts @@ -1,3 +1,5 @@ +import { findSavedPage, normalizeSourceUrl, savedSourceUrl } from '@debatelab/core/source-url'; +import { autoSavedSourceRow, type DiscoveredSource } from '@debatelab/core/ai/discovered-sources'; import type { SourceSemanticMetadata } from '@debatelab/core/ai/discovered-sources'; import { SourceImportOutputSchema, type SourceImportOutput } from '@debatelab/core/ai/schemas/source-import'; import { assessedSemantic, isAssessedSource, type SourceAssessmentUpdate } from '@debatelab/core/ai/source-assessments'; @@ -132,6 +134,7 @@ export async function addSource( }) .select(SOURCE_COLUMNS) .single(); + if (error?.code === '23505') throw new DuplicateSourceError(); if (error || !data) throw new Error(error?.message ?? 'Could not save the source'); afterWrite(userId, [{ group: 'sources', key: input.debateId }, ...debateListScopes(input.debateId, 'source')]); notifySourcesChanged(); @@ -199,10 +202,47 @@ export async function applySourceAssessments(userId: string, debateId: string, a if (failed?.error) throw new Error(failed.error.message); } +export class DuplicateSourceError extends Error { + constructor() { + super('This page is already in the debate Library.'); + this.name = 'DuplicateSourceError'; + } +} + +/** Refresh research metadata while preserving the student's title, folder and active choice. */ +export async function saveResearchedSource(userId: string, debateId: string, source: DiscoveredSource, capturedVia: string, signal?: AbortSignal): Promise<boolean> { + if (signal?.aborted) return false; + const { data, error } = await supabase.from('sources') + .select('id,origin,metadata,content').eq('user_id', userId).eq('debate_id', debateId); + if (error) throw new Error(error.message); + if (signal?.aborted) return false; + const candidates = ((data ?? []) as { id: string; origin: string | null; metadata: Record<string, unknown> & { url?: string } | null; content: string }[]) + .map((row) => ({ ...row, origin: row.origin ?? undefined, metadata: row.metadata ?? undefined, debateId })); + const existing = findSavedPage(candidates, debateId, source.url); + const row = autoSavedSourceRow(source, capturedVia); + if (existing) { + const { error: updateError } = await supabase.from('sources').update({ + metadata: { ...existing.metadata, ...row.metadata }, + ...(!existing.content.trim() && source.content ? { content: source.content } : {}), + }).eq('user_id', userId).eq('debate_id', debateId).eq('id', existing.id); + if (updateError) throw new Error(updateError.message); + afterWrite(userId, [{ group: 'sources', key: debateId }, { group: 'source', key: existing.id }, ...debateListScopes(debateId, 'source')]); + notifySourcesChanged(); + return false; + } + try { + await addSource(userId, { ...row, debateId }); + return true; + } catch (cause) { + if (cause instanceof DuplicateSourceError) return false; + throw cause; + } +} + /** The website rejects a URL that is already in the debate's Library. */ export function isDuplicateOrigin(sources: Source[], origin: string): boolean { - const needle = origin.trim().toLowerCase(); - return Boolean(needle) && sources.some((source) => source.origin?.trim().toLowerCase() === needle); + const key = normalizeSourceUrl(origin); + return key !== null && sources.some((source) => savedSourceUrl({ origin: source.origin ?? undefined }) === key); } /** Reads a page through the import-source Edge Function, which returns an editable draft. */ diff --git a/apps/mobile/src/lib/sparring.ts b/apps/mobile/src/lib/sparring.ts index c7d23f67d..8aa07573c 100644 --- a/apps/mobile/src/lib/sparring.ts +++ b/apps/mobile/src/lib/sparring.ts @@ -51,7 +51,6 @@ function requestBody(input: SparringRespondInput | SparringScoreInput, options: input: parsed.data, provider: PROVIDER, model: options.config.model, - temperature: options.config.temperature, reasoningEffort: options.config.reasoningEffort, // Servers from before Current Debate context read only this field. sourceIds: [], diff --git a/apps/mobile/src/lib/subscription.ts b/apps/mobile/src/lib/subscription.ts index 1ed99e14a..89dbd121f 100644 --- a/apps/mobile/src/lib/subscription.ts +++ b/apps/mobile/src/lib/subscription.ts @@ -1,39 +1,62 @@ -import { readEntitlement, type SubscriptionTier } from '@debatelab/core/supabase/entitlement'; +import { readBillingState } from '@debatelab/core/supabase/billing'; +import { planDailyLimit, type BillingDateKind, type BillingStateName } from '@debatelab/core/contract/billing-state'; +import { billingQuery } from './billing-query'; import { supabase } from './supabase'; /** * The plan and today's AI usage, read the way the website's useSubscription - * (src/hooks/use-subscription.ts) reads them: the plan from - * effective_entitlement, which also counts App Store subscriptions. + * (src/hooks/use-subscription.ts) reads them: the plan from the resolver the + * AI functions use (billing-state.ts), which also counts App Store + * subscriptions, and usage from the server's own counter. */ -export type { SubscriptionTier }; +type SubscriptionTier = 'trial' | 'basic' | 'pro' | 'legacy_free' | 'expired'; export interface Subscription { tier: SubscriptionTier; + billingState: BillingStateName; + billingDate: string | null; + billingDateKind: BillingDateKind | null; dailyLimit: number; - dailyUsed: number; + /** Charged AI units today, or null when the counter cannot be read. */ + dailyUsed: number | null; trialEndsAt: string | null; } +// The server's tier defaults (supabase/functions/_shared/usage-limits.ts). +const DEFAULT_LIMITS = { pro: 50, basic: 20, trial: 5 }; + export async function fetchSubscription(userId: string): Promise<Subscription> { - const entitlement = await readEntitlement(supabase, userId); - if (!entitlement) throw new Error('Could not load the plan'); - - // Usage resets at midnight UTC. - const todayStart = new Date(); - todayStart.setUTCHours(0, 0, 0, 0); - const { count, error: usageError } = await supabase - .from('ai_requests') - .select('*', { count: 'exact', head: true }) - .eq('user_id', userId) - .gte('created_at', todayStart.toISOString()); + const read = await readBillingState(billingQuery(userId)); + if (!read) throw new Error('Could not load the plan'); + const { billing, rows } = read; + + let dailyLimit = planDailyLimit(billing, rows.settings?.daily_limit_override, DEFAULT_LIMITS); + // A per-account rate_limits row replaces the plan's limit on the server too. + if (billing.hasAccess) { + const { data: ownLimit } = await supabase + .from('rate_limits') + .select('limit_value') + .eq('user_id', userId) + .eq('limit_type', 'daily_requests') + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle(); + if (typeof ownLimit?.limit_value === 'number') dailyLimit = ownLimit.limit_value; + } + + // Model-weighted units, one charge per Crossfire or Sparring round, refunds + // given back: what the server enforces. Resets at midnight UTC. + const { data: used, error: usageError } = await supabase.rpc('ai_usage_today'); if (usageError) console.warn('Could not load daily usage:', usageError.message); return { - tier: entitlement.tier, - dailyLimit: entitlement.dailyLimit, - dailyUsed: usageError ? 0 : count || 0, - trialEndsAt: entitlement.trialEndsAt, + tier: billing.tier, + billingState: billing.state, + billingDate: billing.date, + billingDateKind: billing.dateKind, + dailyLimit, + dailyUsed: !usageError && typeof used === 'number' ? used : null, + trialEndsAt: rows.settings?.trial_ends_at ?? null, }; } diff --git a/apps/mobile/src/lib/website.ts b/apps/mobile/src/lib/website.ts index 63aa80c9f..28ddd26ae 100644 --- a/apps/mobile/src/lib/website.ts +++ b/apps/mobile/src/lib/website.ts @@ -5,7 +5,7 @@ import Constants from 'expo-constants'; * there too: the staging site is behind Vercel login, and Terms and Privacy * are the same pages. */ -const websiteUrl = (Constants.expoConfig?.extra as { websiteUrl?: string } | undefined)?.websiteUrl ?? 'https://debate.pi-stack.org'; +const websiteUrl = (Constants.expoConfig?.extra as { websiteUrl?: string } | undefined)?.websiteUrl ?? 'https://debateowl.com'; export function websitePage(path: '/terms' | '/privacy'): string { return `${websiteUrl}${path}`; diff --git a/apps/mobile/src/theme/colors.ts b/apps/mobile/src/theme/colors.ts index 4debe1277..1f20bcc0a 100644 --- a/apps/mobile/src/theme/colors.ts +++ b/apps/mobile/src/theme/colors.ts @@ -1,70 +1,95 @@ /** - * DebateLab colours for the app. Light values come from the approved mocks - * (plans/2026-10-08-native-ios-app), dark values from the website's `.dark` - * palette in src/index.css. + * DebateOwl colours for the app, from the website's design tokens + * (src/styles/tokens.css and the roles in src/index.css): plume blue, ink + * neutrals, beak gold, mint for success (always with a check mark) and coral. + * Text tints and fills are separate, as on the website: `primary` is the blue + * that passes AA as text, `primaryFill` is the button face with white text. */ export const palette = { light: { - background: '#eef4fa', - card: '#f8fafc', - foreground: '#0f172a', - secondary: '#475569', - faint: '#64748b', - border: '#cbd5e1', - line: '#e2e8f0', - primary: '#0284c7', - onPrimary: '#ffffff', - tile: '#e0f2fe', - tileForeground: '#0284c7', - chipSelectedBorder: '#7dd3fc', - chipSelectedForeground: '#075985', - segmentTrack: '#e2e8f0', - segmentSelected: '#ffffff', - input: '#ffffff', - tabInactive: '#94a3b8', - tabBar: 'rgba(248,250,252,0.84)', - destructive: '#dc2626', - success: '#16a34a', - warning: '#d97706', - tileBorder: '#bae6fd', - tileText: '#0c4a6e', + background: '#F7FAFE', + card: '#FFFFFF', + foreground: '#1F2B40', + /** Page and card headings: navy, the colour of the owl's outline. */ + heading: '#0B2A6E', + secondary: '#526380', + faint: '#5F708A', + border: '#E2E9F2', + line: '#E2E9F2', + primary: '#0556D6', + primaryFill: '#0A6FF0', + primaryLedge: '#0342A8', + onPrimary: '#FFFFFF', + tile: '#EEF7FF', + tileForeground: '#0556D6', + chipSelectedBorder: '#7CC0FE', + chipSelectedForeground: '#0342A8', + segmentTrack: '#EFF4FA', + segmentSelected: '#FFFFFF', + input: '#FFFFFF', + inputBorder: '#8494AB', + borderStrong: '#CDD7E4', + tabInactive: '#8494AB', + tabBar: 'rgba(247,250,254,0.88)', + destructive: '#D23239', + destructiveLedge: '#7E1B20', + success: '#0A8058', + successFill: '#14B37D', + successTint: '#E6FBF3', + warning: '#9A5806', + accent: '#FFC414', + accentLedge: '#C9740A', + onAccent: '#0B2A6E', + tileBorder: '#B3D8FC', + tileText: '#0B2A6E', + neutralLedge: '#CDD7E4', + disabledFace: '#E2E9F2', + disabledText: '#8494AB', }, dark: { - background: '#0f172a', - card: '#1e293b', - foreground: '#f8fafc', - secondary: '#cbd5e1', - faint: '#94a3b8', - border: '#475569', - line: '#334155', - primary: '#38bdf8', - onPrimary: '#082f49', - tile: '#15304a', - tileForeground: '#7dd3fc', - chipSelectedBorder: '#0369a1', - chipSelectedForeground: '#bae6fd', - segmentTrack: '#1e293b', - segmentSelected: '#334155', - input: '#0f172a', - tabInactive: '#64748b', - tabBar: 'rgba(15,23,42,0.84)', - destructive: '#f87171', - success: '#4ade80', - warning: '#fbbf24', - tileBorder: '#1e4a6b', - tileText: '#e0f2fe', + background: '#0B1426', + card: '#131E33', + foreground: '#EEF4FF', + heading: '#FFFFFF', + secondary: '#A9B7C9', + faint: '#8E9FB6', + border: '#24324D', + line: '#24324D', + primary: '#4CA6FD', + primaryFill: '#0A6FF0', + primaryLedge: '#02307E', + onPrimary: '#FFFFFF', + tile: '#0E2350', + tileForeground: '#7CC0FE', + chipSelectedBorder: '#1D4691', + chipSelectedForeground: '#B3D8FC', + segmentTrack: '#08101F', + segmentSelected: '#1C2A44', + input: '#0B1426', + inputBorder: '#5F708A', + borderStrong: '#2F4062', + tabInactive: '#7486A0', + tabBar: 'rgba(11,20,38,0.88)', + destructive: '#FF6B70', + destructiveLedge: '#B32A30', + success: '#55DBAB', + successFill: '#2FD39A', + successTint: '#0A2A20', + warning: '#FFD95A', + accent: '#FFC414', + accentLedge: '#9A5806', + onAccent: '#061442', + tileBorder: '#1D4691', + tileText: '#EEF4FF', + neutralLedge: '#060C18', + disabledFace: '#24324D', + disabledText: '#7486A0', }, } as const; export type Palette = { [K in keyof typeof palette.light]: string }; -/** The website's Start Trial gradient. It marks the one main action on a screen. */ -export const mainActionGradient = { - colors: ['#0284c7', '#0ea5e9', '#14b8a6'], - locations: [0, 0.45, 1], - // 115deg in CSS, as start and end points for expo-linear-gradient. - start: { x: 0, y: 0.3 }, - end: { x: 1, y: 0.7 }, -} as const; +/** The ledge under a tactile button, as the website's `.btn` draws it (4px, 3px for small ones). */ +export const LEDGE = 4; -export const radius = { tile: 11, field: 12, card: 16, pill: 999 } as const; +export const radius = { tile: 14, field: 14, card: 18, pill: 999 } as const; diff --git a/apps/mobile/src/theme/fonts.ts b/apps/mobile/src/theme/fonts.ts index bed86ebf8..e810275a4 100644 --- a/apps/mobile/src/theme/fonts.ts +++ b/apps/mobile/src/theme/fonts.ts @@ -1,18 +1,31 @@ -import { - Inter_400Regular, - Inter_500Medium, - Inter_600SemiBold, - Inter_700Bold, -} from '@expo-google-fonts/inter'; +import { Fredoka_500Medium, Fredoka_600SemiBold, Fredoka_700Bold } from '@expo-google-fonts/fredoka'; +import { Nunito_600SemiBold, Nunito_700Bold, Nunito_800ExtraBold } from '@expo-google-fonts/nunito'; -export const interFonts = { Inter_400Regular, Inter_500Medium, Inter_600SemiBold, Inter_700Bold }; +/** + * DebateOwl's fonts, as on the website (src/index.css): Nunito for body text, + * Fredoka for headings and buttons. Nunito reads thin below 600, so body text + * starts there and every step moves up one notch, as the website's + * --font-weight-* tokens do. + */ +export const appFonts = { + Nunito_600SemiBold, + Nunito_700Bold, + Nunito_800ExtraBold, + Fredoka_500Medium, + Fredoka_600SemiBold, + Fredoka_700Bold, +}; /** Custom fonts on iOS need one family per weight; `fontWeight` alone falls back to the system font. */ export const fontFamily = { - regular: 'Inter_400Regular', - medium: 'Inter_500Medium', - semibold: 'Inter_600SemiBold', - bold: 'Inter_700Bold', + regular: 'Nunito_600SemiBold', + medium: 'Nunito_700Bold', + semibold: 'Nunito_700Bold', + bold: 'Nunito_800ExtraBold', + /** Headings and button labels. */ + display: 'Fredoka_600SemiBold', + displayMedium: 'Fredoka_500Medium', + displayBold: 'Fredoka_700Bold', } as const; export type FontWeight = keyof typeof fontFamily; diff --git a/components.json b/components.json index 62e101166..e71fe103f 100644 --- a/components.json +++ b/components.json @@ -4,7 +4,7 @@ "rsc": false, "tsx": true, "tailwind": { - "config": "tailwind.config.ts", + "config": "", "css": "src/index.css", "baseColor": "slate", "cssVariables": true, diff --git a/docs/architecture.md b/docs/architecture.md index d453b10b1..4a3c0b5ac 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -23,9 +23,13 @@ Collaborative Workspace (`/app/workspaces/*`, the `workspace-invites` function, `AuthProvider`, TanStack Query, theme and motion providers, toast systems, the offline indicator, and the route error boundary wrap the routed application. +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. +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 shared by the website and Expo iOS app: languages, input validation, the Zod schemas and parsers for AI requests and results, SSE framing, source URL matching, 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 website keeps its stores, AI transport and stream side effects, Supabase client, autosave queue, notifications, and i18n catalogs in `src/`; the app keeps its own wiring in `apps/mobile/`. Both use the core source-retention and URL-matching rules to keep one Library row per page per debate, refreshing research metadata without replacing student choices. [`apps/mobile/`](../apps/mobile/AGENTS.md) (`@debatelab/mobile`) is the iOS app: Expo SDK 57, React Native, React 19 and Expo Router, while the website stays on React 18. Each workspace keeps its own React, so the root has no React override. The app reads the website's code only through `@debatelab/core`, the `src/locales` catalogs and `src/lib/supabase/projects.ts`, the one copy of the project URLs and anon keys; it does not import stores, the AI client or other `src/` wiring. It has its own Supabase client. The session is AES-encrypted in AsyncStorage with a per-write key in the iOS Keychain (expo-secure-store), and refresh pauses while the app is in the background. On a cold start without a network it shows the stored session instead of signing out, and refreshes once the network is back. Sign-in is email code or demo code, through the same `demo-code-login` handoff as the website. The app uses staging unless a build sets `EXPO_PUBLIC_SUPABASE_ENV=production`. Until the first development build it runs in Expo Go, so it may only use native modules Expo Go bundles. A web export of the app exists only for screenshots on the VPS. Vercel installs only the root package and `@debatelab/core` (`installCommand` in `vercel.json`), so the website deploy never installs the React Native tree. @@ -33,7 +37,7 @@ The repository is an npm workspace. The web app is the root package and still li [`src/lib/stores/session-store.ts`](../src/lib/stores/session-store.ts) owns active AI results, conversations, saved work, sources, folders, flowcharts, autosave state, and persisted form state. It persists selected local state and hydrates authenticated rows from Supabase. -[`src/lib/stores/debate-store.ts`](../src/lib/stores/debate-store.ts) owns the signed-in user's debates, the Current Debate pointer, session-only one-off mode, and the shared Switch and Start dialogs. Every source and every automatically recorded result carries a `debateId`; one-off work and unsorted legacy rows have none. The Library, History, the AI source context, and discovered-source autosave all read from one debate at a time, so material never crosses debates. Motion-bearing tools confirm a motion that differs from the Current Debate (switch, create, or use once) before submitting; with no Current Debate they instead offer to start one from the typed motion. The first-run tour starts with the Current Debate card, then points at Library and Generate. The app blocks with Continue, Start new, or Switch after five days without work in the Current Debate. +[`src/lib/stores/debate-store.ts`](../src/lib/stores/debate-store.ts) owns the signed-in user's debates, the Current Debate pointer, session-only one-off mode, and the shared Switch and Start dialogs. Every source and every automatically recorded result carries a `debateId`; one-off work and unsorted legacy rows have none. The Library, History, the AI source context, and discovered-source autosave all read from one debate at a time, so material never crosses debates. Motion-bearing tools confirm a motion that differs from the Current Debate (switch, create, or use once) before submitting; with no Current Debate they instead offer to start one from the typed motion. The first-run tour starts with the Current Debate card (the Start debate step), then points at Library and Generate. When the one-time welcome is due, that step opens as the welcome closes. The app blocks with Continue, Start new, or Switch after five days without work in the Current Debate. [`src/lib/stores/settings-store.ts`](../src/lib/stores/settings-store.ts) owns language, theme, feature provider settings, prompts, and research preferences. It persists locally and synchronizes authenticated settings. @@ -41,6 +45,12 @@ The repository is an npm workspace. The web app is the root package and still li [`src/lib/supabase/AuthProvider.tsx`](../src/lib/supabase/AuthProvider.tsx) coordinates store hydration and reset across authentication changes. Store code is not the database schema source of truth; migrations are. +The Library keeps one row per web page per debate. [`src/lib/source-url.ts`](../src/lib/source-url.ts) normalizes URLs (host without `www.`, no scheme, fragment, tracking parameters or trailing slash). `addSource` in the session store refuses a page the debate already holds, checking the loaded Library and then the debate's rows on the server, so manual adds, URL imports and research autosave all go through the same check; Undo is exempt. Autosave in [`src/lib/ai/sources.ts`](../src/lib/ai/sources.ts) refreshes the existing row's research metadata instead, and its batches run one at a time within a tab. Two tabs inserting the same page at the same moment can still both pass the check; only a unique index would stop that, and existing duplicate rows on production keep one from being added. + +A new source shows in the Library at once as a `pending-` row and is swapped for the saved row by id. Every write that turns sources on or off (the switch, a folder's Activate/Deactivate, the source editor) joins a per-source queue in the session store: the server applies them in click order, the newest decides what shows, a failure falls back to the last value the server accepted, and a server reload does not undo a write made while it ran. + +[`src/hooks/use-subscription.ts`](../src/hooks/use-subscription.ts) estimates today's remaining AI requests from the account's `ai_requests` rows (refused calls and `jev_*` helper rows excluded) and its own `rate_limits` override. Global `rate_limits` rows are server-only, so the estimate only warns (Sparring setup) and informs (Settings); it is null, never blocking, when any read fails. The `ai` function's 402 stays the decision, and every tool shows it with the same daily-limit dialog. + ## Language behavior [`packages/core/src/languages.ts`](../packages/core/src/languages.ts) is the shared catalog for English, Spanish, and Hungarian language codes, labels, and Intl locales. [`src/i18n.ts`](../src/i18n.ts) owns the locale resources and applies the selected interface language to i18next, document metadata, and `<html lang>`. Logged-out startup preserves local language preferences; authenticated hydration accepts only catalog languages. @@ -51,13 +61,13 @@ Interface language and AI-output language are separate settings. Interface langu [`src/lib/supabase/env.ts`](../src/lib/supabase/env.ts) resolves production or staging client configuration. Public URLs and anon keys are browser configuration. Do not copy their values into docs. Admins switch projects from Settings; see [operations.md](operations.md#switching-the-frontend-to-staging). -[`src/lib/supabase/client.ts`](../src/lib/supabase/client.ts) creates the client with persistent sessions, automatic refresh, PKCE, and environment-specific storage keys. +[`src/lib/supabase/client.ts`](../src/lib/supabase/client.ts) creates the client with persistent sessions, automatic refresh, PKCE, and environment-specific storage keys. Its fetch notices when a signed-in request is refused (401 from REST or Functions, 401 or 403 from Auth's user lookup). [`session-expiry.ts`](../src/lib/supabase/session-expiry.ts) then forces a token refresh outside that request: if Auth refuses the refresh too, the session was revoked, so the browser signs out locally and the login page says the session expired. A network failure, 5xx or rate limit keeps the session. Client-side environment selection is a developer convenience, not authorization. Edge Functions and database policies must enforce access independently. ## AI request flow -[`src/lib/ai/client.ts`](../src/lib/ai/client.ts) is the browser AI boundary. +[`src/lib/ai/client.ts`](../src/lib/ai/client.ts) is the browser AI boundary: app code imports only from it. The request transport is `request.ts` (streaming Ask uses `streaming.ts`, live generation `live.ts`), and each feature has a file in `features/`, mirroring `supabase/functions/ai/features/`. 1. A feature page validates input and calls a feature wrapper. 2. The client reads the current Supabase session. @@ -66,7 +76,15 @@ Client-side environment selection is a developer convenience, not authorization. 5. The backend optionally gathers research, builds prompts, calls an allowlisted model through OpenRouter, validates output, and records the provider-reported usage and charge. 6. The client validates or transforms the response and updates the relevant store. -[`supabase/functions/ai/index.ts`](../supabase/functions/ai/index.ts) serves non-streaming structured tools and practice modes. It is the HTTP entrypoint and dispatcher: authentication, validation, entitlements, model routing, source ranking, and usage accounting run there once, then one handler per feature in [`supabase/functions/ai/features/`](../supabase/functions/ai/features/) builds the prompt and shapes the output. [`supabase/functions/ai-stream/index.ts`](../supabase/functions/ai-stream/index.ts) serves Ask over server-sent events. Each feature's task prompt and user-prompt builder lives in [`supabase/functions/_shared/prompts/`](../supabase/functions/_shared/prompts/) beside one module of shared rules. +[`supabase/functions/ai/index.ts`](../supabase/functions/ai/index.ts) serves the structured tools and practice modes. It is the HTTP entrypoint and dispatcher: authentication, validation, entitlements, model routing, source ranking, and usage accounting run there once, then one handler per feature in [`supabase/functions/ai/features/`](../supabase/functions/ai/features/) builds the prompt and shapes the output. [`supabase/functions/ai-stream/index.ts`](../supabase/functions/ai-stream/index.ts) serves plain Ask over server-sent events. Each feature's task prompt and user-prompt builder lives in [`supabase/functions/_shared/prompts/`](../supabase/functions/_shared/prompts/) beside one module of shared rules. + +### Live generation + +Every structured answer is shown while the model writes it. A page passes a `live` callback to its feature wrapper, and the request adds `stream: true`. Every check before the feature handler (authentication, input, debate context, quota, model routing, source ranking) still answers with plain JSON and its real status. After that, [`supabase/functions/ai/stream.ts`](../supabase/functions/ai/stream.ts) answers with server-sent events: `phase` (`research`, `writing`, `checking`), `delta` with the model's raw text, `reset` when a JSON repair retry starts the answer again, and one final `result` holding the status and body a plain request returns. Only the call whose output the user sees streams; research sub-calls and Jev decisions do not. The joined text is parsed exactly as a non-streamed reply, and streamed usage is billed the same way, marked incomplete if the stream breaks. On the client, [`src/lib/ai/live.ts`](../src/lib/ai/live.ts) reads the stream, [`packages/core/src/ai/partial-json.ts`](../packages/core/src/ai/partial-json.ts) turns the JSON written so far into a draft, and the page renders it with the result's own labels. A JSON body is accepted too, so the client works against a server from before live generation. + +A streamed run keeps its worker alive with `EdgeRuntime.waitUntil` ([`_shared/background.ts`](../supabase/functions/_shared/background.ts)) from the first event until the HTTP layer has read the last one. Supabase otherwise counts the response as sent once its headers are out and may retire the worker mid-run (`EarlyDrop`). Streamed Ask (`ai-stream`) is held the same way until the browser has read its final event. A run still unanswered about 142 s after its request arrived is aborted and gets a retryable timeout `result` instead of a connection cut at the 150 s wall clock; when the aborted run answers within 2 s, that answer is sent instead, so it says `charged: false` when the units went back. Storing the result and waiting for the browser to read it are cut short to finish before the wall clock. The request sends its id as `resultKey`. The function then writes a row to `ai_run_results` when the run starts, and stores the `result` there before sending it ([`ai/run-results.ts`](../supabase/functions/ai/run-results.ts); one row per user and feature, readable only by its owner). When the stream drops, stalls (no keepalive for 45 s), or delivers a result it cannot read, [`src/lib/ai/run-recovery.ts`](../src/lib/ai/run-recovery.ts) polls that row until the run's result appears, then resolves or fails exactly as if the event had arrived. A row still without a status long after it started is a run its worker never finished. A browser Stop, reload, or closed tab still cancels the run. The server cannot tell those from a lost connection, so it stops spending as soon as the connection goes. + +A reply that hits its token cap is written again once with twice the cap, after a `reset`. The Generate cap (12,000 tokens) leaves room for a full comprehensive case on Claude Haiku, so a long case finishes on its first attempt. Claude rejects an assistant prefill, so a cut-off reply cannot be continued where it stopped. Sparring calls send a strict `json_schema` (`sparringOutputSchema` in `ai/sparring-persistence.ts`). Anthropic's structured outputs take no numeric or array-size limits, so the 0-25 range, the four categories, and the 12-item lists are enforced by the parser. When a reply parses but has the wrong shape, the log records only the failing field path, never the model's text. The call is then retried once with a repair prompt that names the field. A second failure answers `invalid_output` (502, logged as `invalid_output`), which the browser shows as a malformed answer. The judge also retries once when Claude is briefly unavailable or its stream breaks. The round has already paid, so retries cost the student nothing. For a validated Crossfire next turn, the server returns the reply first with a short-lived signed sidecar token. The client then requests a Jev classification of only the newest completed question-answer exchange. The token is atomically claimed through its own one-use counter, separate from the product's daily AI quota. The optional directness, concession, and follow-up signal is attached by turn ID to the answer; a missing, slow, replayed, forged, or failed sidecar never delays or invalidates the main reply. Draft version 1 remains compatible because turn signals are optional. @@ -78,7 +96,7 @@ The student's speech never reaches DebateLab as audio. [`src/hooks/use-speech-ca A voice request adds a top-level `voice: true` beside `input`, where a server from before voice rounds ignores it. The opponent prompt then allows a short allowlist of inline audio tags such as `[sighs]` and `[sarcastic]`. [`supabase/functions/_shared/voice-tags.ts`](../supabase/functions/_shared/voice-tags.ts) splits the model's reply in two: the clean text, with every tag removed, is what the response's `output` carries, what is signed, shown, saved, and read by the fallback voice; the spoken text keeps allowlisted tags, turns `[pause]` into the ellipsis Eleven v4 documents for pauses, rations tags to at most one per sentence and never two sentences running, and is cut into segments of at most 1,000 characters. Up to 12 segments are returned in a top-level `voice.segments` list, each with a 30-minute HMAC token from [`_shared/voice-token.ts`](../supabase/functions/_shared/voice-token.ts) that binds the text to the user and the feature; the verifier allows 30 seconds of clock difference between the two functions. Text past the twelfth segment comes back unsigned and without tags in `voice.overflow`, which only the browser voice reads, so a long reply is heard in full without raising the ElevenLabs cost of one reply. The spoken text lives only in memory in the browser. -[`src/lib/voice/opponent-voice.ts`](../src/lib/voice/opponent-voice.ts) plays a reply segment by segment through the [`voice`](../supabase/functions/voice/index.ts) Edge Function, fetching one segment ahead and decoding with Web Audio. The function authenticates the caller, verifies the token so it speaks only text the server wrote, and returns `voice_unavailable` when `ELEVENLABS_API_KEY` is not set, before it reserves anything. It then checks the account, reserves one clip from the per-user daily voice cap through `consume_daily_quota` under the `voice` key (60 clips for paid access, 12 on a trial, overridable with a `daily_voice_clips` row in `rate_limits`), calls ElevenLabs (`eleven_v4` for Sparring, `eleven_v4_turbo` for Crossfire), and logs an `ai_requests` row with the billed characters. Every attempt keeps its clip, including one ElevenLabs refuses, so replayed tokens cannot reach the provider without limit while it fails. Whenever a segment cannot be had, because the key is missing, the cap is reached, or the call fails, the browser's `speechSynthesis` reads the rest of the reply from the clean text and the round continues. After any refusal from the function other than an invalid token, the browser stops asking for the rest of that round and tries ElevenLabs again when the next round starts; a network error is retried on the next reply. In Sparring the student's clock waits while the opponent speaks. Starting to speak stops the opponent's audio. +[`src/lib/voice/opponent-voice.ts`](../src/lib/voice/opponent-voice.ts) plays a reply segment by segment through the [`voice`](../supabase/functions/voice/index.ts) Edge Function, fetching one segment ahead and decoding with Web Audio. The function authenticates the caller, verifies the token so it speaks only text the server wrote, and returns `voice_unavailable` when `ELEVENLABS_API_KEY` is not set, before it reserves anything. It then checks the account, reserves one clip from the per-user daily voice cap through `consume_daily_quota` under the `voice` key (60 clips for paid access, 12 on a trial, overridable with a `daily_voice_clips` row in `rate_limits`), calls ElevenLabs (`eleven_v4` for Sparring, `eleven_v4_turbo` for Crossfire), and logs an `ai_requests` row with the billed characters. Every attempt keeps its clip, including one ElevenLabs refuses, so replayed tokens cannot reach the provider without limit while it fails. Whenever a segment cannot be had, because the key is missing, the cap is reached, or the call fails, the browser's `speechSynthesis` reads the rest of the reply from the clean text and the round continues. After any refusal from the function other than an invalid token, the browser stops asking for the rest of that round and tries ElevenLabs again when the next round starts; a network error is retried on the next reply. In Sparring each of the student's turns opens with a stopped clock, so it waits while the opponent speaks; it runs once the student presses `Start speaking`, starts the microphone, or starts typing. Starting to speak stops the opponent's audio. [`vercel.json`](../vercel.json) allows the microphone for the app's own origin only (`microphone=(self)`); dictation and voice rounds both depend on it. @@ -88,8 +106,12 @@ A configured mock provider bypasses Supabase for local and test behavior. It is Shared research, source ranking, URL validation, and prompt construction live under [`supabase/functions/_shared/`](../supabase/functions/_shared/). +Research makes two small model calls of its own: Ask decides whether a question needs web search, and deep research writes follow-up search queries. Both run on `BACKGROUND_MODEL` (Claude Haiku 5.5 at low effort) in [`supabase/functions/_shared/model-catalog.ts`](../supabase/functions/_shared/model-catalog.ts), not on the model the user picked; only the answer uses that model. Their usage is still added to the parent request's row. + The import endpoint validates public HTTP URLs, resolves redirect targets, blocks private networks, and enforces response timeout, content type, and size limits. Research paths bound source count and content before prompt construction. +[`supabase/functions/_shared/prompt-budget.ts`](../supabase/functions/_shared/prompt-budget.ts) holds the limits that set how many input tokens a request pays for: characters kept per fetched page, search snippets per research prompt, earlier Ask messages, and the Ask context cap. A research prompt lists each source once: a page read in full is not repeated as a search snippet, and a URL that several queries found appears a single time. For `anthropic/*` models the system prompt carries a `cache_control` breakpoint, so a repeat of the same prefix within five minutes is billed at the cache-read price; a prompt below the model's minimum cacheable length is sent uncached at the normal price. + Every AI request in a Current Debate carries a `debateContext` of IDs and preferences, never text: the debate, the side the request prepares, the Library source IDs, and the saved results to read. [`src/lib/debate/context.ts`](../src/lib/debate/context.ts) chooses the default with the rules in [`supabase/functions/_shared/debate-context.ts`](../supabase/functions/_shared/debate-context.ts): the debate's active sources, pinned results first, then up to three of the newest results of the types that feature uses, for the requested side. A compact line above each tool's primary button shows that selection, and Review changes it for one request, or for a whole Crossfire or Sparring round, where Review appears only before the round starts; pins persist in `debate_context_pins`. [`supabase/functions/_shared/debate-context-loader.ts`](../supabase/functions/_shared/debate-context-loader.ts) loads the motion, settings, instructions, sources, and result digests with every query filtered by the caller and the one debate, before quota or provider work. The prompt renders that material in delimited blocks followed by rules that it cannot override. Live sparring stays transcript-only: the scorecard sees only the motion and settings. Crossfire omits free-form instructions. Whole results are dropped when their budget is spent, and a Crossfire round that would pass its 100,000-character prompt budget drops its saved results. In both cases `meta.debateContext` reports what reached the model and what was set aside. A debate deleted on another device returns 404, and the browser reloads its debate list. One-off requests send no debate context. The retired per-user custom system prompt is no longer sent or honored; operator prompts in `global_prompts` remain. Jev scores relevance, importance, and evidence strength after the existing deterministic quality floor, while failures preserve the prior order. Generate also records a bounded source-use snapshot in the saved case output. Semantic counts and the latest case-use flag live in the existing source `metadata` JSON, so the Library can default to Smart ordering without a schema migration. Ordinary Ask remains stateless while a conversation is in progress. The browser sends only the newest eight messages within a UTF-8 byte budget. Inside a debate, each finished exchange is recorded into one chat for that debate; in one-off mode, pressing Save persists the chat and its messages; Workspace threads use their separate durable server-side context path. Legacy local-only sources are not sent to the authenticated AI endpoint because they have no persisted UUID to authorize and load. @@ -104,7 +126,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: @@ -122,6 +144,8 @@ Admin-authored rows such as promo codes, curated sources, announcements, and gra Supabase Auth owns user sessions. Protected routes provide a client navigation boundary, but server authorization is decisive. - 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. 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 closing it continues straight into the tour's Start debate step. If that chunk fails twice, the app logs once, remembers the welcome, and continues without the tour. `?intro=show` opens the welcome again; a finished tour stays finished. - RLS owns row visibility and mutation rights. - Shared backend helpers validate bearer tokens and admin roles. - Admin endpoints verify server-side roles. @@ -132,7 +156,9 @@ Supabase Auth owns user sessions. Protected routes provide a client navigation b Checkout and portal functions create Stripe sessions after authenticated ownership and redirect validation. The webhook updates subscription state and records event processing for idempotency. AI handlers record the provider-reported usage and charge for each request. -A plan can come from several sources, and one database function merges them. `public.effective_entitlement(user_id)` in [`20261014123154_app_store_entitlements.sql`](../supabase/migrations/20261014123154_app_store_entitlements.sql) returns the highest active tier (pro, then legacy_free, basic, trial, expired), its daily limit (50, 50, 20, 5, 0, always replaced by `daily_limit_override`), whether it includes Pro models, the source and its expiry. The sources are `user_settings` (trial, promo and admin grants, legacy access, and the `pro` flag the Stripe webhook sets), Stripe `subscriptions` (counted only while that flag is `pro`), and `app_store_subscriptions`. A missing settings row fails closed. Every tier read goes through it: the AI and voice quota checks (`_shared/entitlements.ts`), the website's `useSubscription` and settings store, and the app, through `readEntitlement` in [`packages/core/src/supabase/entitlement.ts`](../packages/core/src/supabase/entitlement.ts). It runs with the caller's privileges, so a signed-in user reads only their own plan. The admin pages still show and edit the stored `subscription_tier` column. +`resolveBillingState` in [`supabase/functions/_shared/billing-state.ts`](../supabase/functions/_shared/billing-state.ts) decides an account's plan from `user_settings` and its `subscriptions` rows. The AI functions (through `entitlements.ts`) and the Settings card (through `src/hooks/use-subscription.ts`) both call it, so they agree on the state: trial, extended access (an expired trial or the `extra_limit` promo with `daily_limit_override` above 0), Pro, Pro set to cancel, payment failed, legacy free, trial ended, or subscription ended. A failed renewal keeps Pro for 14 days from the first failed payment while Stripe retries (`PAYMENT_GRACE_DAYS`, counted from `subscriptions.payment_failed_at`, which the webhook sets on the first `invoice.payment_failed` and clears on recovery); the webhook no longer downgrades the account on a failed invoice. An account without access is refused with `trial_ended`, `payment_failed`, or `subscription_ended` (older builds sent `trial_expired` for all three, and the client still accepts it). Settings offers Manage subscription, never a second Checkout, while a Stripe subscription can still bill. + +App Store subscriptions count too: `resolveBillingState` takes the account's `app_store_subscriptions` rows with their product tiers (`app_store_products`), and Pro from the App Store ranks with Stripe Pro, the later end showing. Basic, sold only in the app, comes after legacy free and the payment grace and before a trial, with a daily limit of 20. A row counts while it is not revoked, not replaced by an upgrade, and before its expiry or Apple's billing grace end. The website, the iOS app (through `packages/core/src/supabase/billing.ts`) and the AI functions read the same rows. `effective_entitlement()` from the App Store migration is no longer called. The app sells Basic and Pro through StoreKit, and a subscription bought on either the website or the App Store unlocks both. Two Edge Functions write App Store subscriptions, both through `apply_app_store_transaction`, after verifying Apple's JWS signature chain against the pinned Apple Root CA - G3 ([`_shared/app-store.ts`](../supabase/functions/_shared/app-store.ts)) and the bundle id and environment: @@ -141,9 +167,17 @@ The app sells Basic and Pro through StoreKit, and a subscription bought on eithe A subscription row is linked to a user once, by that token, and later transactions update it in purchase-date order. A transaction Apple marks `isUpgraded` grants nothing, as in StoreKit's current entitlements, until the upgrade that replaces it arrives. Product ids map to tiers in `app_store_products`. Until App Store Connect has the real products, that table holds placeholders. -All model requests use the single OpenRouter backend route and `OPENROUTER_API_KEY`. The shared ten-model catalog, Luna Basic default, Pro-only entries, and request controls live in [`supabase/functions/_shared/model-catalog.ts`](../supabase/functions/_shared/model-catalog.ts). OpenRouter usage remains raw in the audit row, and the reported charge is unknown when the provider does not return a complete cost. The opponent's voice is the one exception to the OpenRouter route: the `voice` function calls ElevenLabs with `ELEVENLABS_API_KEY`. ElevenLabs reports billed characters, not dollars, so [`supabase/functions/_shared/cost.ts`](../supabase/functions/_shared/cost.ts) prices those rows from a dated list-price snapshot; promotions and plan credits are not modelled, so a voice row can overstate the charge. +All model requests use the single OpenRouter backend route and `OPENROUTER_API_KEY`. The shared ten-model catalog, its defaults (Claude Haiku 5.5 for Basic, Claude Sonnet 5.5 for Pro; Claude Opus 5.5 runs only when picked), Pro-only entries, retired-ID successors, and request controls live in [`supabase/functions/_shared/model-catalog.ts`](../supabase/functions/_shared/model-catalog.ts). Claude models run only on DebateLab's Anthropic key (OpenRouter BYOK), with no fallback provider; see [`supabase/README.md`](../supabase/README.md). Each feature's reply length cap lives in [`supabase/functions/_shared/output-caps.ts`](../supabase/functions/_shared/output-caps.ts); `max_tokens` adds room for the reasoning effort on top. A JSON reply cut off at its cap is retried once with twice the budget, and a streamed Ask answer cut off there ends with a notice under it. OpenRouter usage remains raw in the audit row, and the reported charge is unknown when the provider does not return a complete cost. For a call billed to our own provider key (BYOK, which the Anthropic models use), OpenRouter's `cost` is only its fee, so the logged cost adds `cost_details.upstream_inference_cost`, and the weekly cap counts the same full charge. Exa searches and page reads report their charge as `costDollars.total`; it joins the request's row, with one `raw_usage.calls` entry per Exa call. The opponent's voice is the one exception to the OpenRouter route: the `voice` function calls ElevenLabs with `ELEVENLABS_API_KEY`. ElevenLabs reports billed characters, not dollars, so [`supabase/functions/_shared/cost.ts`](../supabase/functions/_shared/cost.ts) prices those rows from a dated list-price snapshot; promotions and plan credits are not modelled, so a voice row can overstate the charge. + +Every model call asks for JSON. Calls whose shape the server checks in code (the Crossfire next turn and debrief, the Ask search decision, and research follow-up queries) send a strict JSON Schema, so OpenRouter routes them only to endpoints that enforce it; the other features use plain JSON mode. [`supabase/functions/ai/provider.ts`](../supabase/functions/ai/provider.ts) fixes common slips in a reply locally (markdown fences, prose around the object, trailing commas, raw line breaks in strings) before it pays for one repair retry, and a reply the provider cut off at its cap is retried once with twice the budget, not with the repair prompt. -`Auto Fast` and `Auto Pro` are virtual selections, not provider model IDs. The Edge Functions resolve them through TypeSafe Jev on OpenRouter before calling the concrete writing model. Auto Fast is available to Basic accounts and normally resolves to GLM 5.3 Flash; manual GLM selection keeps its Pro restriction. Auto Pro remains Pro-only. Provider calls, usage rows, and response metadata record the concrete resolved model, while response metadata also retains the requested automatic selection. A Jev router call uses the same OpenRouter key and does not consume a second daily quota unit. +`Auto Fast` and `Auto Pro` are virtual selections, not provider model IDs. Auto routes only to Claude, which runs on DebateLab's Anthropic key (BYOK in OpenRouter); other families bill OpenRouter credits, so they run only when a student picks one by hand. Auto Fast is available to Basic accounts and resolves to Claude Haiku 5.5. Auto Pro remains Pro-only and resolves to Claude Sonnet 5.5; it never routes or falls back to Opus. With one candidate per pool the Edge Functions skip the TypeSafe Jev routing call; a pool with more than one candidate (all `anthropic/`, which `src/test/ai/anthropic-only-guard.test.ts` enforces) is resolved through Jev on OpenRouter before calling the concrete writing model. Provider calls, usage rows, and response metadata record the concrete resolved model, while response metadata also retains the requested automatic selection. A Jev router call uses the same OpenRouter key and does not consume a second daily quota unit. + +The daily AI quota counts units, not requests. `checkRateLimit` in [`supabase/functions/_shared/entitlements.ts`](../supabase/functions/_shared/entitlements.ts) reserves the requested model's weight through `consume_ai_quota` (Claude Opus 5.5 counts as 5, Sonnet as 2, cheap models as 1; an Auto selection counts as its most expensive candidate because the quota is reserved before routing. The Auto weights are derived from the candidate pools, so Auto Pro counts as Sonnet's 2 while Sonnet is its only candidate). The same call refuses the request with `weekly_budget_reached` once the user's weekly AI spend reaches the weekly budget ($2 by default, calendar week from Monday 00:00 UTC). Weekly spend is the sum of `ai_requests.cap_cost_usd`: each request's full logged charge, including Claude calls billed to our own BYOK Anthropic key, plus Exa and ElevenLabs charges. A BYOK response without the upstream charge counts OpenRouter's fee, the part that is known. When part of a request's cost is unknown, the known part counts; rows with no known cap spend, and rows logged before the column existed, do not count. Cost is logged after the provider answers, so the cap can be passed by about one request. The weights, the budget, and the tier defaults live in [`supabase/functions/_shared/usage-limits.ts`](../supabase/functions/_shared/usage-limits.ts). Voice, import, share, and Crossfire signal quotas keep calling `consume_daily_quota`, one unit per call, and are not gated by the weekly budget (voice spend still counts toward it). + +Crossfire and Sparring are charged per round, not per call. The round's first call reserves the model's units through `claim_ai_session_quota` ([`20261022220000_ai_quota_sessions.sql`](../supabase/migrations/20261022220000_ai_quota_sessions.sql)), keyed by the Sparring `sessionId` or the Crossfire round id the client sends as `quotaSession` beside `input`; later turns, the debrief, and the judge in that round reserve nothing. A round past its call cap or lifetime, or switched to a heavier model, pays again (`SESSION_QUOTA` in `usage-limits.ts`), so a reused id cannot buy unlimited AI. A Crossfire request without `quotaSession` (an older client) is charged per call. Before a round starts, both pages send a `quotaCheck` request, which reserves nothing and answers with the round's cost and today's count, so a round the server would refuse never starts. + +A run that produces nothing costs nothing. When an `ai` request fails on the server (a provider failure, a reply cut off at its cap, an answer that failed validation, the time budget running out) and its answer was not saved, the function gives its units back, however much of it had streamed. When the browser goes away (reload, closed tab, Stop, a dropped connection; the server cannot tell these apart), the run is refunded only before `REFUND_MAX_DELIVERED_CHARS` of streamed output reached the student and before the model finished its answer. Otherwise a student could read most of an answer and press Stop to get the unit back. For the plain-text Ask stream the streamed text is the answer, so `ai-stream` refunds only before `REFUND_MAX_DELIVERED_CHARS` reached the reader, whatever ended the run. A refund goes through `refund_daily_quota`, or `refund_ai_session_quota` for the call that paid for a round, at most once per run, and the answer carries `charged: false`, which the client shows as "You were not charged". The weekly cap still counts what the run cost. The usage line in Settings and on every tool page reads the same counter through `ai_usage_today()` (the caller's own units today), so per-round charging and refunds show there, and it is read again after each AI call (`refreshAIUsage` in `src/lib/query-client.ts`). When the counter cannot be read (an older database), the line is hidden rather than estimated from `ai_requests` rows. `ai_requests.error_code` holds a short code (`provider_error`, `timeout`, `aborted`, `claude_unavailable`, the refusal codes); `logUsage` maps anything else to one of those and sends the raw text, truncated, to the function log only. ## Observability @@ -153,10 +187,12 @@ CI and staging must not upload authenticated browser captures. Logging must not ## PWA and offline behavior -The service worker and manifest provide installable web assets and an offline app shell. It precaches only the navigation shell and static identity assets; route chunks enter the runtime cache when a user visits them. On first registration, the client also reports same-origin `/assets/` resources that loaded before the worker took control so the first visited route works offline without a forced reload. Updates wait for the normal service-worker lifecycle instead of reloading an active page. Offline support does not make 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. ## Deployment boundary -Vercel builds `dist/` with Node 22 and serves it. [`vercel.json`](../vercel.json) owns the install command (root and `@debatelab/core` only), SPA fallback, cache policy, crawler restrictions, and response security headers. Merging to `main` deploys the frontend to production; Supabase migrations and Edge Functions deploy separately. GitHub workflow files own quality, live-provider, staging, and canary automation. +Vercel builds `dist/` with Node 22 and serves it. [`vercel.json`](../vercel.json) owns the install command (root and `@debatelab/core` only), the app-route rewrites, the 404 for everything else, cache policy, crawler restrictions, response security headers, and which Git branches deploy. A push to `main` deploys production; any other branch does not until someone requests a preview ([operations.md](operations.md#opt-in-previews)). Supabase migrations and Edge Functions deploy separately. GitHub workflow files own quality, live-provider, staging, and canary automation. Read [operations.md](operations.md) before changing deployment behavior and [testing.md](testing.md) before claiming a boundary works. diff --git a/docs/development.md b/docs/development.md index fe9e4f53b..107e0bfc6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -21,7 +21,7 @@ Never overwrite unrelated changes. Do not switch to the primary checkout because - Node.js 22.x - npm and `package-lock.json` - Deno 2.x for Edge Function tests -- Docker and Supabase CLI 2.115 or newer for database work +- Docker and Supabase CLI 2.121.0, pinned in `package.json` (`npm ci` installs it) for database work - Playwright browser binaries for browser gates Install the locked JavaScript tree with the Node version from `.nvmrc`: @@ -53,7 +53,7 @@ Automated browser tests always start a fresh server. `npm run verify` puts it on ## Preview on the tailnet -A plain `npm run dev` talks to the production Supabase project unless the browser toggle says staging. For anything Adam opens from another device, run the staging preview instead: +A plain `npm run dev` talks to the production Supabase project unless the browser toggle says staging. For anything Adam opens from another device, run the staging preview instead. This local server is not a Vercel deployment. Hosted Vercel previews are opt-in; [operations.md](operations.md#opt-in-previews) has the command. ```bash npm run dev:preview -- --port 9201 --strictPort @@ -296,13 +296,29 @@ In a Superset workspace, use the repository's locking wrapper: Outside Superset, run the underlying commands directly: ```bash -supabase db start +npx supabase db start npm run test:db -supabase stop --no-backup +npx supabase stop --no-backup ``` The default local stack begins at port `54321` and uses shared container names, so it is not parallel-safe across worktrees. The wrapper holds `~/.superset/debatelab-db.lock` through database tests and the real-Auth collaboration browser journey, and stops the stack and owned processes on exit. Outside Superset, coordinate ownership before starting it. The agent that starts the stack stops it before finishing, including after failures when safe. +### A private database per agent + +When several agents need a database at once, each can run its own Docker-free project with [`scripts/agent-stack.sh`](../scripts/agent-stack.sh). It uses the Supabase CLI's experimental stack engine (CLI 2.121 or newer) on the native runtime: no lock, no shared ports, and a cold start of about 20 seconds. + +```bash +scripts/agent-stack.sh --name my-task start # applies every migration; prints API_URL and DB_URL +scripts/agent-stack.sh --name my-task test # what npm run test:db runs: pgTAP and the concurrency scripts +scripts/agent-stack.sh --name my-task psql -c 'select 1' +scripts/agent-stack.sh --name my-task advisors # Supabase security advisors +scripts/agent-stack.sh --name my-task reset # re-apply migrations after editing them +scripts/agent-stack.sh --name my-task serve fn.env # Edge Functions with fn.env's variables (foreground) +scripts/agent-stack.sh --name my-task destroy # always, including after failures +``` + +Ports are assigned by the CLI; read them from the env file `start` prints. The stack cannot serve the magic-link email template, so the script runs the project from an overlay directory under `~/.cache/debatelab-stacks/` that drops that block and symlinks everything else. Use it for migrations, pgTAP, the concurrency scripts and RLS queries. The full-stack browser journeys still need `./.superset/db-gate.sh`, which is also what CI runs. + Never point local tests at staging or production. Never apply a migration to an unverified project. ## Browser workflow diff --git a/docs/operations.md b/docs/operations.md index d15868d53..e24af4cc3 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -36,18 +36,45 @@ If Actions jobs fail within 2 to 5 seconds with no runner and no logs, the accou ## Frontend hosting on Vercel -Vercel builds and serves the frontend from the Git repository. Merging to `main` starts a production deployment to `https://debate.pi-stack.org`; pull requests get two preview deployments (see [Per-PR previews](#per-pr-previews)). For a staging-pinned preview on the tailnet, use `npm run dev:preview` (see [development.md](development.md#preview-on-the-tailnet)). The production canary runs after each successful Vercel `Production` deployment. +Vercel builds and serves the frontend from the Git repository. A push to `main` deploys both projects: `debate-navigator` to `https://debateowl.com` (the previous domain, `https://debate.pi-stack.org`, 301s there with the path and query kept; see `vercel.json`; `https://www.debateowl.com` redirects to the apex through the project's Domains setting, not `vercel.json`, also keeping the path and query), and `debatelab-staging` to `https://debatelab-staging.vercel.app`. Any other branch waits for an explicit preview (see [Opt-in previews](#opt-in-previews)). For a staging-pinned preview on the tailnet, use `npm run dev:preview` (see [development.md](development.md#preview-on-the-tailnet)). The production canary runs after each successful Vercel `Production` deployment of `debate-navigator`. [`vercel.json`](../vercel.json) owns: +- automatic Git deployments: `main` only. `git.deploymentEnabled` sets `main` to true and `**` to false. A branch deploys when any matching rule is true, and a branch that matches nothing defaults to true, so the `**` rule has to stay. `*` does not match a slash, which would leave branches such as `cursor/…` deploying. A push that matches no true rule creates no deployment and does not count toward the 100-deployments-per-day cap; - the Vite build command and `dist` output; - the install command, which installs only the root package and `@debatelab/core` so the deploy never installs the iOS app's React Native tree; -- SPA fallback to `index.html`; -- no-cache headers for `/`, `index.html`, `theme-init.js`, and `sw.js`, and immutable caching for hashed `/assets/`; -- `noindex` headers for `/app`, `/login`, and `/share` routes; -- Content Security Policy and the other response security headers, including the `Permissions-Policy` that allows the microphone for the app's own origin only. +- rewrites of the app routes (`/app`, `/login`, `/share`, `/expocit/sources`) to `app-shell.html`; any other path that is not a file gets `404.html` with a real 404 status. `/expocit/sources` is also prerendered, and the filesystem is served before rewrites, so that file is what visitors and crawlers get. The rewrite remains for a build that did not emit it; +- `trailingSlash: false`, which 308-redirects `/app/settings/` to `/app/settings` before routing, since rewrite and redirect sources match exactly; +- 301 redirects from the retired brand pages (`/debatelab`, `/debatelab-by-programcode`, and the rest) to `/`; +- 301 redirects from the renamed comparison and pricing pages (`/debatelab-pricing`, `/debatelab-vs-chatgpt`, `/debatelab-vs-debateclub`, `/debatelab-vs-windebate`, also under `/hu/` or `/es/` and with a trailing slash) to their `/debateowl-*` pages in one hop, query kept; +- 301 redirects from retired guide slugs to the guide that replaced them, such as `/debate-weighing-guide` to `/how-to-weigh-in-debate`. The `trailingSlash` 308 runs first, so `/debate-weighing-guide/` reaches the same page in two permanent hops and needs no rule of its own; +- revalidating cache headers (`public, max-age=0, must-revalidate`, without `no-store`, so a document can enter the back/forward cache) for `/`, `index.html`, `app-shell.html`, and `theme-init.js`. `sw.js` stays `no-store` so a new service worker is not stuck in the HTTP cache. Hashed `/assets/` are immutable. The unhashed images in `public/` (the favicons, `apple-touch-icon.png`, the `icon-*.png` manifest icons, `hero-visual.jpg`, `placeholder.svg`) are cached for 30 days; the test reads `public/`, so a new icon file must be added to that rule. Jam's capture iframe is removed on `pagehide` (never via `unload`) so that cross-origin frame cannot veto the cache; +- `noindex` headers for `/app`, `/login`, `/share` and `/embed/debate-timer`. The app shell's robots meta is `noindex, nofollow`, the same policy. Public pages, including the prerendered `/expocit/sources`, are `index, follow`. A route deletes the platform `Access-Control-Allow-Origin: *` header on HTML responses and leaves it on static assets; +- Content Security Policy and the other response security headers, including the `Permissions-Policy` that allows the microphone for the app's own origin only. Jam is limited to `https://js.jam.dev` and `https://recorder.jam.dev`; `script-src` does not include `unsafe-eval`. Web Analytics and Speed Insights stay on this origin (`/_vercel/insights` and `/_vercel/speed-insights`), which `'self'` already allows. Every path except `/embed/debate-timer` refuses to be framed (`X-Frame-Options: DENY` and `frame-ancestors 'none'`). `/embed/debate-timer` allows any site to frame it and gets its own CSP, which permits only same-origin scripts and requests. The two rules use complementary sources (`/((?!embed/debate-timer$).*)` and `/embed/debate-timer`) so no path gets both, a new embed needs its path added to both, and `src/test/vercel-deployment-config.test.ts` fails if two matching rules ever set the same header. -The build also writes `dist/terms/index.html` and `dist/privacy/index.html`: copies of the app shell whose canonical URL names their own route instead of the landing page (`selfCanonicalShells` in [`vite.config.ts`](../vite.config.ts)). Vercel serves them before the SPA fallback. Add a route there when a new SPA page joins `public/sitemap.xml`. +After `vite build`, [`scripts/prerender.mjs`](../scripts/prerender.mjs) renders the landing page, `/terms`, `/privacy`, `/expocit/sources`, and the 404 page in Node through [`src/prerender/entry.tsx`](../src/prerender/entry.tsx). It writes them to `dist/index.html`, `dist/terms/index.html`, `dist/privacy/index.html`, `dist/expocit/sources/index.html`, and `dist/404.html`, each with its own title, description, and canonical URL from [`src/lib/seo/pages.ts`](../src/lib/seo/pages.ts); the landing page also carries the Organization, SoftwareApplication (with the trial and Pro offers) and FAQPage JSON-LD. The built shell is kept as `dist/app-shell.html`. On load, the client replaces the prerendered HTML with the same page. A new public SPA page needs an entry in `PAGE_HEADS`, in the prerender entry, and a route kept out of the 404. + +The free tools under `/tools` ([`src/pages/tools/`](../src/pages/tools/)) are prerendered the same way, listed in [`src/prerender/tool-pages.tsx`](../src/prerender/tool-pages.tsx) with heads in [`src/lib/seo/tool-pages.ts`](../src/lib/seo/tool-pages.ts). They render their own JSON-LD in the page body, so the dev server and the prerendered HTML carry the same structured data, and their `dateModified` is `TOOL_PAGES_MODIFIED` in that file: bump it with a substantive content change. Their speech times come from [`src/lib/free-tools/formats.ts`](../src/lib/free-tools/formats.ts), which cites a league source per format. + +The landing page and the free tools also exist in Hungarian and Spanish, each at its own address: `/hu`, `/es`, and the tools under translated slugs such as `/hu/tools/vitaidomero` and `/es/tools/cronometro-debate`. [`src/config/localized-routes.json`](../src/config/localized-routes.json) is the one list of those addresses; the router, the prerender, `check-site` and `llms.txt` read it. The prerender renders each one in its language, with `<html lang>`, a self-canonical, and `hreflang` links for `en`, `hu`, `es` and `x-default` (English) on all three versions; pages without translations get no `hreflang`. Heads come from the `pageHead` catalog section in that language. The translated tool pages reuse the English widgets, take their copy from [`src/pages/tools/localized/`](../src/pages/tools/localized/), and set `dateModified` from that copy's `modified` date. Opening a translated address chooses its language, like a `?lang=` link, so Start leads into the app in that language; choosing another language there moves to that language's address. The old homepage links `/?lang=hu` and `/?lang=es` get a `vercel.json` 301 to `/hu` and `/es`. Vercel appends the original query, so the browser arrives at `/hu?lang=hu`, which is canonical to `/hu`; the client drops `?lang=` on load. `?lang=` on any other page (`/app?lang=hu`) is not redirected. + +`/embed/debate-timer` is a second Vite HTML entry ([`embed/debate-timer/index.html`](../embed/debate-timer/index.html)), not an app route: it loads only the timer, never the app shell, Jam, analytics or the service worker, so a site that frames it sees no third-party request. It is `noindex` with its canonical URL on `/tools/debate-timer`, and the service worker leaves `/embed/` alone. + +[`scripts/sitemap.mjs`](../scripts/sitemap.mjs) then writes `dist/sitemap.xml` from every page in `dist/` that names itself as canonical and is not `noindex`. A page with `hreflang` links in its head is listed with the same alternates as `xhtml:link` entries. Each `<lastmod>` is the `dateModified` the page declares in its JSON-LD: the guide pages' `CONTENT_DATE` in [`scripts/generate_seo_content.py`](../scripts/generate_seo_content.py), the legal pages' "last updated" dates, the tool pages' `TOOL_PAGES_MODIFIED`, and the landing page's last source commit. A page without a date is listed without `<lastmod>`. + +[`scripts/check-site.mjs`](../scripts/check-site.mjs) runs next and fails the build when an internal link, a `#fragment`, a sitemap URL or an `llms.txt` URL does not resolve (it applies the `vercel.json` redirects and rewrites), when JSON-LD does not parse or carries HTML entities, when a FAQPage question is not visible on its page, when the home page's entity graph has an unresolved `@id`, a `WebSite` or `SoftwareApplication` not named for the product, or no `Organization.sameAs`, when a page's `publisher` or `about` names an `@id` the home page does not define, when an indexed page other than `/terms`, `/privacy`, and `/expocit/sources` lacks its question H2 (`#short-answer`) as the first H2, when a page's first heading is not its H1 or a heading skips a level (H1 to H3), when an image has no alt text, a button has no name or a form field has no label, or when a page's `hreflang` links do not resolve, leave out the page itself or `x-default`, disagree with `<html lang>`, or differ from the set on a page they name. + +[`scripts/check-links.mjs`](../scripts/check-links.mjs) runs after it and fails the build when a sitemap page other than `/terms`, `/privacy` and `/expocit/sources` has fewer than 3 inbound links from other sitemap pages, or when the home page links to fewer than 15 distinct sitemap pages. `node scripts/check-links.mjs --report` prints every count after a build. The home page reaches the guides and tools through [`GuideHub.tsx`](../src/components/landing/GuideHub.tsx). + +The guide pages in `public/<slug>/index.html` are generated, not hand-edited: run `python3 scripts/generate_seo_content.py` and commit its output. Short pages live in its `PAGES` list; pages with long-form copy, their own highlight-card descriptions, and their own FAQs (which also feed the page's FAQPage JSON-LD) live in [`scripts/seo_guides.py`](../scripts/seo_guides.py). Each page's "Related" block shows up to six pages from its own topic cluster and one from each of two other clusters, picked from `CLUSTERS`; every page in `PAGES` must sit in exactly one cluster, and the generator stops when one does not. Paragraphs also get up to three in-body links from `LINK_PHRASES`, specific phrases first; `python3 scripts/generate_seo_content.py --links` lists the links each page got. A guide entry can set a later `modified` date for that page alone; no page is dated earlier than `CONTENT_DATE`, which covers the copy every guide shares, including the related and in-body links, so move it when `CLUSTERS` or `LINK_PHRASES` change. Sections hold paragraphs and `h3`, `steps`, `list`, `table` and `example` blocks, plus a `speech_times` block that builds the speech-times table from the free timer's formats in [`src/lib/free-tools/formats.ts`](../src/lib/free-tools/formats.ts), so the guide and the timer cannot disagree; a guide's `definition` (question, term, text) renders a definition list under the short answer and a `DefinedTerm` in its JSON-LD; paragraphs, list items and table cells can hold Markdown-style links to a site path or, for a primary source, an `https://` URL. Every guide needs FAQs of its own in `seo_guides.py`; the generator refuses a page without them, so no two pages share boilerplate questions. [`src/test/seo/guide-content.test.ts`](../src/test/seo/guide-content.test.ts) fails when a guide has fewer than 700 words outside its related and About blocks, or when a sentence longer than 60 characters appears on more than three guides. + +Every guide opens with the reader's main question as its first H2 and an answer of at most 100 words, kept in [`scripts/seo_answers.py`](../scripts/seo_answers.py); the generator refuses a page without one or with a longer answer. Each guide shows visible "Published" and "Updated" dates (`PUBLISHED_DATE` or a `published` key, and the `modified` date) that match its Article JSON-LD. Prices and plan facts are stated "as of" the month in `factsCheckedOn` in `src/config/site.json`; move that date when you recheck them, and the pricing page's About block, `llms.txt` and the landing page follow. Every other guide has a one-line About that links to the landing page and pricing instead of repeating the plan facts. Copy in `seo_guides.py` and `seo_answers.py` that names the month itself (prices and competitor facts) needs the same edit: search those files for "As of". The generator also writes `public/llms.txt`, the plain summary for AI agents. `public/robots.txt` allows AI crawlers by name in their own group, which repeats the private-route rules because a crawler that matches a named group ignores `*`. + +To retire a guide, delete its `PAGES` entry, its `CLUSTERS` and `LINK_PHRASES` entries, and its `public/<slug>/` folder (the generator never deletes), add its redirects to `vercel.json`, and repoint any link to it. Two guides that answer the same search should become one page, not two thin ones. + +The site origin, product name, brand line, organization name and the organization's `sameAs` profiles live in [`src/config/site.json`](../src/config/site.json). The app's head tags, the prerender and sitemap scripts, and the guide generator all read it, and guide copy names the product with a `{product}` token and the Pro price with `{pro_price}`. The number of prep modes comes from the tool lists in [`tool-links.tsx`](../src/components/features/tools/tool-links.tsx): the app's copy reads `PREP_MODE_COUNT` and `UTILITY_COUNT`, and guide copy uses `{mode_count}`, `{mode_names}` and `{utility_names}`. The trial length and Pro price come from `pricing` in the same file: i18next passes `{{trialDays}}`, `{{proMonthly}}`, `{{product}}`, `{{brand}}` and `{{host}}` to every catalog string, so the English, Spanish and Hungarian pages state the same facts. Do not hardcode those strings in new code or copy. App strings name the product with `{{product}}`, `{{brand}}` and `{{host}}`, which [`src/i18n.ts`](../src/i18n.ts) fills from `site.json` for every key. + +After a production deploy, and only with Adam's approval, `npm run seo:indexnow` sends every URL in the sitemap to IndexNow in one request to `api.indexnow.org`, which shares it with Bing, Yandex, Seznam, Naver and the other engines that take part (not Google). It reads `dist/sitemap.xml` when a build is present and the live sitemap otherwise. `-- --only /tools /` sends just those pages, and `-- --dry-run` prints the request without sending it. The key file is `public/<key>.txt`. It is not wired into CI. [`src/test/vercel-deployment-config.test.ts`](../src/test/vercel-deployment-config.test.ts) guards these rules. Update it together with `vercel.json`, and do not paste the full configuration into another guide. @@ -71,7 +98,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 @@ -83,15 +110,32 @@ Before a production backend deploy, check that the commit's `staging-sync` statu [`src/lib/supabase/env.ts`](../src/lib/supabase/env.ts) reads the `debate-supabase-env` localStorage key once at startup. The value `staging` selects staging, and anything else selects production. Each project keeps its own auth session under `debate-auth-<env>`. -Admins see a **Developer** card in Settings that signs out, sets the key, and reloads. The app's persisted stores are shared by both projects, so the switch also clears this project's stored session, its Google token, and all user-specific local data, including saved results and form drafts. It waits at most 4 seconds for the sign-out request, so an unresponsive project cannot block it. Other open tabs clear their own session storage, including their Google token, and reload as soon as the key changes, so no tab keeps talking to the old project while sharing stores with the new one. The strip publishes its height as `--staging-banner-height`, and the landing navigation and app header sit below it. While staging is active, every page shows a staging strip with **Switch to Production**, and the Settings card is shown to everyone. A new staging session starts signed out, and an admin on production may not be an admin on staging, so the way back cannot depend on that role. The switch is a convenience, not an access control. Staging RLS and its separate user accounts are the boundary. +Admins see a **Developer** card in Settings that signs out, sets the key, and reloads. The app's persisted stores are shared by both projects, so the switch also clears this project's stored session, its Google token, and all user-specific local data, including saved results and form drafts. It waits at most 4 seconds for the sign-out request, so an unresponsive project cannot block it. Other open tabs clear their own session storage, including their Google token, and reload as soon as the key changes, so no tab keeps talking to the old project while sharing stores with the new one. The strip publishes its height as `--staging-banner-height`, and the landing navigation and app header sit below it. While staging is active, every page shows a staging strip with **Switch to production**, and the Settings card is shown to everyone. A new staging session starts signed out, and an admin on production may not be an admin on staging, so the way back cannot depend on that role. The switch is a convenience, not an access control. Staging RLS and its separate user accounts are the boundary. + +A build can pin one project with the `VITE_SUPABASE_ENV` build variable (`production` or `staging`; any other non-empty value fails the build). A pinned build ignores the localStorage key, hides the Settings card and the strip's **Switch to production** button, and does not reload when another tab switches. The strip still shows on a pinned staging build. The staging Vercel project uses this so a requested preview is pinned to staging; see [Opt-in previews](#opt-in-previews). + +The switch only works while the `connect-src` directive in [`vercel.json`](../vercel.json) allows the staging project origin. It lists exactly the two project origins from [`src/lib/supabase/projects.ts`](../src/lib/supabase/projects.ts), and `src/test/vercel-deployment-config.test.ts` fails if they drift apart. The same file is deployed to production and to `debatelab-staging`, and an unpinned production preview can still select staging, so the staging origin stays in the production site's CSP. -A build can pin one project with the `VITE_SUPABASE_ENV` build variable (`production` or `staging`; any other non-empty value fails the build). A pinned build ignores the localStorage key, hides the Settings card and the strip's **Switch to Production** button, and does not reload when another tab switches. The strip still shows on a pinned staging build. The staging Vercel project uses this so each PR gets a staging preview; see [Per-PR previews](#per-pr-previews). +## Opt-in previews -The switch only works while the `connect-src` directive in [`vercel.json`](../vercel.json) allows the staging project origin. Today the `*.supabase.co` wildcard covers it. If you narrow that wildcard, list both project origins explicitly. +A push to a branch other than `main` does not deploy. Both Vercel projects read this `vercel.json`, so neither builds a preview on its own. There is no long-lived staging git branch: `debatelab-staging`'s production deployment tracks `main` and stays at `https://debatelab-staging.vercel.app`. -## Per-PR previews +Request a preview only when a real UI change needs a hosted URL. `npm run dev:preview` and the `verify-debatelab` skill do not spend a Vercel deployment. -Each pull request gets two Vercel previews, one per Supabase project, and both rebuild on every push: +From the repo root, with the CLI logged in (`vercel login`, or `VERCEL_TOKEN` in the environment; never commit a token): + +```bash +npx vercel link --yes --non-interactive --scope adam-gaborjanis-projects --project debatelab-staging +npx vercel deploy --yes --non-interactive --scope adam-gaborjanis-projects +``` + +Leave off `--prod`. Do not run `vercel --yes` before the link: the first deployment of a new project is production. The command above is one preview on `debatelab-staging`, pinned to staging by that project's `VITE_SUPABASE_ENV`. Linking `debate-navigator` instead builds a preview with no `VITE_SUPABASE_ENV`, so it uses production data unless the saved toggle says staging. One preview is enough, and the staging project is the one to use unless the change needs production data. + +Adam can do the same from the Vercel dashboard: Create Deployment, pick the branch, leave the target as Preview. That needs no project setting change. + +A commit message cannot turn a deployment back on. `git.deploymentEnabled` is decided before a build exists, which is what keeps the skipped push off the daily cap. An Ignored Build Step (`ignoreCommand`) runs only after a deployment is created, and that skip still counts, so this repo does not use one. + +The preview hosts are unchanged: - `debate-navigator` (the production Vercel project) builds previews with no `VITE_SUPABASE_ENV`, so they use production unless the saved toggle says staging. Production's Auth redirect allowlist must include `https://debate-navigator-*-adam-gaborjanis-projects.vercel.app/**` for Google sign-in to return to the preview. - `debatelab-staging` builds the same repository with `VITE_SUPABASE_ENV=staging`. Staging's Auth redirect allowlist must include `https://debatelab-staging-*-adam-gaborjanis-projects.vercel.app/**`. Its production deployment tracks `main`. @@ -100,6 +144,8 @@ Edge Function CORS and the demo-code sign-in redirect accept these hosts through The staging project's name must not start with `debate-navigator-`, or production's allowlist pattern would also match it. A production preview runs unmerged frontend code with real production sessions and data, so use the staging preview unless the change needs production data. A staging preview runs against whatever backend staging has; if the PR changes migrations or Edge Functions, sync staging first (see [Staging sync](#staging-sync)). +A branch that does not contain this `vercel.json` still deploys on push. After the change is on `main`, branches cut from `main` inherit it. + ## Backend drift check `npm run backend:status` shows what the current commit has that a Supabase project lacks. It inspects the project the Supabase CLI resolves, which is `SUPABASE_PROJECT_ID` when that is set and the linked project otherwise; `npm run backend:status -- --project-ref <ref>` inspects another one. It runs `supabase migration list`, `supabase functions list` and `supabase secrets list`, reads the Auth site URL and redirect allowlist from the Management API with the CLI's access token (`SUPABASE_ACCESS_TOKEN`, or the `access-token` file in `SUPABASE_HOME`, default `~/.supabase`), and changes no schema, data, functions, or secrets. It prints secret names only, never values or digests. To connect to the database, the CLI creates its usual short-lived login role. @@ -134,6 +180,10 @@ Stop when the project target is ambiguous, rollback is unproven, or the operatio Destructive statements such as `DROP` or `TRUNCATE`, secret rotation, project deletion, and broad unrelated function deployment always need separate explicit approval. +### Keepalive monitor + +`keepalive_events` and `keepalive_heartbeat` belong to the external Supabase keepalive monitor (`Programcode/supabase-keepalive-monitor`), not the app. Since `20261024120000_keepalive_server_only.sql` only the service role can read or write them. The monitor's primary run, a systemd user timer on Adam's VPS, uses the service-role key. Any other copy of the monitor (the Pi's GitHub Actions backup) must also use the service-role key for the DebateLab projects; with the publishable key its REST and RPC steps get 401 or 403. + ## Edge Function deployment boundaries [`supabase/config.toml`](../supabase/config.toml) disables gateway JWT verification for functions that authenticate or validate requests in their own handlers. Deployment must preserve those handler checks. @@ -157,6 +207,16 @@ Before enabling protected browser staging, verify `http://127.0.0.1:18080` for ` Local development origins must remain bounded to intended ports and hosts. +## Sign-in email and DNS + +Production and staging Supabase Auth send sign-in codes from `DebateOwl <auth@debateowl.com>` through Resend's SMTP relay (`smtp.resend.com`); the relay password lives only in each project's Auth settings. DNS for `pi-stack.org` is the Cloudflare zone. The records below are the ones set up for the previous sender, `auth@debate.pi-stack.org`; `debateowl.com` has the equivalent Resend records in its own Cloudflare zone. The previous sender's records are: + +- `send.debate.pi-stack.org`: Resend's SPF (`include:amazonses.com`) and bounce MX, so the bounce domain aligns with the From address. +- `resend._domainkey.debate.pi-stack.org`: Resend's DKIM key; messages are signed with `d=debate.pi-stack.org`. +- `_dmarc.debate.pi-stack.org`: `p=quarantine` with relaxed alignment. Reports go to the same Gmail address as `_dmarc.pi-stack.org`; receivers that check external report authorization drop them. + +The SPF and MX records on `debate.pi-stack.org` itself belong to Cloudflare Email Routing for inbound mail. A name can hold only one SPF record, so do not add Resend's there. Before tightening DMARC to `p=reject`, or after changing the sender, send a staging sign-in code to a mail-tester address and confirm `dmarc=pass`, then delete that staging user. Any other service that sends as `@debate.pi-stack.org` must be added to SPF or DKIM first, or its mail lands in spam. + ## Secrets Secret names may be documented; values may not. Server-side categories include: @@ -180,7 +240,7 @@ Vercel build variables for Sentry source maps: `SENTRY_AUTH_TOKEN` (an organizat Choose rollback before changing production: -- Frontend: promote the previous known-good Vercel production deployment (about 10 seconds), or revert the commit on `main` and let Vercel redeploy (one to two minutes). The Vercel project is `debate-navigator`. `vercel inspect debate.pi-stack.org` shows the live deployment, `vercel ls debate-navigator --environment production --status READY` lists the earlier ones, and `vercel rollback <previous-deployment-url> --yes` switches back. On Adam's VPS, `~/ops/rollback/rollback vercel debate-navigator` does all three. After a rollback, Vercel stops putting new `main` deployments live until one is promoted with `vercel promote <deployment-url>`, so promote the fixed deployment once it is ready. A frontend rollback does not touch Supabase: if the bad change came with a migration or function deploy, the old frontend must still work with the new backend. +- Frontend: promote the previous known-good Vercel production deployment (about 10 seconds), or revert the commit on `main` and let Vercel redeploy (one to two minutes). The Vercel project is `debate-navigator`. `vercel inspect debateowl.com` shows the live deployment, `vercel ls debate-navigator --environment production --status READY` lists the earlier ones, and `vercel rollback <previous-deployment-url> --yes` switches back. On Adam's VPS, `~/ops/rollback/rollback vercel debate-navigator` does all three. After a rollback, Vercel stops putting new `main` deployments live until one is promoted with `vercel promote <deployment-url>`, so promote the fixed deployment once it is ready. A frontend rollback does not touch Supabase: if the bad change came with a migration or function deploy, the old frontend must still work with the new backend. - Edge Function: retain the deployed source or artifact baseline and its environment assumptions. - Migration: prefer forward corrective migrations. Do not assume an irreversible schema or data change can be rolled back. - Configuration: record the prior value without exposing secrets. @@ -206,6 +266,6 @@ Do not expose protected data in artifacts. Record the commit, environment, opera - Green local E2E plus staging failure: schema, functions, provider access, demo code, or CORS may differ. Run the staging suite locally first; if it fails there too on a UI step, the spec is stale, not staging. - New frontend errors against Supabase right after a merge: the matching migration or function may not be applied to production yet. Run `npm run backend:status` from the linked checkout. - Function returns unauthorized after deploy: check handler auth and environment propagation before changing gateway settings. -- Browser works on one route only: check the `vercel.json` SPA rewrite and cache headers. +- Browser works on one route only, or an app route shows the 404 page: check the `vercel.json` app-route rewrites and cache headers. Read [testing.md](testing.md) for evidence rules and [`../supabase/README.md`](../supabase/README.md) for backend ownership. 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/staging.md b/docs/staging.md index adae12056..eed7535cb 100644 --- a/docs/staging.md +++ b/docs/staging.md @@ -6,7 +6,7 @@ Staging is the Supabase project `debate-trainer-staging` (`brifeyypjauyusmkptkx` ## Get in -**On your phone or laptop, for a PR.** Every PR gets a `debatelab-staging` Vercel preview; its link is in the Vercel comment on the PR. That build is pinned to staging, whatever the browser's saved toggle says. Previews sit behind Vercel login, so sign in to Vercel once on each device. The permanent staging site is `https://debatelab-staging.vercel.app`, built from `main`. +**On your phone or laptop, for a PR.** A pull request does not get a Vercel preview unless someone requests one ([operations.md](operations.md#opt-in-previews)). The preview to open is `debatelab-staging`: it is pinned to staging, whatever the browser's saved toggle says. Previews sit behind Vercel login, so sign in to Vercel once on each device. The permanent staging site is `https://debatelab-staging.vercel.app`, built from `main`. **Sign in.** Two ways: 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/standards/boundaries.md b/docs/standards/boundaries.md index a8b12821f..c40398f1a 100644 --- a/docs/standards/boundaries.md +++ b/docs/standards/boundaries.md @@ -1,6 +1,6 @@ # Trust boundaries and validation -Part of the [coding standards](../../CODING_STANDARDS.md). The client AI boundary is [`src/lib/ai/client.ts`](../../src/lib/ai/client.ts); the server boundaries are the Edge Function handlers described in [supabase/README.md](../../supabase/README.md). +Part of the [coding standards](../../CODING_STANDARDS.md). The client AI boundary is [`src/lib/ai/client.ts`](../../src/lib/ai/client.ts), and response validation lives in `src/lib/ai/request.ts`, `src/lib/ai/streaming.ts`, `src/lib/ai/sources.ts`, and each `src/lib/ai/features/*.ts`; the server boundaries are the Edge Function handlers described in [supabase/README.md](../../supabase/README.md). ## Validate at the boundary, then trust the type diff --git a/docs/standards/security.md b/docs/standards/security.md index d9ad20a18..f771230cd 100644 --- a/docs/standards/security.md +++ b/docs/standards/security.md @@ -16,6 +16,7 @@ Part of the [coding standards](../../CODING_STANDARDS.md). AGENTS.md "Hard bound - A suite that writes a real session into a browser restricts its base URL to loopback ([#21](https://github.com/Programcode/DebateLab/pull/21#discussion_r3896484019)). - Documentation names the secret, never the account's email or password ([#68](https://github.com/Programcode/DebateLab/pull/68#discussion_r4095363422)). - One secret, one purpose. Do not derive signing keys from the service-role key; rotating it would break every outstanding token ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649683)). +- An Edge Function that fetches a user-supplied URL connects only to an address it has just checked against the IANA special-purpose ranges, never to the hostname: `fetch` resolves the name again, so a DNS answer that changes in between (rebinding) would reach a blocked range. `import-source` dials the checked address with [`pinned-fetch.ts`](../../supabase/functions/import-source/pinned-fetch.ts), on every redirect hop. Citation verification does not fetch at all (`verifyUrl` in [`supabase/functions/_shared/research.ts`](../../supabase/functions/_shared/research.ts)). Closed finding [7] of the 2026-08-19 review. ## Recorded exceptions @@ -24,8 +25,7 @@ These were raised in review, judged valid, and accepted on purpose. Changing the - Live provider keys reach same-repository PR jobs in `live-e2e.yml`. Adam accepted this on PR #68; the mitigation is dedicated low-limit keys ([#68](https://github.com/Programcode/DebateLab/pull/68#discussion_r4090563258)). - The production canary account's credentials reach `prod-canary.yml` through Actions secrets, approved 2026-09-24 and recorded in AGENTS.md ([#68](https://github.com/Programcode/DebateLab/pull/68#discussion_r4090664336)). - Manual canary runs can exhaust the account's daily quota; that shows as a quota error, not a false pass ([#68](https://github.com/Programcode/DebateLab/pull/68#discussion_r4093482054)). -- CodeRabbit rated the shared preview-origin allowlist from PR #95 as a high retained concern: an approved production preview runs unmerged code that can receive demo-session handoffs ([#95 security review](https://github.com/Programcode/DebateLab/pull/95)). [docs/operations.md](../operations.md#per-pr-previews) describes the current setup. This one has no recorded decision yet. -- `import-source` checks every resolved A and AAAA address against the IANA special-purpose ranges before each fetch, but the Edge runtime cannot pin the connection to the checked address. A hostname whose DNS answer changes between the check and the connection can still reach a blocked range. Citation verification avoids the same gap by not fetching at all (`verifyUrl` in [`supabase/functions/_shared/research.ts`](../../supabase/functions/_shared/research.ts)). Closing it needs an egress proxy outside the runtime. Raised as finding [7] of the 2026-08-19 security review; this needs Adam's decision. +- CodeRabbit rated the shared preview-origin allowlist from PR #95 as a high retained concern: an approved production preview runs unmerged code that can receive demo-session handoffs ([#95 security review](https://github.com/Programcode/DebateLab/pull/95)). [docs/operations.md](../operations.md#opt-in-previews) describes the current setup. This one has no recorded decision yet. ## Telemetry and error reports diff --git a/docs/standards/ui.md b/docs/standards/ui.md index 77633e243..d1c9e5a63 100644 --- a/docs/standards/ui.md +++ b/docs/standards/ui.md @@ -6,7 +6,26 @@ Part of the [coding standards](../../CODING_STANDARDS.md). [docs/architecture.md - Every user-facing string, including toasts, errors, and accessible names, goes through `t()`. `src/test/i18n-keys.test.ts` fails when a literal key is missing from the catalogs or a `count` call lacks its plural forms. It cannot see dynamic keys (``t(`nav.${key}`)``) or raw values rendered as text, so check those by hand: an English word in a Hungarian screen is the usual sign ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004217), [#43](https://github.com/Programcode/DebateLab/pull/43#discussion_r4048412531)). - Store a translation key or error kind in state and translate at render time. English strings stored in state stay English after a reload ([#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641290)). -- Use the localized feature name everywhere in a catalog: "Interrogatorio", "Keresztkérdezés" ([#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641268), [#13 review](https://github.com/Programcode/DebateLab/pull/13#issuecomment-5477866033)). +- Use the localized feature name everywhere in a catalog: "Interrogatorio", "Keresztkérdezés" ([#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641268), [#13 review](https://github.com/Programcode/DebateLab/pull/13#issuecomment-5477866033)). The glossary below is the one name per language for each tool, role, and format. +- A count goes into the string as `count`, so i18next picks the plural form: `t('libraryPage.sourceCount', { count })`, never `` `${n} ${t('sources')}` ``. Format numbers and dates with `src/lib/date-format.ts`, which follows the interface language. +- `src/test/i18n-untranslated.test.ts` fails when a Spanish or Hungarian value is identical to English. When that is intended, such as a product or format name, add it to the test's list under a reason. + +### Glossary + +| Concept | English | Spanish | Hungarian | +| --- | --- | --- | --- | +| Crossfire (tool and saved round) | Crossfire | Interrogatorio | Keresztkérdezés | +| Sparring (tool and saved round) | Sparring | Sparring | Vitapárbaj | +| Timer | Timer | Cronómetro | Időmérő | +| Case | Case | Caso | Eset (Vitaeset in titles) | +| Ruthless (difficulty, intensity) | Ruthless | Despiadado | Kíméletlen | +| School format | School 4-6-6-3 | Formato escolar 4-6-6-3 | Iskolai 4-6-6-3 | +| School-format roles (the Timer spells out R1 and R2) | Introductor, R1, R2, Conclusor | Introductor, R1, R2, Conclusor | Bevezető, R1, R2, Záró | +| Sides in a Sparring turn | Pro, Con | a favor, en contra | Pro, Kontra | +| Competition formats | Public Forum, Lincoln-Douglas, Policy, British Parliamentary, World Schools, Asian Parliamentary | same as English | same as English | +| Load (button) / Loading | Load / Loading | Cargar / Cargando | Megnyitás / Betöltés | +| AI | AI | IA | MI | +| DebateLab with a suffix | | | DebateLabba, DebateLabban, DebateLabhoz, DebateLabot (no hyphen, back vowels) | ## Accessible names @@ -30,4 +49,4 @@ When `shouldReduceMotion` is true, render a static element. Passing the flag to - Keep a confirmation dialog open while its request is pending, and close it only on success. Radix closes on click unless you call `preventDefault()` ([#26](https://github.com/Programcode/DebateLab/pull/26#discussion_r3897947151)). - Side effects such as scrolling run on a success signal, not on "loading became false" ([#20](https://github.com/Programcode/DebateLab/pull/20#discussion_r3896378580)). - Open a new tab within the click handler. After an awaited request the user activation has expired and popup blockers drop the tab. Show a fallback link ([#63](https://github.com/Programcode/DebateLab/pull/63#discussion_r4081818952)). -- Close the mobile menu on every navigation inside the persistent layout, including the logo link ([#20](https://github.com/Programcode/DebateLab/pull/20#discussion_r3896378566)). +- On phones (below `md`) the bottom tab bar is the navigation. From `md` up the header nav is the navigation; there is no menu button at any width, so never show two navs at once ([#20](https://github.com/Programcode/DebateLab/pull/20#discussion_r3896378566)). diff --git a/docs/testing.md b/docs/testing.md index 6141d8969..1c28eef6f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -51,6 +51,8 @@ Run one test file while iterating: npx vitest run src/test/security/url-validation.test.ts ``` +The root Vitest run includes website tests, mobile library tests, and shared tests beside their modules in `packages/core/src/`. Core parser, URL-matching and Sparring move tests exercise the same code both clients import. + For a bug, watch the reproduction fail for the expected reason before implementing the fix. Which behavior deserves a test, and which tests not to write, is in [AGENTS.md](../AGENTS.md#what-to-test). The short version: test specs where a regression is costly or invisible (auth, RLS, billing, quotas, migrations, concurrency, parsers, regressions of fixed bugs), and prove everything else by using the app. @@ -86,7 +88,7 @@ OPENROUTER_API_KEY=... deno run --allow-read --allow-write --allow-env=OPENROUTE --allow-net=openrouter.ai scripts/prompt-examples.ts supabase/functions /tmp/after.json ``` -This sends the fourteen prompt examples to one real model (GPT-5.6 Luna by default) and writes the raw outputs for manual review. Point the first argument at a directory holding an older `_shared/prompts.ts` and `ai/prompt-builder.ts` to compare revisions. It runs no web research and is a bounded manual check, not a model matrix or an automated quality gate. +This sends the fourteen prompt examples to one real model (GPT-6 Luna by default) and writes the raw outputs for manual review. Point the first argument at a directory holding an older `_shared/prompts.ts` and `ai/prompt-builder.ts` to compare revisions. It runs no web research and is a bounded manual check, not a model matrix or an automated quality gate. ### Live Jev contract smoke @@ -102,7 +104,7 @@ This sends one tiny typed choice to OpenRouter's Decisions endpoint using the pi npm run build ``` -This proves the Vite production bundle compiles and then walks the emitted Vite manifest to reject feature-only PDF, DOCX, chart, ReactFlow, export, and Admin modules from the static `index.html` dependency graph. It does not prove authentication, database behavior, provider access, or production deployment. +This proves the Vite production bundle compiles, prerenders the public pages, checks the built site's links, fragments, JSON-LD and basic accessibility (including heading order) with `scripts/check-site.mjs`, checks that every sitemap page has at least 3 inbound links and the home page links to at least 15 with `scripts/check-links.mjs`, and then walks the emitted Vite manifest to reject feature-only PDF, DOCX, chart, ReactFlow, export, and Admin modules, the Spanish and Hungarian catalogs, and the signed-in layout from the static `index.html` dependency graph. It does not prove authentication, database behavior, provider access, or production deployment. ## Production service-worker smoke @@ -110,7 +112,7 @@ This proves the Vite production bundle compiles and then walks the emitted Vite npm run test:sw ``` -Run this after `npm run build`. It serves `dist/` through Vite preview and proves that a v2 worker cache upgrades to v3 and claims the page, a first-visited route and its display logo reload offline, an unvisited route shows recovery UI, an intent-prefetched route opens offline, and private API paths do not enter Cache Storage. It uses a stub session and blocks external requests, so it is not authenticated backend or deployed-host proof. +Run this after `npm run build`. It serves `dist/` through Vite preview (which applies the `vercel.json` rewrites, so app routes get `app-shell.html` as in production) and proves that a v2 worker cache upgrades to this build's caches and claims the page; that after one visit to `/app`, Home, Library, History, Settings and Timer open offline as fresh navigations, the timer runs, the offline indicator shows, and switching to Spanish works; that a visited public page answers offline only with itself and an unvisited one gets the offline page; that an unvisited route shows recovery UI and an intent-prefetched one opens; and that private API paths and `/auth/callback` do not enter Cache Storage. It uses a stub session and blocks external requests, so it is not authenticated backend or deployed-host proof. ## Credential-free browser gate @@ -127,10 +129,16 @@ Pull requests run only tests tagged `@smoke` on desktop Chromium (`npm run test: The server is never reused. CI uses retries and two workers. On failure, traces, screenshots, videos, and the HTML report stay in ignored `test-results/` and `playwright-report/` directories. CI may upload those credential-free artifacts for diagnosis. +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. Closing it continues into the tour's Start debate step when that account has not finished the tour. If that chunk fails twice, the app logs once, remembers the welcome, and continues without the tour. 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. +`e2e/free-tools.spec.ts` (tag `@smoke`) opens every `/tools` page signed out and checks its heading, canonical URL and JSON-LD, and that it makes no Supabase request. On a paused Playwright clock it runs the timer past the BP protected minute, through a reload, and runs the two Public Forum prep banks; it also draws a motion set and reloads its permalink, times a pasted speech, lays out a BP flow sheet under print media, and checks that `/embed/debate-timer` is `noindex` and requests nothing from another origin. The format data, timer transitions, custom-format links, motion filters and speech-time arithmetic have unit tests in `src/test/free-tools/`. The dev server cannot prove the response headers: `src/test/vercel-deployment-config.test.ts` checks which paths may be framed. + `src/test/i18n-keys.test.ts` fails when a literal `t()` key in `src/` is missing from `en.json`, or when a call that passes `count` lacks its `_one` and `_other` forms. Dynamic keys such as ``t(`nav.${key}`)`` are out of its reach. `src/test/i18n.test.ts` requires the same keys in all three catalogs. Together they prove every literal key has a Spanish and Hungarian entry, not that the translation is natural. The speech unit test proves that Hungarian selects `recognition.lang = 'hu-HU'`. Credential-free browser automation has no real microphone or speech service, so it does not prove recognition accuracy or hardware support. @@ -164,7 +172,7 @@ It does not start local Supabase, run pgTAP, run WebKit, or use protected stagin Requirements: - Docker -- Supabase CLI 2.115 or newer +- Supabase CLI 2.121.0, pinned in `package.json` (`npm ci` installs it) - exclusive ownership of the local Supabase ports and containers In Superset, run: @@ -176,9 +184,9 @@ In Superset, run: The wrapper acquires `~/.superset/debatelab-db.lock`, starts full local Supabase, runs `npm run test:db`, then holds the same lock while a controlled Mailpit relay, collaboration process, fake model provider, and Vite client run the real-Auth desktop/mobile workspace and full-stack feature journeys. The `Database` workflow runs this same gate on pull requests that touch its paths, and retains its logs and browser traces on failure. It stops every owned process and the stack on exit, including after failure. To run only the database portion under the same lock, use `./.superset/db-gate.sh --db-only`. Outside Superset, run it with: ```bash -supabase db start +npx supabase db start npm run test:db -supabase stop --no-backup +npx supabase stop --no-backup ``` The stack replays migrations and runs pgTAP tests. The browser portion creates isolated local users and deletes them, captures mail only in local Mailpit, restarts the collaboration process, and proves recovery and revocation. `CREATE INDEX CONCURRENTLY` requires the stated CLI floor so it runs outside the migration batch. @@ -196,7 +204,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. @@ -215,7 +223,7 @@ To check one feature by hand, or keep evidence for a change, use the same stack LIVE_PROVIDERS=1 LIVE_OPENROUTER_API_KEY=... LIVE_EXA_API_KEY=... ./.superset/db-gate.sh ``` -Live mode uses the same locked stack but skips pgTAP and the fake-provider specs. It runs `e2e/live/` on desktop Chromium against real OpenRouter, Jev, and Exa. Imported web pages are fetched live too. The fake provider becomes a recording proxy. It forwards every chat call to OpenRouter with the model pinned to `LIVE_MODEL` (default `meta/muse-spark-1.3-contributor`; CI reads the `LIVE_MODEL` repository variable, which is unset), passes Jev decisions through unchanged, and refuses calls past `MAX_UPSTREAM_CALLS` (200). The default is the cheapest model that supports every request parameter the app sends, including `response_format` and reasoning effort. It is test-only and not in the app catalog, so users cannot pick it. Meta is its only provider, so an outage there fails the gate instead of failing over. +Live mode uses the same locked stack but skips pgTAP and the fake-provider specs. It runs `e2e/live/` on desktop Chromium against real OpenRouter, Jev, and Exa. Imported web pages are fetched live too. The fake provider becomes a recording proxy. It forwards every chat call to OpenRouter with the model pinned to `LIVE_MODEL` (default `anthropic/claude-haiku-5.5`; CI reads the `LIVE_MODEL` repository variable, which is unset), passes Jev decisions through unchanged, and refuses calls past `MAX_UPSTREAM_CALLS` (200). Every rewritten call leaves pinned to the Anthropic provider with `allow_fallbacks: false`, so it bills DebateLab's Anthropic key (BYOK in OpenRouter) and never OpenRouter credits. The gate, `verify.sh`, and the proxy refuse a `LIVE_MODEL` outside `anthropic/`, except `as-requested` for the model matrix. The default is also the Basic catalog default, so a live run that selects it cannot be told apart from the pin by the upstream model alone; recipes check the requested model instead. Anthropic is its only provider, so an outage or an exhausted key fails the gate instead of failing over. The specs cover web-researched case generation, verified Factoids citations, Ask with web search, URL import, streamed Ask, Attack, Brainstorm, a judged sparring round, and Crossfire with live Jev signals. Outputs vary, so tests assert structure rather than text: @@ -263,7 +271,7 @@ How it runs: - Always through `verify.sh e2e <run|explore|list|cache>`. The wrapper runs `doctor`, loads the stack environment, turns e2e telemetry off, and takes the testing agent's key from `OPENROUTER_API_KEY`, `LIVE_OPENROUTER_API_KEY`, or `~/.config/debatelab/live-verify.env`. It writes the report (`summary.md`, `report.json`), screenshots, traces, and failure videos to `.verify/artifacts/<run>-e2e-<command>-<pid>/`. The config refuses a non-loopback URL and never starts a dev server, because a plain `npm run dev` reaches production. - Each signed-in test signs in a fresh demo-code user through the Login page and deletes it afterwards. Public-page tests use the `visitorTest` fixture with the same checks. - There are two targets, `desktop` (1280 by 800) and `mobile` (393 by 851). e2e does not emulate touch. The app lays out by viewport width and never reads the user agent, so the viewport is what matters; the Playwright Pixel 5 project still owns touch behavior. -- The testing agent has two models on OpenRouter, separate from the app's provider mode, and a fake-mode stack still needs both. The actor (`agent.act` and explorations) is `inclusionai/ling-3.0-flash-vl` ($0.021 and $0.062 per million input and output tokens on Novita, with DeepInfra as fallback). Recorded flows replay without it, so it runs only on a cache miss and in explorations. A small model is enough to click through these flows: with the cache off it passed the actor-heavy specs for about a fifth of GPT-6 Luna's price, while Gemma 3 12B, cheaper still, failed most of them. It sometimes calls `check` on a switch without `checked: false` and then reports the step done, so an unrecorded step that turns something off can fail; record such steps and commit the entry. OpenRouter withdrew the free Qwen tier the suite used before, which now fails with `This model is unavailable for free`. The judge (`agent.assert`, `waitFor`, `extract`) is `meta/muse-spark-1.3-contributor` ($0.10 and $0.20 per million input and output tokens), the same tier as the app's live pin. It answers every judgment on every run. A full run of both viewports makes about 250 judgments and costs about ten cents. It takes about 45 minutes on one machine with three workers, which is why CI splits it into four jobs. The judge cannot act, because Meta's endpoint accepts only `tool_choice: "auto"`. `E2E_AGENT_MODEL` and `E2E_JUDGE_MODEL` override the two. The judge's tier may keep prompts, so use made-up debate content only. A transient OpenRouter error such as `[Meta] The requested model was not found` is infrastructure: rerun with `--last-failed`. The agent's context explains that fake AI answers are canned free-transport text. +- The testing agent has two models on OpenRouter, separate from the app's provider mode, and a fake-mode stack still needs both. The actor (`agent.act` and explorations) and the judge (`agent.assert`, `waitFor`, `extract`) are `anthropic/claude-haiku-5.5` and `anthropic/claude-sonnet-5.5`, both pinned to the Anthropic provider with `allow_fallbacks: false`, so they bill DebateLab's Anthropic key (BYOK in OpenRouter) rather than OpenRouter credits. Recorded flows replay without the actor, so it runs only on a cache miss and in explorations; the model is not part of the replay key. The judge answers every judgment on every run: a full run of both viewports makes about 250 judgments. It is Sonnet because a Haiku judge failed about one in eight page checks on screens Sonnet passes, mostly by reading content below the fold as cut off. It takes about 45 minutes on one machine with three workers, which is why CI splits it into four jobs. `E2E_AGENT_MODEL` and `E2E_JUDGE_MODEL` override the two, but `e2e.config.ts` refuses anything outside `anthropic/` (`src/test/ai/anthropic-only-guard.test.ts` checks it). Use made-up debate content only. An OpenRouter error naming Anthropic (an outage, or the key out of credit) is infrastructure: rerun with `--last-failed`. The agent's context explains that fake AI answers are canned free-transport text. - Replay. When a later check passes, an `agent.act` flow is recorded in [`agentic/replay/`](../agentic/replay/). Later runs replay it with no model call and verify that its effect happened, until the app changes and the agent takes over and records again. The model is not part of the cache key, so a flow recorded with one actor replays under another. A step that ends on a passing toast or other transient state can hand off to the agent on replay (`end-mismatch` in the report); that costs a model call, not a failure. Commit new and changed entries with the test that made them. Local runs read and write the cache; CI only reads it. Changing an instruction, its params, the test or target name, the agent's `context`, or e2e's minor version re-records. - Without a model key or the stack environment, agent tests skip, so a bare `npx e2e run` proves nothing. `failOnSkippedFailure` fails a run in which a test skipped after a failure. @@ -282,18 +290,18 @@ PROD_SUPABASE_ANON_KEY=... PROD_CANARY_EMAIL=... PROD_CANARY_PASSWORD=... \ npx playwright test --config=playwright.production.config.ts ``` -`e2e/production/canary.spec.ts` checks `https://debate.pi-stack.org` directly: +`e2e/production/canary.spec.ts` checks `https://debateowl.com` directly: - the site loads and signed-out app routes redirect to Login; - every browser-facing Edge Function answers a preflight that allows the site origin (`workspace-invites` is excluded until production enables Workspaces); - `ai` returns 401, not a 5xx, to an unauthenticated call; - the canary account signs in, opens Generate, and Settings shows its Pro plan (a row-level-security read), without spending an AI request; -- the dedicated canary account gets a streamed GPT-5.6 Luna answer from `ai-stream`; -- the canary account generates a three-contention case with GPT-5.6 Luna through `ai`. +- the dedicated canary account gets a streamed Claude Haiku 5.5 answer from `ai-stream`; +- the canary account generates a three-contention case with Claude Haiku 5.5 through `ai`. The last two checks are the only production writes, and they touch only the canary account: its `ai_requests` usage rows and whatever the Generate page saves for it. The dedicated canary account has Pro promo access capped at 10 requests a day. Captures are disabled because they could retain its session. -`.github/workflows/prod-canary.yml` runs after every successful Vercel `Production` deployment, daily at 06:41 UTC, and on demand. Main can deploy more than 10 times a day, so deployment runs set `CANARY_AI_REQUESTS=false` and skip the two AI checks. The signed-in session check still runs on every deployment. Only the daily and manual runs make real AI requests: two per run, plus at most two retries. It reads the password and email from secrets and the public anon key from the `PROD_SUPABASE_ANON_KEY` repository variable. +`.github/workflows/prod-canary.yml` runs after every successful Vercel production deployment of the `debate-navigator` project (GitHub environment `Production – debate-navigator`; the `debatelab-staging` project's production deployments do not start it), daily at 06:41 UTC, and on demand. Main can deploy more than 10 times a day, so deployment runs set `CANARY_AI_REQUESTS=false` and skip the two AI checks. The signed-in session check still runs on every deployment. Only the daily and manual runs make real AI requests: two per run, plus at most two retries. It reads the password and email from secrets and the public anon key from the `PROD_SUPABASE_ANON_KEY` repository variable. The local stack begins at port `54321` and is not parallel-safe across worktrees. The task that starts it must stop it before finishing or abandoning the run. Use `supabase stop --no-backup` unless another active task explicitly owns the same stack. diff --git a/e2e.config.ts b/e2e.config.ts index cdbe7c66b..dd7d16189 100644 --- a/e2e.config.ts +++ b/e2e.config.ts @@ -1,6 +1,7 @@ import type { E2EConfig } from "e2e"; import { web } from "@e2e-dev/web"; import { openrouter } from "@openrouter/ai-sdk-provider"; +import { withAnthropicCacheLimit } from "./agentic/support/anthropic-cache"; // Agentic browser tests (TesterArmy e2e) against the verify-debatelab stack. // Run them through `.claude/skills/verify-debatelab/bin/verify.sh e2e`, which @@ -18,15 +19,27 @@ const live = process.env.VERIFY_MODE === "live"; // The testing agent's models, separate from the app's own. Both need tool // calling and image input. The actor drives agent.act and explorations; its // recorded flows replay with no model call, so it runs only on a cache miss -// and in explorations. Ling 3.0 Flash VL is about a fifth of GPT-6 Luna's -// price and drives these flows fine; Gemma 3 12B, cheaper still, could not. -// OpenRouter withdrew the free Qwen tier used before. -// The judge answers every agent.assert, waitFor and extract on every run, so it -// must be cheap and reliable: the Muse Spark contributor tier, which like the -// app's live pin may keep prompts (made-up content only). +// and in explorations. The judge answers every agent.assert, waitFor and +// extract on every run. +// The actor runs on Claude Haiku and the judge on Claude Sonnet: with Haiku +// judging, about one in eight page checks failed on screens Sonnet passes +// (content below the fold read as "cut off", verdicts that contradict their +// own reasoning). Both are pinned to the Anthropic provider with fallbacks off, +// so they bill DebateLab's Anthropic key (BYOK in OpenRouter), never OpenRouter +// credits. Any other family would bill credits, so a non-Claude override fails +// here instead (src/test/ai/anthropic-only-guard.test.ts checks this file). // `||`, not `??`: an unset Actions variable arrives as an empty string. -const model = openrouter(process.env.E2E_AGENT_MODEL || "inclusionai/ling-3.0-flash-vl"); -const judge = openrouter(process.env.E2E_JUDGE_MODEL || "meta/muse-spark-1.3-contributor"); +const E2E_ACTOR_DEFAULT = "anthropic/claude-haiku-5.5"; +const E2E_JUDGE_DEFAULT = "anthropic/claude-sonnet-5.5"; +function claudeOnly(id: string) { + if (!id.startsWith("anthropic/")) { + throw new Error(`The testing agent runs only on Claude through the Anthropic BYOK key; ${id} would bill OpenRouter credits`); + } + // Anthropic allows four prompt-cache breakpoints; see agentic/support/anthropic-cache.ts. + return withAnthropicCacheLimit(openrouter(id, { provider: { only: ["anthropic"], allow_fallbacks: false } })); +} +const model = claudeOnly(process.env.E2E_AGENT_MODEL || E2E_ACTOR_DEFAULT); +const judge = claudeOnly(process.env.E2E_JUDGE_MODEL || E2E_JUDGE_DEFAULT); // `context` reaches every model call, judges included: what the app calls things. const context = [ @@ -49,8 +62,7 @@ const rules = [ "Never delete the account or all data, sign out, or open billing checkout unless the instruction asks for it.", ]; -// The judge cannot act: Meta's endpoint accepts only tool_choice "auto", and -// the act loop requires a tool call. Explorations act with the actor too. +// The judge only judges; the actor drives agent.act and explorations. const agent = (persona?: string) => ({ model, judge, diff --git a/e2e/admin-announcements.spec.ts b/e2e/admin-announcements.spec.ts index 812cb7ccc..8e5969b20 100644 --- a/e2e/admin-announcements.spec.ts +++ b/e2e/admin-announcements.spec.ts @@ -15,10 +15,9 @@ test.beforeEach(async ({ page }) => { }); async function openAppNav(page: Page, label: string) { - if ((page.viewportSize()?.width ?? 1280) < 1024) { - await page.getByRole('button', { name: 'Open menu' }).click(); + if ((page.viewportSize()?.width ?? 1280) < 768) { await page.getByRole('navigation', { name: 'Mobile navigation' }) - .getByRole('link', { name: new RegExp(`^${label}`) }) + .getByRole('link', { name: label, exact: true }) .click(); return; } @@ -111,9 +110,9 @@ test('keeps announcements stable across routes and refreshes on a full reload', await expect(page.getByRole('heading', { name: 'Scheduled maintenance' })).toHaveCount(0); await openAppNav(page, 'Library'); - await expect(page.getByRole('heading', { name: 'Source Library', exact: true })).toBeVisible(); - await openAppNav(page, 'Home'); - await expect(page.getByRole('main').getByRole('heading', { name: 'DebateLab', exact: true })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Library', exact: true })).toBeVisible(); + await openAppNav(page, 'Tools'); + await expect(page.getByRole('main').getByRole('heading', { name: 'What are you working on?', exact: true })).toBeVisible(); expect(listRequestCount()).toBe(1); expect(listResponseCount).toBe(1); await expect(page.getByRole('heading', { name: 'Scheduled maintenance' })).toHaveCount(0); diff --git a/e2e/animation-polish.spec.ts b/e2e/animation-polish.spec.ts index 41235dcad..96d081623 100644 --- a/e2e/animation-polish.spec.ts +++ b/e2e/animation-polish.spec.ts @@ -2,7 +2,7 @@ import { expect, test, type Page } from '@playwright/test'; import { seedCurrentDebate, stubSupabaseSession } from './helpers/auth'; import { stubAIResponses } from './fixtures/ai-responses'; -const featureConfig = { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', temperature: 0.7 }; +const featureConfig = { provider: 'openrouter', model: 'z-ai/glm-5.3-flash' }; const STATE_TIMEOUT_MS = 15_000; async function prepareMotionFixture(page: Page, reducedMotion: 'no-preference' | 'reduce') { @@ -36,7 +36,7 @@ async function prepareMotionFixture(page: Page, reducedMotion: 'no-preference' | async function openGenerateOptions(page: Page) { await page.goto('/app/generate', { waitUntil: 'domcontentloaded' }); - await expect(page.getByText('Ready to Generate')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await expect(page.getByText('Your case will appear here')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); const options = page.locator('#generate-more-options'); expect(await options.evaluate((element) => (element as HTMLElement).inert)).toBe(true); await page.getByRole('button', { name: 'More options' }).click(); @@ -51,14 +51,14 @@ 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.getByLabel('Motion / topic').fill('This House would make public transport free.'); + await page.getByRole('button', { name: 'Generate debate case' }).click(); await page.mouse.move(0, 0); - await expect(page.getByText('Reading the motion...')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + 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 }); } -/** Tools open from Home, which is reached from the header. */ +/** Tools open from the Tools page, which is reached from the header. */ async function openFeatureRoute(page: Page, route: 'brainstorm' | 'factoids' | 'ask') { await page.locator('header a[href="/app"]').first().click(); await page.locator('main').locator(`a[href="/app/${route}"]`).click(); @@ -70,15 +70,16 @@ test('removes non-essential route and state movement for reduced motion', async const options = await openGenerateOptions(page); await expect(page.locator('[data-route-transition-ms]')).toHaveCount(0); - await expect.poll(() => options.evaluate((element) => getComputedStyle(element).transitionDuration)).toBe('0s'); + // Reduced motion keeps gentle fades but nothing that moves: no transform, height or grid-row transitions. + await expect.poll(() => options.evaluate((element) => getComputedStyle(element).transitionProperty)).not.toMatch(/transform|grid-template-rows|height|all/); await expect.poll(() => page.evaluate(() => document.getAnimations().filter((animation) => animation.playState === 'running').length)).toBe(0); await page.screenshot({ path: testInfo.outputPath('reduced-empty-options.png'), fullPage: true }); await generateMockCase(page); await expect(page.locator('#generate-results')).toBeVisible(); - await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(0); + // Phones scroll the results into view; wide screens show them beside the form. + if ((page.viewportSize()?.width ?? 1280) < 1024) await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(0); await expect.poll(() => page.evaluate(() => document.getAnimations().filter((animation) => animation.playState === 'running').length)).toBe(0); - await expect(page.getByText('Generate complete', { exact: true })).toBeHidden({ timeout: 10_000 }); await page.screenshot({ path: testInfo.outputPath('reduced-result.png'), fullPage: true }); }); @@ -117,37 +118,37 @@ test('audits applicable empty, loading, result, and option states across feature await prepareMotionFixture(page, 'no-preference'); await page.goto('/app/attack', { waitUntil: 'domcontentloaded' }); - await expect(page.getByText('No Mercy Mode')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await expect(page.getByText('Your critique will appear here')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); const attackOptions = page.locator('#attack-options'); await page.getByRole('button', { name: 'More options' }).click(); await expect(attackOptions).toHaveAttribute('data-open', 'true'); await page.getByRole('button', { name: 'More options' }).click(); await expect(attackOptions).toHaveAttribute('data-open', 'false'); expect(await attackOptions.evaluate((element) => (element as HTMLElement).inert)).toBe(true); - await page.getByLabel('Your Argument').fill('Universal transit improves access, reduces congestion, and strengthens civic participation.'); - await page.getByRole('button', { name: 'Find Weaknesses' }).click(); - await expect(page.getByText('Analyzing the case structure...')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); - await expect(page.getByRole('heading', { name: 'Overall Assessment' })).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await page.getByLabel('Your argument').fill('Universal transit improves access, reduces congestion, and strengthens civic participation.'); + await page.getByRole('button', { name: 'Find weaknesses' }).click(); + await expect(page.getByText('Reviewing your case…')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await expect(page.getByRole('heading', { name: 'Overall assessment' })).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await page.screenshot({ path: testInfo.outputPath('attack-result.png'), fullPage: true }); await openFeatureRoute(page, 'brainstorm'); - await expect(page.getByText('Unlock Creative Angles')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); - await page.getByLabel('Motion / Topic').fill('This House would make public transport free.'); - await page.getByRole('button', { name: 'Start Brainstorming' }).click(); - await expect(page.getByText('Exploring angles...')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); - await expect(page.getByRole('heading', { name: 'Top 5 Argument Angles' })).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await expect(page.getByText('Your angles will appear here')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await page.getByLabel('Motion / topic').fill('This House would make public transport free.'); + await page.getByRole('button', { name: 'Start brainstorming' }).click(); + await expect(page.getByText('Finding angles…')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await expect(page.getByRole('heading', { name: 'Top 5 argument angles' })).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await openFeatureRoute(page, 'factoids'); - await expect(page.getByText('Evidence That Wins')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await expect(page.getByText('Your evidence will appear here')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await page.getByLabel('Statement').fill('Free public transport reduces urban congestion.'); - await page.getByRole('button', { name: 'Find Factoids' }).click(); - await expect(page.getByText('Searching databases...')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await page.getByRole('button', { name: 'Find factoids' }).click(); + await expect(page.getByText('Searching for evidence…')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await expect(page.getByRole('heading', { name: 'Factoids' })).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await openFeatureRoute(page, 'ask'); await expect(page.getByText('Start a conversation')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); - await page.getByPlaceholder('Ask your debate coach...').fill('How should I weigh access against cost?'); + await page.getByPlaceholder('Ask your debate coach…').fill('How should I weigh access against cost?'); await page.getByRole('button', { name: 'Send message' }).click(); - await expect(page.getByText('Thinking...')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await expect(page.getByText('Working…')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await expect(page.getByText(/Great question about/)).toBeVisible({ timeout: STATE_TIMEOUT_MS }); }); diff --git a/e2e/authed-pages.spec.ts b/e2e/authed-pages.spec.ts index d4d7f3741..a8d8700c0 100644 --- a/e2e/authed-pages.spec.ts +++ b/e2e/authed-pages.spec.ts @@ -100,7 +100,7 @@ test('visible Ask advanced controls use the non-streaming contract', async ({ pa outputLanguage: 'en', theme: 'light', featureConfigs: { - ask: { provider: 'openai', model: 'gpt-5.4-mini', temperature: 0.7 }, + ask: { provider: 'openai', model: 'gpt-5.4-mini' }, }, }, version: 0, @@ -130,10 +130,12 @@ test('visible Ask advanced controls use the non-streaming contract', async ({ pa await page.goto('/app/ask', { waitUntil: 'domcontentloaded' }); await page.getByRole('button', { name: 'Clarify' }).click(); - for (const name of ['Citations', 'Web search', 'Pros/Cons', 'Arg Map']) { + await page.getByRole('button', { name: 'Options' }).click(); + for (const name of ['Citations', 'Web search', 'Pros/cons', 'Arg map']) { await page.getByRole('switch', { name }).click(); } - await page.getByPlaceholder('Ask your debate coach...').fill('Which assumptions should I define?'); + await page.keyboard.press('Escape'); + await page.getByPlaceholder('Ask your debate coach…').fill('Which assumptions should I define?'); await page.getByRole('button', { name: 'Send message' }).click(); await expect(page.getByText('Clarification contract response')).toBeVisible(); diff --git a/e2e/crossfire.spec.ts b/e2e/crossfire.spec.ts index 072c4adb5..5fdd42899 100644 --- a/e2e/crossfire.spec.ts +++ b/e2e/crossfire.spec.ts @@ -1,5 +1,6 @@ import { expect, test, type Page } from '@playwright/test'; import { seedCurrentDebate, setLocale, stubSupabaseSession } from './helpers/auth'; +import { fulfillQuotaCheck } from './helpers/quota'; const MOTION = 'Resolved: cities should ban private cars downtown.'; const SEEDED_DEBATE_ID = '00000000-0000-4000-8000-00000000d001'; @@ -56,6 +57,7 @@ async function installDeterministicBackend(page: Page, requests: CrossfireReques } if (url.pathname.endsWith('/functions/v1/ai')) { + if (await fulfillQuotaCheck(route, {})) return; const body = route.request().postDataJSON() as CrossfireRequest; requests.push(body); if (body.input.operation === 'signal') { @@ -97,7 +99,7 @@ async function installDeterministicBackend(page: Page, requests: CrossfireReques const output = { concessionsSecured: [{ turnIds: ['T2'], claim: 'The answer accepts that the safety link depends on traffic exposure.' }], - controlledQuestions: [{ turnIds: ['T1', 'T2'], claim: 'The opening exchange keeps the discussion on the central causal warrant.' }], + controlledQuestions: [{ turnIds: ['T1'], claim: 'The opening question keeps the discussion on the central causal warrant.' }], weakOrRepetitiveQuestions: [{ turnIds: ['T1'], claim: 'The question could identify the type of evidence required more precisely.' }], evasiveOrIncompleteAnswers: [{ turnIds: ['T2'], claim: 'The answer does not name a study or traffic count.' }], missedFollowUps: [{ turnIds: ['T2'], claim: 'The next question should test the missing evidence.' }], diff --git a/e2e/first-run.spec.ts b/e2e/first-run.spec.ts index 193eeccf5..de286fba5 100644 --- a/e2e/first-run.spec.ts +++ b/e2e/first-run.spec.ts @@ -8,32 +8,32 @@ const USER_ID = '00000000-0000-0000-0000-000000000001'; const copy = { en: { - welcome: 'Welcome to DebateLab!', + welcome: 'Welcome to DebateOwl', firstDebate: 'Start your first debate here.', startDebate: 'Start debate', motionField: 'Motion', library: 'Library', generate: 'Generate', - ready: "You're Ready!", - finish: 'Get Started!', - generateMotion: 'Motion / Topic *', - generateSubmit: 'Generate Debate Case', + ready: "You're ready", + finish: 'Get started', + generateMotion: 'Motion / topic *', + generateSubmit: 'Generate debate case', offerTitle: 'Start a debate with this motion?', workingIn: 'Working in:', tourMotion: 'This House would ban homework', gateMotion: 'This House would abolish zoos', }, hu: { - welcome: 'Üdvözlünk a DebateLab-ban!', + welcome: 'Üdvözlünk a DebateOwl-ban', firstDebate: 'Itt indíthatod el az első vitádat.', startDebate: 'Vita indítása', motionField: 'Tétel', - library: 'Forráskönyvtár', + library: 'Könyvtár', generate: 'Generálás', - ready: 'Készen Állsz!', - finish: 'Kezdjük!', - generateMotion: 'Indítvány / Téma *', - generateSubmit: 'Vitaeset Generálása', + ready: 'Készen állsz', + finish: 'Kezdjük', + generateMotion: 'Indítvány / téma *', + generateSubmit: 'Vitaeset generálása', offerTitle: 'Indítasz egy vitát ezzel a tétellel?', workingIn: 'Munka ebben:', tourMotion: 'Ez a Ház betiltaná a házi feladatot', diff --git a/e2e/fixtures/ai-responses.ts b/e2e/fixtures/ai-responses.ts index 030ebf660..40f32b9c7 100644 --- a/e2e/fixtures/ai-responses.ts +++ b/e2e/fixtures/ai-responses.ts @@ -5,6 +5,7 @@ * which bypassed both. */ import type { Page } from '@playwright/test'; +import { fulfillQuotaCheck } from '../helpers/quota'; const CORS_HEADERS = { 'access-control-allow-origin': '*', @@ -14,7 +15,7 @@ const CORS_HEADERS = { }; // The client rejects a generate response whose contention count does not match -// the requested length (see expectedContentionCount in src/lib/ai/client.ts). +// the requested length (see expectedContentionCount in src/lib/ai/features/generate.ts). const CONTENTION_COUNT: Record<string, number> = { brief: 2, standard: 3, comprehensive: 4 }; const CONTENTIONS = [ @@ -162,6 +163,7 @@ export async function stubAIResponses(page: Page) { await route.fulfill({ status: 200, headers: CORS_HEADERS, body: 'ok' }); return; } + if (await fulfillQuotaCheck(route, CORS_HEADERS)) return; const body = (route.request().postDataJSON() ?? {}) as AiRequest; const motion = typeof body.input?.motion === 'string' ? body.input.motion : 'the motion'; const message = typeof body.input?.message === 'string' ? body.input.message : 'this'; diff --git a/e2e/free-tools.spec.ts b/e2e/free-tools.spec.ts new file mode 100644 index 000000000..c6baf0d87 --- /dev/null +++ b/e2e/free-tools.spec.ts @@ -0,0 +1,174 @@ +import { test, expect, type Page } from '@playwright/test'; + +const ORIGIN = 'https://debateowl.com'; +const TOOL_PAGES = [ + { path: '/tools', h1: 'Free debate tools', type: 'CollectionPage' }, + { path: '/tools/debate-timer', h1: 'Free debate timer for every format', type: 'WebApplication' }, + { path: '/tools/debate-motion-generator', h1: 'Debate motion generator', type: 'WebApplication' }, + { path: '/tools/debate-flow-sheet', h1: 'Printable debate flow sheet', type: 'WebApplication' }, + { path: '/tools/speech-time-calculator', h1: 'How long is my speech? Speech time calculator', type: 'WebApplication' }, +] as const; + +/** Every request the page makes to a Supabase host or API path. The free tools need none. */ +function watchSupabase(page: Page): string[] { + const calls: string[] = []; + page.on('request', (request) => { + const url = new URL(request.url()); + if (url.hostname.endsWith('supabase.co') || /^\/(auth|rest|functions|storage)\/v1\//.test(url.pathname)) calls.push(request.url()); + }); + return calls; +} + +const T0 = new Date('2026-10-22T10:00:00Z').getTime(); + +/** Opens a page on a fake clock that only moves when the test runs it forward. */ +async function openPaused(page: Page, path: string) { + await page.clock.install({ time: T0 }); + await page.goto(path); + await page.clock.pauseAt(T0 + 5_000); +} + +function watchErrors(page: Page): string[] { + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error' && !/Failed to load resource|net::ERR_/.test(message.text())) errors.push(message.text()); + }); + return errors; +} + +test.describe('free debate tools', { tag: '@smoke' }, () => { + for (const tool of TOOL_PAGES) { + test(`${tool.path} renders signed out with its head and structured data`, async ({ page }) => { + const supabase = watchSupabase(page); + const errors = watchErrors(page); + const response = await page.goto(tool.path); + expect(response?.ok()).toBeTruthy(); + await expect(page.getByRole('heading', { level: 1, name: tool.h1 })).toBeVisible(); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', `${ORIGIN}${tool.path}`); + await expect(page).toHaveTitle(/DebateOwl/); + const graphs = await page.locator('script[type="application/ld+json"]').allTextContents(); + const types = graphs.flatMap((json) => (JSON.parse(json)['@graph'] as { '@type': string }[]).map((node) => node['@type'])); + expect(types).toContain(tool.type); + expect(types).toContain('BreadcrumbList'); + expect(JSON.stringify(graphs)).not.toMatch(/aggregateRating|"review"/); + expect(supabase, supabase.join('\n')).toEqual([]); + expect(errors, errors.join('\n')).toEqual([]); + }); + } + + test('the timer starts, pauses and opens points of information after the first minute', async ({ page }) => { + await openPaused(page, '/tools/debate-timer?format=bp'); + const clock = page.getByRole('timer', { name: 'Speech time remaining' }); + await expect(clock).toHaveText('7:00'); + await expect(page.getByText('Protected time', { exact: true })).toBeVisible(); + + await page.getByRole('button', { name: 'Start' }).click(); + await page.clock.runFor(61_000); + await expect(clock).toHaveText('5:59'); + await expect(page.getByText('POIs open', { exact: true })).toBeVisible(); + + await page.getByRole('button', { name: 'Pause' }).click(); + await page.clock.runFor(5_000); + await expect(clock).toHaveText('5:59'); + + // The round survives a reload. + await page.reload(); + await expect(page.getByRole('timer', { name: 'Speech time remaining' })).toHaveText('5:59'); + + // Keys act once the timer has focus. + await page.getByRole('timer', { name: 'Speech time remaining' }).click(); + await page.keyboard.press('ArrowRight'); + await expect(page.getByRole('heading', { level: 2, name: 'Leader of the Opposition (Opening Opposition)' })).toBeVisible(); + }); + + test('a custom format applies, lands in the share link, and survives a reload', async ({ page }) => { + await page.goto('/tools/debate-timer?format=wsdc'); + await page.getByRole('button', { name: 'Build a custom format' }).click(); + const editor = page.getByRole('form', { name: 'Custom format' }); + await editor.getByLabel('Format name').fill('Club round'); + // Whole and fractional minutes both submit; the browser's step check once blocked every value. + await editor.getByLabel('Minutes', { exact: true }).first().fill('5'); + await editor.getByLabel('Minutes', { exact: true }).nth(1).fill('2.5'); + await editor.getByRole('button', { name: 'Use this format' }).click(); + await expect(editor).toHaveCount(0); + await expect(page).toHaveURL(/format=custom&custom=[A-Za-z0-9_-]+/); + await expect(page.getByRole('timer', { name: 'Speech time remaining' })).toHaveText('5:00'); + await page.reload(); + await expect(page.getByRole('region', { name: /round timer/ }).getByLabel('Format')).toHaveValue('custom'); + await expect(page.getByRole('navigation', { name: 'Speeches' })).toContainText('2:30'); + }); + + test('prep banks run down one side at a time', async ({ page }) => { + await openPaused(page, '/tools/debate-timer?format=pf'); + const teamA = page.getByRole('button', { name: /Team A prep/ }); + const teamB = page.getByRole('button', { name: /Team B prep/ }); + await teamA.click(); + await page.clock.runFor(30_000); + await teamB.click(); + await page.clock.runFor(10_000); + await expect(teamA).toContainText('2:30'); + await expect(teamB).toContainText('2:50'); + await expect(teamB).toHaveAttribute('aria-pressed', 'true'); + await expect(teamA).toHaveAttribute('aria-pressed', 'false'); + }); + + test('the embed renders the format alone, makes no third-party request, and stays out of the index', async ({ page, baseURL }) => { + const external: string[] = []; + page.on('request', (request) => { + if (new URL(request.url()).origin !== new URL(baseURL!).origin) external.push(request.url()); + }); + await page.goto('/embed/debate-timer?format=wsdc'); + await expect(page.getByRole('heading', { level: 2, name: 'First Proposition' })).toBeVisible(); + await expect(page.locator('meta[name="robots"]')).toHaveAttribute('content', 'noindex'); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', `${ORIGIN}/tools/debate-timer`); + const attribution = page.getByRole('link', { name: 'Timer by DebateOwl' }); + await expect(attribution).toHaveAttribute('href', `${ORIGIN}/tools/debate-timer`); + await expect(attribution).not.toHaveAttribute('rel', /nofollow/); + await expect(page.getByLabel('Format')).toHaveCount(0); + await page.getByRole('button', { name: 'Start' }).click(); + await expect(page.getByRole('button', { name: 'Pause' })).toBeVisible(); + // Running for a few seconds gives any stray request time to show up. + await expect(page.getByRole('timer', { name: 'Speech time remaining' })).toHaveText('7:57', { timeout: 10_000 }); + expect(external, external.join('\n')).toEqual([]); + }); + + test('a drawn set of motions keeps its permalink', async ({ page }) => { + await page.goto('/tools/debate-motion-generator'); + await page.getByRole('button', { name: 'Education', exact: true }).click(); + await page.getByRole('button', { name: 'Draw three' }).click(); + const options = page.getByRole('region', { name: 'Motion generator' }).locator('ol > li'); + await expect(options).toHaveCount(3); + const drawn = await options.locator('p').first().textContent(); + await expect(page).toHaveURL(/[?&]m=edu-\d{3}%2Cedu-\d{3}%2Cedu-\d{3}/); + await page.reload(); + await expect(page.getByRole('region', { name: 'Motion generator' }).locator('ol > li').first().locator('p').first()).toHaveText(drawn!); + // The whole bank is on the page for readers and crawlers. + await expect(page.locator('#edu-001')).toBeAttached(); + }); + + test('the calculator times a pasted speech and fits it to speech slots', async ({ page }) => { + await page.goto('/tools/speech-time-calculator'); + await page.getByLabel('Your speech').fill(Array.from({ length: 260 }, (_, i) => `word${i}`).join(' ')); + await expect(page.getByTestId('word-count')).toHaveText('260'); + // 260 words at 130 wpm is two minutes: it fits a 2:00 final focus exactly. + await expect(page.getByTestId('slot-fits').locator('li', { hasText: 'PF 2:00' })).toContainText('0:00 spare'); + }); + + test('the flow sheet has a column per speech and prints', async ({ page }) => { + await page.goto('/tools/debate-flow-sheet'); + await page.getByLabel('Format').selectOption('bp'); + const sheet = page.getByTestId('flow-sheet'); + for (const speech of ['PM', 'LO', 'DPM', 'DLO', 'MG', 'MO', 'GW', 'OW']) await expect(sheet.getByText(speech, { exact: true })).toBeVisible(); + await page.emulateMedia({ media: 'print' }); + await expect(page.getByRole('button', { name: /Print/ })).toBeHidden(); + await expect(sheet).toBeVisible(); + }); + + test('the landing footer links to the tools hub', async ({ page }) => { + await page.goto('/'); + await page.getByRole('contentinfo').getByRole('link', { name: 'Free debate tools' }).click(); + await expect(page).toHaveURL(/\/tools$/); + await expect(page.getByRole('heading', { level: 1, name: 'Free debate tools' })).toBeVisible(); + }); +}); diff --git a/e2e/full-stack/account-data.spec.ts b/e2e/full-stack/account-data.spec.ts index 1d14955fe..bb622a7a4 100644 --- a/e2e/full-stack/account-data.spec.ts +++ b/e2e/full-stack/account-data.spec.ts @@ -1,12 +1,12 @@ -import { admin, createDemoUser, deleteDemoUser, expect, MARK, providerCall, signIn, stackReady, startDebate, test } from "./stack"; +import { admin, createDemoUser, deleteDemoUser, expect, expectResult, MARK, providerCall, signIn, stackReady, startDebate, test } from "./stack"; 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("textbox", { name: "Source title..." }).fill(title); - await page.getByRole("textbox", { name: "Paste source content..." }).fill(content); + await page.getByRole("button", { name: "Add source" }).click(); + await page.getByRole("textbox", { name: "Title", exact: true }).fill(title); + await page.getByRole("textbox", { name: "Content", exact: true }).fill(content); await page.getByRole("button", { name: "Save to Library" }).click(); await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked(); } @@ -20,9 +20,9 @@ test("only active Library sources ground AI requests, and deletions persist", as await expect(page.getByRole("switch", { name: "Toggle Paused study" })).not.toBeChecked(); await page.goto("/app/brainstorm"); - await page.getByLabel("Motion / Topic *").fill("Public transport should be free"); - await page.getByRole("button", { name: "Start Brainstorming" }).click(); - await expect(page.getByText(`${MARK} idea 1`)).toBeVisible({ timeout: 30_000 }); + await page.getByLabel("Motion / topic *").fill("Public transport should be free"); + await page.getByRole("button", { name: "Start brainstorming" }).click(); + await expectResult(page, `${MARK} idea 1`); const call = await providerCall("brainstorm"); expect(call.system).toContain("ACTIVE-SOURCE-MARKER"); expect(call.system).not.toContain("PAUSED-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,11 +57,11 @@ 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 expect(page.getByText("Data Deleted").first()).toBeVisible(); + 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); expect(mine).toEqual([]); @@ -79,7 +79,7 @@ test("Delete All My Data erases the account's saved work and nobody else's", asy test("the output language setting follows the account and reaches the model", async ({ signedIn: page, user, browser }) => { await page.goto("/app/settings"); - await page.getByRole("combobox", { name: "AI Output Language" }).click(); + await page.getByRole("combobox", { name: "AI output language" }).click(); await page.getByRole("option", { name: "Magyar" }).click(); await expect.poll(async () => { const { data } = await admin.from("user_settings").select("output_language").eq("user_id", await user.userId()).single(); @@ -93,8 +93,8 @@ test("the output language setting follows the account and reaches the model", as await other.addInitScript(() => localStorage.setItem("debate-trainer-onboarding", JSON.stringify({ state: { hasCompletedOnboarding: true, isTourRunning: false }, version: 0 }))); await signIn(other, user); await other.goto("/app/attack"); - await other.getByLabel("Your Argument *").fill("Free transit cuts congestion."); - await other.getByRole("button", { name: "Find Weaknesses" }).click(); + await other.getByLabel("Your argument *").fill("Free transit cuts congestion."); + await other.getByRole("button", { name: "Find weaknesses" }).click(); await expect(other.getByText(`${MARK} weakness`)).toBeVisible({ timeout: 30_000 }); expect((await providerCall("attack")).user).toContain("Language: Hungarian (hu)"); } finally { @@ -108,8 +108,8 @@ test("a saved flowchart is stored on the account", async ({ signedIn: page, user await page.getByRole("button", { name: "Save", exact: true }).click(); const dialog = page.getByRole("dialog"); await dialog.getByRole("textbox").fill(title); - await dialog.getByRole("button", { name: "Save Flowchart" }).click(); - await expect(page.getByText("Flowchart saved successfully")).toBeVisible(); + await dialog.getByRole("button", { name: "Save flowchart" }).click(); + await expect(page.getByText("Flowchart saved")).toBeVisible(); const { data } = await admin.from("flowcharts").select("title").eq("user_id", await user.userId()); expect(data?.map((row) => row.title)).toEqual([title]); diff --git a/e2e/full-stack/account-deletion.spec.ts b/e2e/full-stack/account-deletion.spec.ts index 5a963d67f..9e83ed9ba 100644 --- a/e2e/full-stack/account-deletion.spec.ts +++ b/e2e/full-stack/account-deletion.spec.ts @@ -9,9 +9,9 @@ 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("textbox", { name: "Source title..." }).fill(title); - await page.getByRole("textbox", { name: "Paste source content..." }).fill(content); + await page.getByRole("button", { name: "Add source" }).click(); + await page.getByRole("textbox", { name: "Title", exact: true }).fill(title); + await page.getByRole("textbox", { name: "Content", exact: true }).fill(content); await page.getByRole("button", { name: "Save to Library" }).click(); await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked(); } diff --git a/e2e/full-stack/admin.spec.ts b/e2e/full-stack/admin.spec.ts index e7bc856ab..94a5cdd27 100644 --- a/e2e/full-stack/admin.spec.ts +++ b/e2e/full-stack/admin.spec.ts @@ -37,3 +37,39 @@ test("a regular user cannot open the admin dashboard", async ({ signedIn: page } await expect(page.getByText("Access Denied")).toBeVisible(); await expect(page.getByRole("heading", { name: "Admin Dashboard" })).toHaveCount(0); }); + +/** The signed-in browser session's access token, for calling the function directly. */ +async function accessToken(page: import("@playwright/test").Page) { + return page.evaluate(() => { + const key = Object.keys(localStorage).find((candidate) => candidate.startsWith("debate-auth-")); + return key ? (JSON.parse(localStorage.getItem(key)!) as { access_token: string }).access_token : ""; + }); +} + +// Production's rate_limits had no updated_at, so Set limit failed there (DB-01); +// scripts/test-admin-table-drift-migration.sh replays that shape. This drives the real function. +test("an admin sets a per-user and a global rate limit through the admin function", async ({ signedIn: page, user }) => { + const limitType = `fullstack_daily_${Date.now()}`; + const userId = await user.userId(); + await admin.from("user_roles").update({ role: "admin" }).eq("user_id", userId); + await page.goto("/app/admin"); + await expect(page.getByRole("heading", { name: "Admin Dashboard" })).toBeVisible(); + const token = await accessToken(page); + const setLimit = (body: Record<string, unknown>) => fetch(`${process.env.LOCAL_SUPABASE_URL}/functions/v1/admin?action=set-limit`, { + method: "POST", + headers: { "content-type": "application/json", apikey: process.env.SUPABASE_ANON_KEY ?? "", authorization: `Bearer ${token}` }, + body: JSON.stringify(body), + }); + try { + for (const value of [5, 9]) { + expect((await setLimit({ userId, limitType, limitValue: value })).status).toBe(200); + expect((await setLimit({ limitType, limitValue: value * 10 })).status).toBe(200); + } + // Each second call updated the row the first one wrote; none was added. + const { data: rows } = await admin.from("rate_limits").select("user_id, limit_value, created_at, updated_at").eq("limit_type", limitType); + expect(rows?.map((row) => [row.user_id, row.limit_value]).sort()).toEqual([[userId, 9], [null, 90]].sort()); + expect(rows?.every((row) => row.updated_at >= row.created_at)).toBe(true); + } finally { + await admin.from("rate_limits").delete().eq("limit_type", limitType); + } +}); diff --git a/e2e/full-stack/ai-tools.spec.ts b/e2e/full-stack/ai-tools.spec.ts index 49416c04d..ca3506b24 100644 --- a/e2e/full-stack/ai-tools.spec.ts +++ b/e2e/full-stack/ai-tools.spec.ts @@ -1,40 +1,40 @@ -import { admin, chooseForMotion, expect, MARK, fake, providerCall, stackReady, test } from "./stack"; +import { admin, chooseForMotion, expect, expectResult, fake, MARK, providerCall, stackReady, test } from "./stack"; 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.getByLabel("Your argument *").fill("Free transit cuts congestion because riders leave their cars at home."); + await page.getByRole("button", { name: "Find weaknesses" }).click(); - await expect(page.getByText(`${MARK} weakness`)).toBeVisible({ timeout: 30_000 }); + await expectResult(page, `${MARK} weakness`); expect((await providerCall("attack")).user).toContain("riders leave their cars at home"); }); 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.getByLabel("Motion / topic *").fill("Universal basic income should replace targeted welfare"); + await page.getByRole("button", { name: "Start brainstorming" }).click(); await chooseForMotion(page, "create"); - await expect(page.getByText(`${MARK} idea 1`)).toBeVisible({ timeout: 30_000 }); + await expectResult(page, `${MARK} idea 1`); expect((await providerCall("brainstorm")).user).toContain("Motion: Universal basic income should replace targeted welfare"); }); test("Factoids completes research without verified web sources", async ({ signedIn: page }) => { await page.goto("/app/factoids"); await page.getByLabel("Statement *").fill("Fare-free transit raises ridership"); - await page.getByRole("button", { name: "Find Factoids" }).click(); + await page.getByRole("button", { name: "Find factoids" }).click(); // The local stack has no search key, so the server must drop every unverified // factoid and still finish instead of inventing citations. - await expect(page.getByText(`${MARK} search suggestion`)).toBeVisible({ timeout: 30_000 }); + await expectResult(page, `${MARK} search suggestion`); expect((await providerCall("factoids")).user).toContain('"Fare-free transit raises ridership"'); }); test("Ask streams a coaching answer and records usage", async ({ signedIn: page, user }) => { await page.goto("/app/ask"); - await page.getByPlaceholder("Ask your debate coach...").fill("How do I weigh access against cost?"); + await page.getByPlaceholder("Ask your debate coach…").fill("How do I weigh access against cost?"); await page.getByRole("button", { name: "Send message" }).click(); await expect(page.getByText(`${MARK} streamed answer: weigh access against cost.`)).toBeVisible({ timeout: 30_000 }); @@ -53,8 +53,11 @@ test("Ask streams a coaching answer and records usage", async ({ signedIn: page, test("Ask with citations uses the structured endpoint", async ({ signedIn: page }) => { await page.goto("/app/ask"); + // The Ask switches live in the Options popover above the message box. + await page.getByRole("button", { name: "Options" }).click(); await page.locator("#citation-mode").click(); - await page.getByPlaceholder("Ask your debate coach...").fill("Which evidence supports fare-free transit?"); + await page.keyboard.press("Escape"); + await page.getByPlaceholder("Ask your debate coach…").fill("Which evidence supports fare-free transit?"); await page.getByRole("button", { name: "Send message" }).click(); await expect(page.getByText(`${MARK} answer`)).toBeVisible({ timeout: 30_000 }); diff --git a/e2e/full-stack/auth.spec.ts b/e2e/full-stack/auth.spec.ts index 5f89e1bfd..969559eca 100644 --- a/e2e/full-stack/auth.spec.ts +++ b/e2e/full-stack/auth.spec.ts @@ -1,4 +1,4 @@ -import { admin, chooseForMotion, expect, MARK, stackReady, test } from "./stack"; +import { admin, chooseForMotion, expect, expectResult, MARK, stackReady, test } from "./stack"; test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)"); @@ -38,8 +38,8 @@ test("an expired access token refreshes before an AI request", async ({ signedIn localStorage.setItem(key, JSON.stringify(session)); }); await page.goto("/app/generate"); - await page.getByRole("textbox", { name: "Motion / Topic *" }).fill("This House would make public transport free"); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await page.getByRole("textbox", { name: "Motion / topic *" }).fill("This House would make public transport free"); + await page.getByRole("button", { name: "Generate debate case" }).click(); await chooseForMotion(page, "once"); - await expect(page.getByText(`${MARK} case thesis`)).toBeVisible({ timeout: 30_000 }); + await expectResult(page, `${MARK} case thesis`); }); diff --git a/e2e/full-stack/billing.spec.ts b/e2e/full-stack/billing.spec.ts index dd41cce36..8f5ce440f 100644 --- a/e2e/full-stack/billing.spec.ts +++ b/e2e/full-stack/billing.spec.ts @@ -1,6 +1,6 @@ import { createHmac, randomUUID } from "node:crypto"; import type { Page } from "@playwright/test"; -import { admin, chooseForMotion, expect, fake, MARK, providerCall, stackReady, test } from "./stack"; +import { admin, chooseForMotion, expect, expectResult, fake, MARK, providerCall, stackReady, test } from "./stack"; test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)"); @@ -22,8 +22,8 @@ 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.getByLabel("Motion / topic *").fill("Public transport should be free"); + await page.getByRole("button", { name: "Start brainstorming" }).click(); await chooseForMotion(page, "once"); } @@ -38,25 +38,27 @@ function sendWebhook(event: object, signature = true) { }); } -function subscriptionEvent(type: string, subscriptionId: string, status: string) { +function subscriptionObject(subscriptionId: string, status: string) { const now = Math.floor(Date.now() / 1000); + return { + id: subscriptionId, + object: "subscription", + status, + cancel_at_period_end: false, + current_period_start: now, + current_period_end: now + 30 * 86_400, + cancel_at: null, + canceled_at: null, + ended_at: null, + }; +} + +function subscriptionEvent(type: string, subscriptionId: string, status: string) { return { id: `evt_${randomUUID()}`, object: "event", type, - data: { - object: { - id: subscriptionId, - object: "subscription", - status, - cancel_at_period_end: false, - current_period_start: now, - current_period_end: now + 30 * 86_400, - cancel_at: null, - canceled_at: null, - ended_at: null, - }, - }, + data: { object: subscriptionObject(subscriptionId, status) }, }; } @@ -72,7 +74,7 @@ test("a promo code redeemed in Settings restores AI access", async ({ signedIn: try { await brainstorm(page); - await expect(page.getByRole("dialog").getByText("Free Trial Ended")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole("dialog").getByText("Your free trial has ended")).toBeVisible({ timeout: 30_000 }); expect((await fake.requests()).filter((request) => request.feature === "brainstorm")).toEqual([]); await page.goto("/app/settings"); @@ -81,13 +83,13 @@ test("a promo code redeemed in Settings restores AI access", async ({ signedIn: await page.getByRole("button", { name: "Have a promo code?" }).click(); await page.getByPlaceholder("Enter promo code").fill(code.toLowerCase()); await page.getByRole("button", { name: "Apply", exact: true }).click(); - await expect(page.getByText("Promo code applied!").first()).toBeVisible(); + await expect(page.getByText("Promo code applied").first()).toBeVisible(); await expect(page.getByRole("dialog")).toBeHidden(); await expect(page.getByText("Pro", { exact: true })).toBeVisible(); expect(await tier(userId)).toBe("pro"); await brainstorm(page); - await expect(page.getByText(`${MARK} idea 1`)).toBeVisible({ timeout: 30_000 }); + await expectResult(page, `${MARK} idea 1`); await providerCall("brainstorm"); } finally { await admin.from("promo_codes").delete().eq("id", promo.id); @@ -114,6 +116,8 @@ test("signed Stripe webhooks grant and revoke Pro, and forged ones change nothin expect(forged.status).toBe(400); expect(await tier(userId)).toBe("expired"); + // The webhook applies Stripe's current state, which the fake Stripe holds. + await fake.stripeSubscription(subscriptionObject(subscriptionId, "active")); const accepted = await sendWebhook(activated); expect(accepted.status).toBe(200); expect(await tier(userId)).toBe("pro"); @@ -122,14 +126,40 @@ 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 + // new payment method instead of selling a second subscription (QB-13). + await fake.stripeSubscription(subscriptionObject(subscriptionId, "past_due")); + const failed = await sendWebhook({ + id: `evt_${randomUUID()}`, + object: "event", + type: "invoice.payment_failed", + data: { object: { id: `in_e2e_${randomUUID()}`, object: "invoice", subscription: subscriptionId } }, + }); + expect(failed.status).toBe(200); + expect(await tier(userId)).toBe("pro"); + 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: /Upgrade to Pro/ })).toHaveCount(0); + + await fake.stripeSubscription(subscriptionObject(subscriptionId, "canceled")); const canceled = await sendWebhook(subscriptionEvent("customer.subscription.deleted", subscriptionId, "canceled")); expect(canceled.status).toBe(200); expect(await tier(userId)).toBe("expired"); + // An older "active" update that Stripe delivers late must not revive the plan. + const stale = await sendWebhook(subscriptionEvent("customer.subscription.updated", subscriptionId, "active")); + expect(stale.status).toBe(200); + expect(await tier(userId)).toBe("expired"); const { data: row } = await admin.from("subscriptions").select("status").eq("stripe_subscription_id", subscriptionId).single(); expect(row?.status).toBe("canceled"); await page.reload(); await expect(page.getByText("Expired", { exact: true })).toBeVisible(); + // A former subscriber is told the subscription ended, not that a free trial did (QB-14). + await expect(page.getByText(/Your Pro subscription ended on/)).toBeVisible(); + await expect(page.getByRole("button", { name: "Resubscribe" })).toBeVisible(); }); diff --git a/e2e/full-stack/case-generation.spec.ts b/e2e/full-stack/case-generation.spec.ts index bc7397bdf..d04c4abe5 100644 --- a/e2e/full-stack/case-generation.spec.ts +++ b/e2e/full-stack/case-generation.spec.ts @@ -1,5 +1,5 @@ import { readFile } from "node:fs/promises"; -import { admin, chooseForMotion, expect, fake, MARK, providerCall, signIn, stackReady, startDebate, test } from "./stack"; +import { admin, chooseForMotion, expect, expectResult, fake, MARK, providerCall, signIn, stackReady, startDebate, test } from "./stack"; test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)"); @@ -9,25 +9,25 @@ 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("textbox", { name: "Source title..." }).fill("Tallinn fare study"); + 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. - await page.getByRole("textbox", { name: "Paste source content..." }) + await page.getByRole("textbox", { name: "Content", exact: true }) .fill(`${marker} Tallinn ridership rose 14 percent after fares were removed. ${"Detailed ridership evidence. ".repeat(9_000)}`); await page.getByRole("button", { name: "Save to Library" }).click(); - await expect(page.getByText("Grounding Active")).toBeVisible(); + await expect(page.getByText("Grounding active")).toBeVisible(); const userId = await user.userId(); const { data: sources } = await admin.from("sources").select("id,is_active").eq("user_id", userId); expect(sources).toEqual([expect.objectContaining({ is_active: true })]); await page.goto("/app/generate"); - await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await page.getByRole("textbox", { name: "Motion / topic *" }).fill(motion); + await page.getByRole("button", { name: "Generate debate case" }).click(); // A hang (as in the auth relock regression) fails here instead of spinning forever. - await expect(page.getByText(`${MARK} case thesis`)).toBeVisible({ timeout: 30_000 }); + await expectResult(page, `${MARK} case thesis`); await expect(page.getByText(`${MARK} contention 3`)).toBeVisible(); const call = await providerCall("generate"); @@ -43,33 +43,33 @@ test("a provider failure shows an error and lets the user retry", async ({ signe await fake.fail("generate", 500); await startDebate(page, motion); await page.goto("/app/generate"); - await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await page.getByRole("textbox", { name: "Motion / topic *" }).fill(motion); + await page.getByRole("button", { name: "Generate debate case" }).click(); - await expect(page.getByText("Generation Failed", { exact: true })).toBeVisible({ timeout: 30_000 }); - await expect(page.getByRole("button", { name: "Generate Debate Case" })).toBeEnabled(); + await expect(page.getByText("Generation failed", { exact: true })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole("button", { name: "Generate debate case" })).toBeEnabled(); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); - await expect(page.getByText(`${MARK} case thesis`)).toBeVisible({ timeout: 30_000 }); + await page.getByRole("button", { name: "Generate debate case" }).click(); + await expectResult(page, `${MARK} case thesis`); }); test("an account without quota is told why generation is blocked", async ({ signedIn: page, user }) => { await admin.from("user_settings").update({ daily_limit_override: 0 }).eq("user_id", await user.userId()); await startDebate(page, motion); await page.goto("/app/generate"); - await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await page.getByRole("textbox", { name: "Motion / topic *" }).fill(motion); + await page.getByRole("button", { name: "Generate debate case" }).click(); - await expect(page.getByRole("dialog").getByText("Daily Access Paused")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole("dialog").getByText("Daily AI uses used up")).toBeVisible({ timeout: 30_000 }); expect((await fake.requests()).filter((request) => request.feature === "generate")).toEqual([]); }); test("a saved one-off case persists to the account and reloads from History", async ({ signedIn: page, user }) => { await page.goto("/app/generate"); - await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await page.getByRole("textbox", { name: "Motion / topic *" }).fill(motion); + await page.getByRole("button", { name: "Generate debate case" }).click(); await chooseForMotion(page, "once"); - await expect(page.getByText(`${MARK} case thesis`)).toBeVisible({ timeout: 30_000 }); + await expectResult(page, `${MARK} case thesis`); const title = `Saved case ${Date.now()}`; await page.getByRole("button", { name: "Save", exact: true }).click(); @@ -93,9 +93,9 @@ test("a saved one-off case persists to the account and reloads from History", as test("a generated case exports to PDF and DOCX files", async ({ signedIn: page }) => { await startDebate(page, motion); await page.goto("/app/generate"); - await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); - await expect(page.getByText(`${MARK} case thesis`)).toBeVisible({ timeout: 30_000 }); + await page.getByRole("textbox", { name: "Motion / topic *" }).fill(motion); + 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". for (const [button, extension, magic] of [["PDF", ".pdf", "%PDF"], ["DOCX", ".docx", "PK"]] as const) { diff --git a/e2e/full-stack/current-debate.spec.ts b/e2e/full-stack/current-debate.spec.ts index 6ca93a13f..a3defe3b2 100644 --- a/e2e/full-stack/current-debate.spec.ts +++ b/e2e/full-stack/current-debate.spec.ts @@ -1,5 +1,5 @@ import type { Page } from "@playwright/test"; -import { admin, chooseForMotion, debateControl, expect, fake, MARK, stackReady, startDebate, test } from "./stack"; +import { admin, chooseForMotion, debateControl, expect, expectResult, fake, MARK, stackReady, startDebate, test } from "./stack"; test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)"); @@ -17,9 +17,9 @@ 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("textbox", { name: "Source title..." }).fill(title); - await page.getByRole("textbox", { name: "Paste source content..." }).fill(content); + await page.getByRole("button", { name: "Add source" }).click(); + await page.getByRole("textbox", { name: "Title", exact: true }).fill(title); + await page.getByRole("textbox", { name: "Content", exact: true }).fill(content); await page.getByRole("button", { name: "Save to Library" }).click(); await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked(); } @@ -52,9 +52,9 @@ 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 expect(page.getByText(`${MARK} idea 1`)).toBeVisible({ timeout: 30_000 }); + await expect(page.getByLabel("Motion / topic *")).toHaveValue(HOMEWORK); + await page.getByRole("button", { name: "Start brainstorming" }).click(); + await expectResult(page, `${MARK} idea 1`); const homeworkCall = await latestCall("brainstorm"); expect(homeworkCall.user).toContain(`Motion: ${HOMEWORK}`); expect(homeworkCall.system).toContain("HOMEWORK-SOURCE"); @@ -69,9 +69,9 @@ test("two debates keep their motions, sources, and results apart", async ({ sign await snap(page, "2-library-in-zoo-debate"); await page.goto("/app/generate"); - await expect(page.getByRole("textbox", { name: "Motion / Topic *" })).toHaveValue(ZOOS); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); - await expect(page.getByText(`${MARK} case thesis`)).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole("textbox", { name: "Motion / topic *" })).toHaveValue(ZOOS); + await page.getByRole("button", { name: "Generate debate case" }).click(); + await expectResult(page, `${MARK} case thesis`); const zooCall = await latestCall("generate"); expect(zooCall.user).toContain(`Motion: ${ZOOS}`); expect(zooCall.system).toContain("ZOO-SOURCE"); @@ -79,12 +79,12 @@ 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.getByLabel("Motion / topic *").fill("This House would lower the voting age"); + await page.getByRole("button", { name: "Start brainstorming" }).click(); await expect(page.getByRole("dialog")).toContainText("This is a different motion"); await snap(page, "3-different-motion-choice"); await chooseForMotion(page, "once"); - await expect(page.getByText(`${MARK} idea 1`)).toBeVisible({ timeout: 30_000 }); + await expectResult(page, `${MARK} idea 1`); const oneOffCall = await latestCall("brainstorm"); expect(oneOffCall.system).not.toContain("ZOO-SOURCE"); expect(oneOffCall.system).not.toContain("HOMEWORK-SOURCE"); diff --git a/e2e/full-stack/debate-context.spec.ts b/e2e/full-stack/debate-context.spec.ts index 6768dba02..6f0f186e2 100644 --- a/e2e/full-stack/debate-context.spec.ts +++ b/e2e/full-stack/debate-context.spec.ts @@ -1,5 +1,5 @@ import type { Page } from "@playwright/test"; -import { admin, expect, fake, MARK, stackReady, startDebate, test } from "./stack"; +import { admin, expect, expectResult, fake, MARK, stackReady, startDebate, test } from "./stack"; test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)"); @@ -17,9 +17,9 @@ 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("textbox", { name: "Source title..." }).fill(title); - await page.getByRole("textbox", { name: "Paste source content..." }).fill(content); + await page.getByRole("button", { name: "Add source" }).click(); + await page.getByRole("textbox", { name: "Title", exact: true }).fill(title); + await page.getByRole("textbox", { name: "Content", exact: true }).fill(content); await page.getByRole("button", { name: "Save to Library" }).click(); await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked(); } @@ -49,8 +49,8 @@ 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 expect(page.getByText(`${MARK} case thesis`)).toBeVisible({ timeout: 30_000 }); + 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"]) { expect(generateCall.system).toContain(expected); @@ -69,8 +69,8 @@ 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 expect(page.getByText(`${MARK} idea 1`)).toBeVisible({ timeout: 30_000 }); + 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"); expect(brainstormCall.system).not.toContain("RIDERSHIP-SOURCE"); @@ -95,11 +95,11 @@ test("every request carries its debate's material, and Review changes exactly on await page.keyboard.press("Escape"); await expect(sheet).toBeHidden(); await page.goto("/app/brainstorm"); - await page.getByLabel("Motion / Topic *").fill("This House would lower the voting age"); + await page.getByLabel("Motion / topic *").fill("This House would lower the voting age"); const before = (await fake.requests()).filter((request) => request.feature === "brainstorm").length; - await page.getByRole("button", { name: "Start Brainstorming" }).click(); + await page.getByRole("button", { name: "Start brainstorming" }).click(); await page.getByRole("dialog").getByRole("button", { name: "Use once without saving to a debate" }).click(); - await expect(page.getByText(`${MARK} idea 1`)).toBeVisible({ timeout: 30_000 }); + await expectResult(page, `${MARK} idea 1`); const oneOffCall = await latestCall("brainstorm", before); expect(oneOffCall.system).not.toContain("<current-debate>"); expect(oneOffCall.system).not.toContain("FARE-SOURCE"); diff --git a/e2e/full-stack/fake-openrouter.mjs b/e2e/full-stack/fake-openrouter.mjs index 9ff7c65bb..4f48206d2 100644 --- a/e2e/full-stack/fake-openrouter.mjs +++ b/e2e/full-stack/fake-openrouter.mjs @@ -14,14 +14,39 @@ const port = Number(process.env.PORT ?? 8787); const upstream = process.env.UPSTREAM; const liveModel = process.env.LIVE_MODEL; const chatModel = (requested) => (liveModel === "as-requested" ? requested : liveModel); +// Claude runs only on DebateOwl's Anthropic key (BYOK in OpenRouter): every +// Claude call leaves pinned to the Anthropic provider with fallbacks off, and +// any `models` fallback list is dropped. A rewrite to another family (only +// as-requested reaches one) keeps just the options that still apply to it. +const ANTHROPIC_ONLY = { only: ["anthropic"], allow_fallbacks: false }; +const liveChatBody = (body) => { + const model = chatModel(body.model); + const { models: _models, ...rest } = body; + const provider = body.provider && typeof body.provider === "object" ? body.provider : {}; + if (model.startsWith("anthropic/")) return { ...rest, model, provider: { ...provider, ...ANTHROPIC_ONLY } }; + if (model === body.model || !body.provider || typeof body.provider !== "object") return { ...rest, model }; + const { only: _only, allow_fallbacks: _allowFallbacks, ...others } = provider; + return { ...rest, model, provider: others }; +}; const maxUpstreamCalls = Number(process.env.MAX_UPSTREAM_CALLS ?? 200); if (upstream && !liveModel) throw new Error("LIVE_MODEL is required with UPSTREAM"); +if (upstream && liveModel !== "as-requested" && !liveModel.startsWith("anthropic/")) { + throw new Error(`LIVE_MODEL must be an anthropic/ model (Anthropic BYOK) or as-requested, not ${liveModel}`); +} const requests = []; +// Subscription state the fake Stripe returns, set by tests through /__subscription. +const subscriptions = new Map(); let failures = []; +// Replies cut off at max_tokens (finish_reason "length"), set through /__truncate. +let truncations = []; let upstreamCalls = 0; +// Delay between streamed chunks; a test can slow it to keep a draft on screen. +let streamDelayMs; const MARK = "FAKE-PROVIDER"; +const STREAM_CHUNK_CHARS = 24; +const STREAM_CHUNK_DELAY_MS = 10; const usage = { prompt_tokens: 120, completion_tokens: 80, total_tokens: 200, cost: 0.0001 }; const contention = (index) => ({ @@ -166,20 +191,32 @@ const routes = [ ["brainstorm", "Brainstorm arguments like experienced competitive debaters", brainstorm], ["factoids", "You are a debate research specialist", factoids], ["sparring", "live debate sparring opponent", (user, system) => sparring(user, system)], - ["crossfire", "You are DebateLab's crossfire coach", (user) => crossfire(user)], + ["crossfire", "You are DebateOwl's crossfire coach", (user) => crossfire(user)], ["ask", "You are a debate coach chat bot", ask], ]; +// A cacheable system prompt arrives as text parts instead of a plain string. +function messageText(message) { + const content = message?.content; + if (Array.isArray(content)) return content.map((part) => part?.text ?? "").join(""); + return String(content ?? ""); +} + function classify(body) { const messages = Array.isArray(body.messages) ? body.messages : []; - const system = String(messages.find((message) => message.role === "system")?.content ?? ""); + const systemMessage = messages.find((message) => message.role === "system"); + const system = messageText(systemMessage); const user = String(messages.at(-1)?.content ?? ""); + const shape = { + messageCount: messages.length, + cacheBreakpoint: Array.isArray(systemMessage?.content) && systemMessage.content.some((part) => part?.cache_control), + }; if (body.stream === true && system.includes("Answer in clear Markdown that can stream smoothly")) { - return { feature: "ask-stream", system, user }; + return { feature: "ask-stream", system, user, ...shape }; } const route = routes.find(([, marker]) => system.includes(marker)); - if (!route) return { feature: null, system, user }; - return { feature: route[0], system, user, output: route[2](user, system) }; + if (!route) return { feature: null, system, user, ...shape }; + return { feature: route[0], system, user, ...shape, output: route[2](user, system) }; } function jevAnswers(questions) { @@ -198,13 +235,26 @@ function json(res, status, value) { res.end(JSON.stringify(value)); } -function stream(res) { +function stream(res, finishReason = "stop") { res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" }); const chunks = [`${MARK} streamed answer: `, "weigh access ", "against cost."]; for (const content of chunks) { res.write(`data: ${JSON.stringify({ choices: [{ delta: { content } }] })}\n\n`); } - res.write(`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: "stop" }] })}\n\n`); + res.write(`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: finishReason }] })}\n\n`); + res.write(`data: ${JSON.stringify({ choices: [], usage })}\n\n`); + res.end("data: [DONE]\n\n"); +} + +// Live generation: a structured answer streamed in small chunks, the way a +// model writes tokens, so the client's partial rendering runs for real. +async function streamJson(res, content, finishReason = "stop") { + res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" }); + for (let index = 0; index < content.length; index += STREAM_CHUNK_CHARS) { + res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: content.slice(index, index + STREAM_CHUNK_CHARS) } }] })}\n\n`); + await new Promise((resolve) => setTimeout(resolve, streamDelayMs ?? STREAM_CHUNK_DELAY_MS)); + } + res.write(`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: finishReason }] })}\n\n`); res.write(`data: ${JSON.stringify({ choices: [], usage })}\n\n`); res.end("data: [DONE]\n\n"); } @@ -219,7 +269,7 @@ async function proxy(req, res, path) { const body = JSON.parse(await readBody(req)); const chat = path === "/api/v1/chat/completions"; const record = chat - ? { ...classify(body), requestedModel: body.model, model: chatModel(body.model), stream: body.stream === true } + ? { ...classify(body), requestedModel: body.model, model: chatModel(body.model), stream: body.stream === true, maxTokens: body.max_tokens } : { feature: "jev", model: body.model }; delete record.output; requests.push(record); @@ -235,25 +285,44 @@ async function proxy(req, res, path) { return json(res, 429, { error: { message: `Live gate stopped after ${maxUpstreamCalls} provider calls` } }); } const started = Date.now(); + // A caller that hangs up (a cancelled live run) stops the upstream call too. + // A streamed reply is complete at its finish reason; the caller may close + // right after it without reading the trailing bytes. + const hangUp = new AbortController(); + let finished = false; + res.on("close", () => { + if (!res.writableEnded) hangUp.abort(); + }); try { const response = await fetch(`${upstream}${path.replace(/^\/api/, "")}`, { method: "POST", headers: { authorization: req.headers.authorization ?? "", "content-type": "application/json" }, - body: JSON.stringify(chat ? { ...body, model: chatModel(body.model) } : body), + body: JSON.stringify(chat ? liveChatBody(body) : body), + signal: hangUp.signal, }); record.status = response.status; res.writeHead(response.status, { "content-type": response.headers.get("content-type") ?? "application/json" }); // Keep the head of the reply: OpenRouter's own `model` and `provider` // fields prove which model answered, independent of the rewrite above. + // A streamed reply names them in its first chunk, so read them as it arrives. let head = ""; + let tail = ""; for await (const chunk of response.body ?? []) { - if (head.length < 8192) head += Buffer.from(chunk).toString("utf8"); + tail = (tail + Buffer.from(chunk).toString("utf8")).slice(-4096); + if (/"finish_reason"\s*:\s*"|data: \[DONE\]/.test(tail)) finished = true; + if (head.length < 8192) { + head += Buffer.from(chunk).toString("utf8"); + record.upstreamModel ??= /"model"\s*:\s*"([^"]+)"/.exec(head)?.[1]; + record.upstreamProvider ??= /"provider"\s*:\s*"([^"]+)"/.exec(head)?.[1]; + } res.write(chunk); } res.end(); - record.upstreamModel = /"model"\s*:\s*"([^"]+)"/.exec(head)?.[1]; - record.upstreamProvider = /"provider"\s*:\s*"([^"]+)"/.exec(head)?.[1]; } catch (error) { + if (hangUp.signal.aborted) { + if (!finished) record.status = 499; + return; + } record.status = 502; const message = error instanceof Error ? error.message : "upstream connection failed"; console.error(`live provider request failed: ${message}`); @@ -273,6 +342,23 @@ createServer(async (req, res) => { if (url.pathname === "/__reset" && req.method === "POST") { requests.length = 0; failures = []; + truncations = []; + streamDelayMs = undefined; + return json(res, 200, { ok: true }); + } + if (url.pathname === "/__stream-delay" && req.method === "POST") { + const { ms } = JSON.parse(await readBody(req) || "{}"); + streamDelayMs = typeof ms === "number" && ms >= 0 ? ms : undefined; + return json(res, 200, { ok: true }); + } + if (url.pathname === "/__subscription" && req.method === "POST") { + const subscription = JSON.parse(await readBody(req) || "{}"); + subscriptions.set(subscription.id, subscription); + return json(res, 200, { ok: true }); + } + if (url.pathname === "/__truncate" && req.method === "POST") { + const { feature, count = 1 } = JSON.parse(await readBody(req) || "{}"); + truncations.push({ feature, count }); return json(res, 200, { ok: true }); } if (url.pathname === "/__fail" && req.method === "POST") { @@ -290,6 +376,14 @@ createServer(async (req, res) => { requests.push({ feature: "jev", model: body.model, status: 200 }); return json(res, 200, { model: body.model, answers: jevAnswers(body.questions ?? {}), usage: { input_tokens: 40, output_tokens: 8, cost: 0.00001 } }); } + const subscriptionPath = url.pathname.match(/^\/v1\/subscriptions\/([^/]+)$/); + if (subscriptionPath && req.method === "GET") { + // The webhook applies Stripe's current subscription state, not the event's. + const subscription = subscriptions.get(decodeURIComponent(subscriptionPath[1])); + if (!subscription) return json(res, 404, { error: { type: "invalid_request_error", message: "No such subscription" } }); + requests.push({ feature: "stripe-subscription", user: subscription.id }); + return json(res, 200, subscription); + } if (url.pathname === "/v1/customers/search" && req.method === "GET") { // Account deletion searches Stripe (through STRIPE_API_URL) for the user's // customers. Test accounts have none. @@ -301,8 +395,9 @@ createServer(async (req, res) => { } const body = JSON.parse(await readBody(req)); - const { feature, system, user, output } = classify(body); - requests.push({ feature, model: body.model, stream: body.stream === true, system, user }); + const { output, ...record } = classify(body); + const { feature } = record; + requests.push({ ...record, model: body.model, stream: body.stream === true, maxTokens: body.max_tokens }); if (!feature) return json(res, 500, { error: { message: "Fake OpenRouter does not recognize this prompt" } }); const failure = failures.find((item) => item.count > 0 && (!item.feature || item.feature === feature)); @@ -310,12 +405,18 @@ createServer(async (req, res) => { failure.count -= 1; return json(res, failure.status, { error: { message: `Fake ${feature} failure` } }); } - if (feature === "ask-stream") return stream(res); + // A cut-off reply stops halfway with finish_reason "length", as a model does at max_tokens. + const truncation = truncations.find((item) => item.count > 0 && (!item.feature || item.feature === feature)); + if (truncation) truncation.count -= 1; + const finishReason = truncation ? "length" : "stop"; + if (feature === "ask-stream") return stream(res, finishReason); if (output === null) return json(res, 500, { error: { message: `Fake ${feature} could not parse the request` } }); + const content = truncation ? JSON.stringify(output).slice(0, Math.floor(JSON.stringify(output).length / 2)) : JSON.stringify(output); + if (body.stream === true) return streamJson(res, content, finishReason); return json(res, 200, { id: `fake-${requests.length}`, model: body.model, - choices: [{ index: 0, finish_reason: "stop", message: { role: "assistant", content: JSON.stringify(output) } }], + choices: [{ index: 0, finish_reason: finishReason, message: { role: "assistant", content } }], usage, }); }).listen(port, "0.0.0.0", () => console.log(`${upstream ? "live proxy" : "fake OpenRouter"} listening on ${port}`)); diff --git a/e2e/full-stack/first-run.spec.ts b/e2e/full-stack/first-run.spec.ts index 8c6f8dbe7..f01f0b96b 100644 --- a/e2e/full-stack/first-run.spec.ts +++ b/e2e/full-stack/first-run.spec.ts @@ -1,5 +1,5 @@ import type { Page } from "@playwright/test"; -import { admin, type DemoUser, expect, MARK, providerCall, stackReady, test } from "./stack"; +import { admin, expect, expectResult, MARK, providerCall, stackReady, test, type DemoUser } from "./stack"; test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)"); @@ -10,16 +10,16 @@ const copy = { demoTab: "Demo code", demoCode: "Demo code", signIn: "Sign in with demo code", - welcome: "Welcome to DebateLab!", + welcome: "Welcome to DebateOwl", firstDebate: "Start your first debate here.", startDebate: "Start debate", motionField: "Motion", library: "Library", generate: "Generate", - finish: "Get Started!", - skip: "Skip Tour", - generateMotion: "Motion / Topic *", - generateSubmit: "Generate Debate Case", + finish: "Get started", + skip: "Skip tour", + generateMotion: "Motion / topic *", + generateSubmit: "Generate debate case", offerTitle: "Start a debate with this motion?", workingIn: "Working in:", tourMotion: "This House would ban homework", @@ -29,16 +29,16 @@ const copy = { demoTab: "Demókód", demoCode: "Demókód", signIn: "Bejelentkezés demókóddal", - welcome: "Üdvözlünk a DebateLab-ban!", + welcome: "Üdvözlünk a DebateOwl-ban", firstDebate: "Itt indíthatod el az első vitádat.", startDebate: "Vita indítása", motionField: "Tétel", - library: "Forráskönyvtár", + library: "Könyvtár", generate: "Generálás", - finish: "Kezdjük!", - skip: "Túra Kihagyása", - generateMotion: "Indítvány / Téma *", - generateSubmit: "Vitaeset Generálása", + finish: "Kezdjük", + skip: "Túra kihagyása", + generateMotion: "Indítvány / téma *", + generateSubmit: "Vitaeset generálása", offerTitle: "Indítasz egy vitát ezzel a tétellel?", workingIn: "Munka ebben:", tourMotion: "Ez a Ház betiltaná a házi feladatot", @@ -124,7 +124,7 @@ for (const locale of ["en", "hu"] as const) { await expect(page.getByTestId("current-debate-strip")).toContainText(`${text.workingIn} ${text.tourMotion}`); await expect(page.getByRole("textbox", { name: text.generateMotion })).toHaveValue(text.tourMotion); await page.getByRole("button", { name: text.generateSubmit }).click(); - await expect(page.getByText(`${MARK} case thesis`)).toBeVisible({ timeout: 30_000 }); + await expectResult(page, `${MARK} case thesis`); await expect(page.getByRole("dialog")).toHaveCount(0); expect((await providerCall("generate")).user).toContain(`Motion: ${text.tourMotion}`); await expect.poll(async () => { @@ -149,7 +149,7 @@ for (const locale of ["en", "hu"] as const) { await offer.getByRole("button", { name: text.startDebate }).click(); await expect(offer).toBeHidden(); - await expect(page.getByText(`${MARK} case thesis`)).toBeVisible({ timeout: 30_000 }); + await expectResult(page, `${MARK} case thesis`); await expect(page.getByTestId("current-debate-strip")).toContainText(`${text.workingIn} ${text.gateMotion}`); const { debates, currentId } = await serverDebates(user); expect(debates.map((debate) => debate.motion)).toEqual([text.gateMotion]); diff --git a/e2e/full-stack/stack-core.ts b/e2e/full-stack/stack-core.ts index 4761b4c66..0b5480f24 100644 --- a/e2e/full-stack/stack-core.ts +++ b/e2e/full-stack/stack-core.ts @@ -22,7 +22,7 @@ export const admin = stackReady ? createClient(supabaseUrl!, serviceKey!, { auth: { persistSession: false } }) : (null as never); -export type FakeRequest = { feature: string | null; model?: string; stream?: boolean; system: string; user: string }; +export type FakeRequest = { feature: string | null; model?: string; stream?: boolean; maxTokens?: number; system: string; user: string }; export const fake = { async reset() { @@ -34,6 +34,18 @@ export const fake = { async fail(feature: string, status = 500, count = 1) { await fetch(`${fakeUrl}/__fail`, { method: "POST", body: JSON.stringify({ feature, status, count }) }); }, + /** Cuts the next `count` replies for a feature off at max_tokens (fake mode only). */ + async truncate(feature: string, count = 1) { + await fetch(`${fakeUrl}/__truncate`, { method: "POST", body: JSON.stringify({ feature, count }) }); + }, + /** Sets what the fake Stripe returns for GET /v1/subscriptions/:id. */ + async stripeSubscription(subscription: { id: string; status: string }) { + await fetch(`${fakeUrl}/__subscription`, { method: "POST", body: JSON.stringify(subscription) }); + }, + /** Milliseconds between streamed chunks (fake mode only) until the next reset. */ + async streamDelay(ms: number) { + await fetch(`${fakeUrl}/__stream-delay`, { method: "POST", body: JSON.stringify({ ms }) }); + }, }; export type DemoUser = { code: string; email: string; userId: () => Promise<string> }; diff --git a/e2e/full-stack/stack.ts b/e2e/full-stack/stack.ts index 041eb8d78..e873ca69c 100644 --- a/e2e/full-stack/stack.ts +++ b/e2e/full-stack/stack.ts @@ -12,6 +12,17 @@ export async function signIn(page: Page, user: DemoUser) { await expect(page).toHaveURL(/\/app$/, { timeout: 20_000 }); } +/** + * Waits for a tool's finished result to show `text`. A live draft shows the + * text while the request is still running, before the result is saved and + * billed, so seeing the text alone does not mean the run is done. + */ +export async function expectResult(page: Page, text: string, timeout = 30_000) { + await expect(page.getByText(text).first()).toBeVisible({ timeout }); + await expect(page.getByTestId("live-draft")).toHaveCount(0, { timeout }); + await expect(page.getByText(text).first()).toBeVisible(); +} + /** The visible Current Debate control: the header menu on desktop, the second header row on mobile. */ export function debateControl(page: Page) { return page.locator('[data-testid="current-debate-desktop"]:visible, [data-testid="current-debate-mobile"]:visible').first(); @@ -63,6 +74,12 @@ export const test = base.extend<Fixtures>({ // Any uncaught exception fails the test, even when the visible flow passes. const errors: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); + // Jam starts on the first click, mid-test, and wraps window.fetch. With + // it, Playwright stops seeing the streamed `ai` responses these flows + // wait on. Real users are unaffected; tests that cover Jam itself live in + // the browser-only specs. Serve empty modules so it never starts here. + await page.context().route("https://js.jam.dev/**", (route) => + route.fulfill({ status: 200, contentType: "text/javascript", body: "" })); // Init scripts rerun on every load, so seed only what the app has not written. await page.addInitScript(() => { const seed = (key: string, state: Record<string, unknown>) => { diff --git a/e2e/full-stack/voice.spec.ts b/e2e/full-stack/voice.spec.ts index 854db7164..830a86b6e 100644 --- a/e2e/full-stack/voice.spec.ts +++ b/e2e/full-stack/voice.spec.ts @@ -1,5 +1,6 @@ import { admin, chooseForMotion, expect, fake, MARK, providerCall, stackReady, test } from "./stack"; import { say, spokenByBrowser, stubSpeech } from "../helpers/voice"; +import { readAIResponse } from "../helpers/ai-response"; test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)"); @@ -16,7 +17,7 @@ test("a spoken sparring round runs on the browser voice, and no audio tag is sho page.on("response", async (response) => { if (response.request().method() !== "POST") return; const path = new URL(response.url()).pathname; - if (path.endsWith("/functions/v1/ai")) aiVoice.push(await response.json().catch(() => ({}))); + if (path.endsWith("/functions/v1/ai")) aiVoice.push(await readAIResponse(response).then(({ body }) => body).catch(() => ({}))); if (path.endsWith("/functions/v1/voice")) { const body = await response.json().catch(() => ({})); voiceAnswers.push({ status: response.status(), code: body.code }); @@ -107,7 +108,7 @@ test("a spoken Crossfire reply is cleaned, signed, and read by the browser voice const aiVoice: Array<VoiceBody & { output?: { text?: string } }> = []; page.on("response", async (response) => { if (response.request().method() === "POST" && new URL(response.url()).pathname.endsWith("/functions/v1/ai")) { - aiVoice.push(await response.json().catch(() => ({}))); + aiVoice.push(await readAIResponse(response).then(({ body }) => body).catch(() => ({}))); } }); diff --git a/e2e/helpers/ai-response.ts b/e2e/helpers/ai-response.ts new file mode 100644 index 000000000..bfe0a28aa --- /dev/null +++ b/e2e/helpers/ai-response.ts @@ -0,0 +1,22 @@ +import type { Response } from "@playwright/test"; + +export type AIResponseBody = { output: Record<string, unknown>; discoveredSources?: unknown[]; [key: string]: unknown }; + +/** + * The status and body of an `ai` Edge Function answer. Pages ask for live + * generation, which answers with Server-Sent Events whose last `result` + * event carries the status and JSON body a plain request returns; a plain + * request (or a server from before live generation) answers with JSON. + */ +export async function readAIResponse(response: Response): Promise<{ status: number; body: AIResponseBody }> { + const contentType = response.headers()["content-type"] ?? ""; + if (!contentType.includes("text/event-stream")) { + return { status: response.status(), body: await response.json() as AIResponseBody }; + } + const text = await response.text(); + const result = text.split(/\n\n/).reverse().find((block) => /^event: result$/m.test(block)); + const data = result && /^data: (.*)$/m.exec(result)?.[1]; + if (!data) throw new Error("The ai event stream ended without a result event"); + const parsed = JSON.parse(data) as { status: number; body: AIResponseBody }; + return { status: parsed.status, body: parsed.body }; +} diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts index 4a6e17918..379b1d230 100644 --- a/e2e/helpers/auth.ts +++ b/e2e/helpers/auth.ts @@ -43,6 +43,27 @@ export async function stubSupabaseSession(page: Page, opts: StubAuthOptions = {} } }, { key: `debate-auth-${env}`, value: session }); + // The bogus JWT makes data requests fail with 401, and the client then + // forces a token refresh to tell a revoked session from a stale one. Accept + // that refresh with the same stub session, so a render check keeps its + // session and never asks the real Auth server about it. + await page.route((url) => url.pathname.endsWith('/auth/v1/token') && url.searchParams.get('grant_type') === 'refresh_token', (route) => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ...session, expires_at: Math.floor(Date.now() / 1000) + HOUR_SECONDS }), + })); + + // A restored session is confirmed with GET /user. A 401 or 403 signs the tab + // out, so the stub user has to answer that call or the page returns to /login. + await page.route((url) => url.pathname.endsWith('/auth/v1/user'), (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(session.user), + }); + }); + // Credential-free render checks must not call the production announcement // function. Tests that exercise announcements register a more specific // route after this default. diff --git a/e2e/helpers/quota.ts b/e2e/helpers/quota.ts new file mode 100644 index 000000000..1bb7e7205 --- /dev/null +++ b/e2e/helpers/quota.ts @@ -0,0 +1,22 @@ +import type { Route } from '@playwright/test'; + +/** + * Answers the read-only quota check Crossfire and Sparring send to the `ai` + * function before a round starts (`quotaCheck: true`, no `input`): a round + * that fits. Returns true when the request was one; mocks call it first. + */ +export async function fulfillQuotaCheck(route: Route, headers: Record<string, string>): Promise<boolean> { + let body: { quotaCheck?: unknown } | null = null; + try { + body = route.request().postDataJSON() as { quotaCheck?: unknown } | null; + } catch { + return false; + } + if (body?.quotaCheck !== true) return false; + await route.fulfill({ + status: 200, + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ quota: { allowed: true, limit: 50, current: 0, units: 1 } }), + }); + return true; +} diff --git a/e2e/home-navigation.spec.ts b/e2e/home-navigation.spec.ts index 67b9aac5f..d4d85b657 100644 --- a/e2e/home-navigation.spec.ts +++ b/e2e/home-navigation.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, type Page } from '@playwright/test'; import { setLocale, stubSupabaseSession } from './helpers/auth'; test.beforeEach(async ({ page }) => { @@ -16,7 +16,7 @@ test('home groups the eight main tools and keeps utility routes visible', async await page.goto('/app', { waitUntil: 'domcontentloaded' }); const main = page.locator('main'); - await expect(main.getByRole('heading', { name: 'DebateLab' })).toBeVisible({ timeout: 15_000 }); + await expect(main.getByRole('heading', { name: 'What are you working on?', level: 1 })).toBeVisible({ timeout: 15_000 }); await expect(main.getByRole('heading', { name: 'Prepare' })).toBeVisible(); await expect(main.getByRole('heading', { name: 'Practice' })).toBeVisible(); await expect(main.getByRole('heading', { name: 'Utilities' })).toBeVisible(); @@ -65,8 +65,8 @@ test('onboarding keeps its original start schedule after a delayed lazy chunk', const response = await route.fetch(); const source = await response.text(); const body = source.replace( - /const\s+timer\s*=\s*setTimeout\(\(\)\s*=>\s*\{/, - 'window.localStorage.setItem("__onboarding-auto-start-delay", String(autoStartDelay));\n const timer = setTimeout(() => {', + /const\s+deadline\s*=\s*startedAt\s*\+\s*autoStartDelay;/, + 'window.localStorage.setItem("__onboarding-auto-start-delay", String(autoStartDelay));\n const deadline = startedAt + autoStartDelay;', ); if (body === source) throw new Error('Unable to instrument the onboarding auto-start delay'); await new Promise((resolve) => setTimeout(resolve, 750)); @@ -95,7 +95,7 @@ test('onboarding starts a fresh delay when a protected route reaches home', asyn })); }); await page.goto('/app/generate', { waitUntil: 'domcontentloaded' }); - await expect(page.getByRole('heading', { name: 'Ready to Generate' })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('heading', { name: 'Your case will appear here' })).toBeVisible({ timeout: 15_000 }); // eslint-disable-next-line no-restricted-properties -- observation window: let the first onboarding delay run partway before navigating. await page.waitForTimeout(650); @@ -110,15 +110,19 @@ test('header keeps the brand and controls inside a 320px viewport', async ({ pag await page.goto('/app', { waitUntil: 'domcontentloaded' }); await expect(page.getByRole('heading', { name: 'Prepare' })).toBeVisible({ timeout: 15_000 }); - // Mobile keeps the logo, language, theme, and menu on the first row; Settings lives in the menu. + // A phone keeps the logo, language, and Current debate row. Settings is a tab, not a menu button. const languageControl = page.getByRole('button', { name: 'Language' }); - const themeControl = page.getByRole('button', { name: 'Toggle theme' }); - const menuControl = page.getByRole('button', { name: 'Open menu' }); const debateControl = page.getByTestId('current-debate-mobile'); - const controls = [languageControl, themeControl, menuControl, debateControl]; + const tabBar = page.getByRole('navigation', { name: 'Mobile navigation' }); + const controls = [languageControl, debateControl]; - await expect(page.getByAltText('DebateLab logo')).toBeVisible(); + await expect(page.getByAltText('DebateOwl logo')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Open menu' })).toBeHidden(); + await expect(tabBar).toBeVisible(); for (const control of controls) await expect(control).toBeVisible(); + for (const label of ['Tools', 'Current debate', 'Library', 'Settings']) { + await expect(tabBar.getByRole('link', { name: label, exact: true })).toBeVisible(); + } const boxes = await Promise.all(controls.map((control) => control.boundingBox())); expect(boxes.every((box) => box && box.x >= 0 && box.x + box.width <= 320)).toBe(true); @@ -133,21 +137,178 @@ test('desktop header fits without internal or document overflow at supported wid const navigation = page.getByRole('navigation', { name: 'Main navigation' }); await expect(navigation).toBeVisible(); + await expect(page.getByRole('navigation', { name: 'Mobile navigation' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Open menu' })).toBeHidden(); expect(await navigation.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); } }); -test('mobile menu holds only Home, Current debate, Library, History, and Settings', async ({ page }) => { +test('phone tab bar holds Tools, Current debate, Library, and Settings', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/app', { waitUntil: 'domcontentloaded' }); - await page.getByRole('button', { name: 'Open menu' }).click(); + await expect(page.getByRole('heading', { name: 'Prepare' })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('button', { name: 'Open menu' })).toBeHidden(); - const mobileNavigation = page.getByRole('navigation', { name: 'Mobile navigation' }); - const hrefs = await mobileNavigation.getByRole('link').evaluateAll((links) => links.map((link) => link.getAttribute('href'))); - expect(hrefs).toEqual(['/app', '/app/debate', '/app/library', '/app/history', '/app/settings']); + const tabBar = page.getByRole('navigation', { name: 'Mobile navigation' }); + await expect(tabBar).toBeVisible(); + const hrefs = await tabBar.getByRole('link').evaluateAll((links) => links.map((link) => link.getAttribute('href'))); + expect(hrefs).toEqual(['/app', '/app/debate', '/app/library', '/app/settings']); + const widths = await tabBar.getByRole('link').evaluateAll((links) => links.map((link) => link.getBoundingClientRect().width)); + expect(Math.max(...widths) - Math.min(...widths)).toBeLessThan(2); - await mobileNavigation.getByRole('link', { name: /^Library/ }).click(); + await tabBar.getByRole('link', { name: 'Library', exact: true }).click(); await expect(page).toHaveURL(/\/app\/library$/); + await expect(tabBar.getByRole('link', { name: 'Library', exact: true })).toHaveAttribute('aria-current', 'page'); + + // The page can scroll its end clear of the bar, including the footer. + await page.goto('/app/settings', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('heading', { name: 'Settings', exact: true })).toBeVisible({ timeout: 15_000 }); + const terms = page.getByRole('contentinfo').getByRole('link', { name: 'Terms' }); + await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)); + await expect(terms).toBeVisible(); + const [termsBox, tabBox] = await Promise.all([terms.boundingBox(), tabBar.boundingBox()]); + expect(termsBox).not.toBeNull(); + expect(tabBox).not.toBeNull(); + expect(termsBox!.y + termsBox!.height).toBeLessThanOrEqual(tabBox!.y + 1); +}); + +async function paintViewport(page: Page, next: { height: number; offsetTop: number; scale: number }) { + await page.evaluate((value) => { + const viewport = window.visualViewport; + if (!viewport) throw new Error('visualViewport is missing'); + for (const key of ['height', 'offsetTop', 'scale'] as const) { + Object.defineProperty(viewport, key, { configurable: true, get: () => value[key] }); + } + viewport.dispatchEvent(new Event('resize')); + }, next); +} + +test('phone tab bar hides for a keyboard and returns, and ignores pinch-zoom', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/app/ask', { waitUntil: 'domcontentloaded' }); + const field = page.getByRole('textbox', { name: 'Message to your debate coach' }); + const tabBar = page.getByRole('navigation', { name: 'Mobile navigation' }); + await expect(field).toBeVisible({ timeout: 15_000 }); + await expect(tabBar).toBeVisible(); + + await field.focus(); + await paintViewport(page, { height: 380, offsetTop: 0, scale: 1.5 }); + await expect(tabBar).toBeVisible(); + + await paintViewport(page, { height: 380, offsetTop: 0, scale: 1 }); + await expect(tabBar).toBeHidden(); + const hidden = await page.evaluate(() => ({ + reserve: document.documentElement.style.getPropertyValue('--phone-tab-reserve').trim(), + keyboard: document.documentElement.style.getPropertyValue('--native-keyboard-height').trim(), + })); + expect(hidden.reserve).toBe('0px'); + expect(Number.parseFloat(hidden.keyboard)).toBeGreaterThan(120); + + await paintViewport(page, { height: 844, offsetTop: 0, scale: 1 }); + await expect(tabBar).toBeVisible(); + const shown = await page.evaluate(() => document.documentElement.style.getPropertyValue('--phone-tab-reserve').trim()); + expect(Number.parseFloat(shown)).toBeGreaterThan(0); +}); + +test('Ask composer stays above the tab bar while an announcement is up', async ({ page }) => { + await page.route('**/functions/v1/announcements*', (route) => route.fulfill({ + json: { + announcements: [{ + id: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', + title: 'Scheduled maintenance', + body: 'DebateLab will be unavailable for ten minutes.', + starts_at: '2026-08-31T12:00:00.000Z', + expires_at: null, + created_at: '2026-08-31T11:00:00.000Z', + }], + }, + })); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/app/ask', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('heading', { name: 'Scheduled maintenance' })).toBeVisible({ timeout: 15_000 }); + const send = page.getByRole('button', { name: 'Send' }); + const tabBar = page.getByRole('navigation', { name: 'Mobile navigation' }); + await expect(send).toBeVisible(); + await expect(tabBar).toBeVisible(); + const [sendBox, tabBox, tops] = await Promise.all([ + send.boundingBox(), + tabBar.boundingBox(), + page.evaluate(() => ({ + main: document.documentElement.style.getPropertyValue('--app-main-top').trim(), + header: document.documentElement.style.getPropertyValue('--app-header-bottom').trim(), + })), + ]); + expect(sendBox).not.toBeNull(); + expect(tabBox).not.toBeNull(); + expect(sendBox!.y + sendBox!.height).toBeLessThanOrEqual(tabBox!.y + 1); + expect(Number.parseFloat(tops.main)).toBeGreaterThan(Number.parseFloat(tops.header)); +}); + +test('a toast at 700px sits above the tab bar', async ({ page }) => { + await page.setViewportSize({ width: 700, height: 800 }); + await page.goto('/app', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('heading', { name: 'Prepare' })).toBeVisible({ timeout: 15_000 }); + const tabBar = page.getByRole('navigation', { name: 'Mobile navigation' }); + await expect(tabBar).toBeVisible(); + + const toasted = await page.evaluate(async () => { + const match = performance.getEntriesByType('resource') + .map((entry) => entry.name) + .find((name) => name.includes('/sonner')); + if (!match) return 'missing'; + const mod = await import(/* @vite-ignore */ match) as { toast?: { success: (message: string, options: { duration: number }) => void } }; + if (!mod.toast?.success) return 'no-toast'; + mod.toast.success('Copied to clipboard', { duration: 60_000 }); + return 'ok'; + }); + expect(toasted).toBe('ok'); + + const toast = page.locator('[data-sonner-toast]').last(); + await expect(toast).toHaveAttribute('data-mounted', 'true'); + const offset = await page.evaluate(() => { + const ol = document.querySelector('[data-sonner-toaster]'); + return { + reserve: Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--phone-tab-reserve')), + usedBottom: ol ? Number.parseFloat(getComputedStyle(ol).bottom) : 0, + }; + }); + expect(offset.reserve).toBeGreaterThan(40); + expect(offset.usedBottom).toBeGreaterThan(offset.reserve); + // The enter slide starts under the offset. The resting box is what has to clear the bar. + await expect.poll(async () => { + const [toastBox, tabBox] = await Promise.all([toast.boundingBox(), tabBar.boundingBox()]); + if (!toastBox || !tabBox) return Number.POSITIVE_INFINITY; + return toastBox.y + toastBox.height - tabBox.y; + }).toBeLessThanOrEqual(1); +}); + +test('iPad width does not keep a keyboard pad', async ({ page }) => { + await page.setViewportSize({ width: 820, height: 1180 }); + await page.goto('/app/ask', { waitUntil: 'domcontentloaded' }); + const field = page.getByRole('textbox', { name: 'Message to your debate coach' }); + await expect(field).toBeVisible({ timeout: 15_000 }); + await field.focus(); + await paintViewport(page, { height: 500, offsetTop: 0, scale: 1 }); + const pad = await page.evaluate(() => ({ + keyboard: document.documentElement.style.getPropertyValue('--native-keyboard-height').trim(), + reserve: document.documentElement.style.getPropertyValue('--phone-tab-reserve').trim(), + main: getComputedStyle(document.querySelector('main')!).paddingBottom, + })); + expect(pad.keyboard).toBe(''); + expect(pad.reserve).toBe(''); + expect(pad.main).toBe('0px'); +}); + +test('iPad portrait keeps the header nav instead of the tab bar', async ({ page }) => { + await page.setViewportSize({ width: 820, height: 1180 }); + await page.goto('/app', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('heading', { name: 'Prepare' })).toBeVisible({ timeout: 15_000 }); + const navigation = page.getByRole('navigation', { name: 'Main navigation' }); + await expect(navigation).toBeVisible(); await expect(page.getByRole('navigation', { name: 'Mobile navigation' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Open menu' })).toHaveCount(0); + const hrefs = await navigation.getByRole('link').evaluateAll((links) => links.map((link) => link.getAttribute('href'))); + expect(hrefs).toEqual(expect.arrayContaining(['/app', '/app/library', '/app/history'])); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); }); diff --git a/e2e/hungarian-parity.spec.ts b/e2e/hungarian-parity.spec.ts index d2fedeb0f..207a2caae 100644 --- a/e2e/hungarian-parity.spec.ts +++ b/e2e/hungarian-parity.spec.ts @@ -1,6 +1,9 @@ +import { readFileSync } from 'node:fs'; import { expect, test, type Page } from '@playwright/test'; import { seedCurrentDebate, setLocale, stubSupabaseSession } from './helpers/auth'; +const site = JSON.parse(readFileSync('src/config/site.json', 'utf8')) as { product: string }; + function collectRuntimeErrors(page: Page) { const errors: string[] = []; page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`)); @@ -32,7 +35,8 @@ test.describe('Hungarian parity', () => { await page.goto('/', { waitUntil: 'domcontentloaded' }); await expectHungarianDocument(page); - await expect(page.getByText('MI-alapú vitafelkészülés, ami előnyt ad minden fordulóban.')).toBeVisible(); + await expect(page.getByRole('heading', { level: 1, name: `${site.product}: MI-alapú vitafelkészítés és gyakorlás` })).toBeVisible(); + await expect(page.getByText(/^Attól függ, mire van szükséged\. 2026\. október állapot szerint/)).toBeAttached(); if ((page.viewportSize()?.width ?? 0) < 768) { await expect(page.getByRole('button', { name: 'Menü megnyitása' })).toBeVisible(); } else { @@ -45,9 +49,9 @@ test.describe('Hungarian parity', () => { await expectHungarianDocument(page); for (const [path, heading] of [ - ['/login', 'Bejelentkezés a DebateLab-ba'], - ['/terms', 'Felhasználási Feltételek'], - ['/privacy', 'Adatvédelmi Irányelvek'], + ['/login', 'Bejelentkezés a DebateOwl-ba'], + ['/terms', 'Felhasználási feltételek'], + ['/privacy', 'Adatvédelmi irányelvek'], ['/expocit/sources', 'Források'], ] as const) { await page.goto(path, { waitUntil: 'domcontentloaded' }); @@ -75,9 +79,9 @@ test.describe('Hungarian parity', () => { await expectHungarianDocument(page); await expect(page.getByRole('heading', { name: 'Felkészülés' })).toBeVisible({ timeout: 15_000 }); - await expect(page.locator('.react-joyride__tooltip')).toContainText('Üdvözlünk a DebateLab-ban!', { timeout: 15_000 }); + await expect(page.locator('.react-joyride__tooltip')).toContainText('Üdvözlünk a DebateOwl-ban', { timeout: 15_000 }); await expect(page.locator('[data-test-id="button-primary"]')).toContainText('Következő'); - await expect(page.locator('[data-test-id="button-skip"]')).toContainText('Túra Kihagyása'); + await expect(page.locator('[data-test-id="button-skip"]')).toContainText('Túra kihagyása'); await page.locator('[data-test-id="button-skip"]').click(); for (const [route, heading] of [ @@ -87,7 +91,7 @@ test.describe('Hungarian parity', () => { ['factoids', 'Tények'], ['ask', 'Kérdezés'], ] as const) { - // Tools open from Home; the header keeps only Home, Current debate, Library and History. + // Tools open from the Tools page; the header keeps only Tools, Current debate, Library and History. // Navigate in-app: a reload would rerun the init script and restart the tour. await page.locator('header a[href="/app"]').first().click(); await page.locator(`main [data-tour="mode-${route}"]`).click(); @@ -131,17 +135,25 @@ test.describe('Hungarian parity', () => { await page.goto('/app/library', { waitUntil: 'domcontentloaded' }); await expect(page.getByRole('textbox', { name: 'Források keresése' })).toBeVisible(); - await page.getByRole('button', { name: 'Forrás Hozzáadása' }).click(); + await page.getByRole('button', { name: 'Forrás hozzáadása' }).click(); await expect(page.getByText('Importálás URL-ből')).toBeVisible(); - await page.getByRole('button', { name: 'Mentés a Könyvtárba' }).click(); - await expect(page.locator('[data-sonner-toast] [data-close-button]')).toHaveAttribute('aria-label', 'Bezárás'); + await page.getByRole('button', { name: 'Mentés a könyvtárba' }).click(); + // Required fields are checked inline, in Hungarian. + await expect(page.getByRole('alert').filter({ hasText: 'Adj meg egy címet.' })).toBeVisible(); await page.goto('/app/flowchart', { waitUntil: 'domcontentloaded' }); - await page.getByRole('button', { name: 'Betöltés' }).click(); - await expect(page.getByRole('dialog', { name: 'Mentett Folyamatábrák' }).getByRole('button', { name: 'Bezárás' })).toBeVisible(); + await page.getByRole('button', { name: 'Megnyitás', exact: true }).click(); + await expect(page.getByRole('dialog', { name: 'Mentett folyamatábrák' }).getByRole('button', { name: 'Bezárás' })).toBeVisible(); + await page.keyboard.press('Escape'); + // Deleting a node raises a toast, whose close button is localized too. + await page.getByText('1. érv', { exact: true }).click(); + await page.getByRole('button', { name: 'Törlés' }).last().click(); + const confirmNodeDelete = page.getByRole('button', { name: 'Csomópont törlése' }); + if (await confirmNodeDelete.isVisible().catch(() => false)) await confirmNodeDelete.click(); + await expect(page.locator('[data-sonner-toast] [data-close-button]')).toHaveAttribute('aria-label', 'Bezárás'); await page.goto('/app/timer', { waitUntil: 'domcontentloaded' }); - await expect(page.getByRole('timer', { name: 'Időmérő kijelző' })).toBeVisible(); + await expect(page.getByRole('timer', { name: 'Időmérő-kijelző' })).toBeVisible(); expect(errors, errors.join('\n')).toEqual([]); }); }); diff --git a/e2e/live/live.ts b/e2e/live/live.ts index 7033b9d90..619ad0126 100644 --- a/e2e/live/live.ts +++ b/e2e/live/live.ts @@ -1,5 +1,6 @@ import type { Page } from "@playwright/test"; import { expect, fake } from "../full-stack/stack"; +import { readAIResponse, type AIResponseBody } from "../helpers/ai-response"; // Live runs call real providers, so outputs vary. Assert structure, never text. export const LIVE_TIMEOUT = 180_000; @@ -13,9 +14,10 @@ export function aiResponse(page: Page, feature: string) { } catch { return false; } - }, { timeout: LIVE_TIMEOUT }).then(async (response) => { - expect(response.status(), `ai ${feature} status`).toBe(200); - return response.json() as Promise<{ output: Record<string, unknown>; discoveredSources?: unknown[] }>; + }, { timeout: LIVE_TIMEOUT }).then(async (response): Promise<AIResponseBody> => { + const { status, body } = await readAIResponse(response); + expect(status, `ai ${feature} status`).toBe(200); + return body; }); } diff --git a/e2e/live/models.spec.ts b/e2e/live/models.spec.ts index 633f2fadd..dbe2cad2b 100644 --- a/e2e/live/models.spec.ts +++ b/e2e/live/models.spec.ts @@ -6,7 +6,7 @@ test.describe.configure({ timeout: LIVE_TIMEOUT + 60_000 }); test("Ask streams a live answer", async ({ signedIn: page }) => { await page.goto("/app/ask"); - await page.getByPlaceholder("Ask your debate coach...").fill("In two sentences, how should I weigh access against cost?"); + await page.getByPlaceholder("Ask your debate coach…").fill("In two sentences, how should I weigh access against cost?"); const stream = page.waitForResponse((response) => response.url().endsWith("/functions/v1/ai-stream"), { timeout: LIVE_TIMEOUT }); await page.getByRole("button", { name: "Send message" }).click(); @@ -24,15 +24,15 @@ test("Ask streams a live answer", async ({ signedIn: page }) => { test("Attack and Brainstorm return valid structured analyses", async ({ signedIn: page }) => { await page.goto("/app/attack"); - await page.getByLabel("Your Argument *").fill("Free transit cuts congestion because riders leave their cars at home, so cities should abolish fares."); + await page.getByLabel("Your argument *").fill("Free transit cuts congestion because riders leave their cars at home, so cities should abolish fares."); const attack = aiResponse(page, "attack"); - await page.getByRole("button", { name: "Find Weaknesses" }).click(); + await page.getByRole("button", { name: "Find weaknesses" }).click(); expect(((await attack).output.weaknesses as unknown[]).length).toBeGreaterThan(0); await page.goto("/app/brainstorm"); - await page.getByLabel("Motion / Topic *").fill("Universal basic income should replace targeted welfare"); + await page.getByLabel("Motion / topic *").fill("Universal basic income should replace targeted welfare"); const brainstorm = aiResponse(page, "brainstorm"); - await page.getByRole("button", { name: "Start Brainstorming" }).click(); + await 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 4ecff7837..0347c5367 100644 --- a/e2e/live/research.spec.ts +++ b/e2e/live/research.spec.ts @@ -8,9 +8,9 @@ const httpLink = /^https?:\/\//; test("case generation researches the web and cites what it found", async ({ signedIn: page, user }) => { await page.goto("/app/generate"); - await page.getByRole("textbox", { name: "Motion / Topic *" }).fill("This House would make public transport free at the point of use"); + await page.getByRole("textbox", { name: "Motion / topic *" }).fill("This House would make public transport free at the point of use"); const response = aiResponse(page, "generate"); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await page.getByRole("button", { name: "Generate debate case" }).click(); await chooseForMotion(page, "once"); const body = await response; @@ -22,6 +22,8 @@ test("case generation researches the web and cites what it found", async ({ sign const panel = page.getByText("Researched sources").locator(".."); await expect(panel).toBeVisible(); + // The sources are folded under the result header; open them. + await page.getByText("Researched sources").click(); await expect(panel.getByRole("link").first()).toHaveAttribute("href", httpLink); const { data } = await admin.from("ai_requests").select("feature,status").eq("user_id", await user.userId()).eq("feature", "generate"); @@ -33,7 +35,7 @@ test("Factoids returns verified evidence with working citations", async ({ signe await page.goto("/app/factoids"); await page.getByLabel("Statement *").fill("Fare-free public transport increases ridership"); const response = aiResponse(page, "factoids"); - await page.getByRole("button", { name: "Find Factoids" }).click(); + await page.getByRole("button", { name: "Find factoids" }).click(); const body = await response; // The server keeps only factoids whose citation matches fetched research. @@ -49,8 +51,11 @@ test("Factoids returns verified evidence with working citations", async ({ signe test("Ask with web search shows the sources it used", async ({ signedIn: page }) => { await page.goto("/app/ask"); + // The Ask switches live in the Options popover above the message box. + await page.getByRole("button", { name: "Options" }).click(); await page.locator("#web-search-mode").click(); - await page.getByPlaceholder("Ask your debate coach...").fill("What did recent studies find about fare-free public transport ridership?"); + await page.keyboard.press("Escape"); + await page.getByPlaceholder("Ask your debate coach…").fill("What did recent studies find about fare-free public transport ridership?"); const response = aiResponse(page, "ask"); await page.getByRole("button", { name: "Send message" }).click(); const body = await response; @@ -67,10 +72,10 @@ 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("Source Ready", { exact: true })).toBeVisible({ timeout: 60_000 }); + await expect(page.getByText("Import preview", { exact: true })).toBeVisible({ timeout: 60_000 }); await page.getByRole("button", { name: "Save imported source" }).click(); await expect.poll(async () => { diff --git a/e2e/loading-performance.spec.ts b/e2e/loading-performance.spec.ts index 46c1d2a86..74b12e432 100644 --- a/e2e/loading-performance.spec.ts +++ b/e2e/loading-performance.spec.ts @@ -4,25 +4,59 @@ import { setLocale, stubSupabaseSession } from './helpers/auth'; test('a stalled Jam recorder cannot delay the real application', async ({ page, browserName }) => { test.skip(browserName === 'webkit', 'Jam recording requires MediaRecorder, which this WebKit runtime does not expose.'); await page.route('https://js.jam.dev/**', async () => new Promise(() => undefined)); - const jamRequest = page.waitForRequest('https://js.jam.dev/**'); + const jamRequests: string[] = []; + page.on('request', (request) => { + if (request.url().startsWith('https://js.jam.dev/')) jamRequests.push(request.url()); + }); await page.goto('/', { waitUntil: 'domcontentloaded' }); + // Jam waits for an interaction, so the landing page is up before any recorder request. + await expect(page.getByRole('heading', { name: /: AI debate prep and practice$/ })).toBeVisible({ timeout: 2_000 }); + expect(jamRequests).toEqual([]); + + const jamRequest = page.waitForRequest('https://js.jam.dev/**'); + await page.evaluate(() => window.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))); await jamRequest; - await expect(page.getByRole('heading', { name: /Prep Faster/ })).toBeVisible({ timeout: 2_000 }); - await expect(page.getByText('DebateLab by Pi-Stack is the official')).toHaveCount(0); + await expect(page.getByRole('heading', { name: /: AI debate prep and practice$/ })).toBeVisible(); + await expect(page.getByText('DebateOwl is the official')).toHaveCount(0); +}); + +test('pagehide removes the Jam frame and a persisted pageshow does not re-add it (synthetic events, stubbed Jam)', async ({ page, browserName }) => { + test.skip(browserName === 'webkit', 'Jam recording requires MediaRecorder, which this WebKit runtime does not expose.'); + await page.route('https://js.jam.dev/**', (route) => route.fulfill({ status: 200, contentType: 'text/javascript', body: '' })); + + await page.goto('/', { waitUntil: 'load' }); + const heading = page.getByRole('heading', { name: /: AI debate prep and practice$/ }); + await expect(heading).toBeVisible(); + const jamRequest = page.waitForRequest('https://js.jam.dev/**'); + await page.evaluate(() => window.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))); + await jamRequest; + + // Stand-in for the capture frame Jam injects; the real one is cross-origin. + const frames = await page.evaluate(() => { + const frame = document.createElement('iframe'); + frame.dataset.jamCapture = 'true'; + document.body.append(frame); + window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true })); + const afterHide = document.querySelectorAll('iframe[data-jam-capture]').length; + window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })); + return { afterHide, afterShow: document.querySelectorAll('iframe[data-jam-capture]').length }; + }); + expect(frames).toEqual({ afterHide: 0, afterShow: 0 }); + await expect(heading).toBeVisible(); }); test('public routes keep the real UI after refresh', async ({ page }) => { await setLocale(page, 'en'); for (const [path, label] of [ - ['/', /Prep Faster/], + ['/', /: AI debate prep and practice$/], ['/login', 'Continue with Google'], ] as const) { await page.goto(path); await page.reload(); await expect(page.getByRole(path === '/' ? 'heading' : 'button', { name: label })).toBeVisible(); - await expect(page.getByText('DebateLab by Pi-Stack is the official')).toHaveCount(0); + await expect(page.getByText('DebateOwl is the official')).toHaveCount(0); } }); @@ -31,14 +65,14 @@ test('authenticated routes keep the shell and current page after refresh', async await stubSupabaseSession(page); for (const [path, heading] of [ ['/app', 'Prepare'], - ['/app/generate', 'Ready to Generate'], - ['/app/attack', 'No Mercy Mode'], + ['/app/generate', 'Your case will appear here'], + ['/app/attack', 'Your critique will appear here'], ] as const) { await page.goto(path); await page.reload(); await expect(page.locator('header')).toBeVisible(); await expect(page.getByRole('heading', { name: heading, exact: true })).toBeVisible(); - await expect(page.getByText('DebateLab by Pi-Stack is the official')).toHaveCount(0); + await expect(page.getByText('DebateOwl is the official')).toHaveCount(0); } }); @@ -53,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 free trial' }).first().click(); await expect(page).toHaveURL(/\/app$/); expect(documentRequests).toBe(initialDocumentRequests); diff --git a/e2e/localization.spec.ts b/e2e/localization.spec.ts new file mode 100644 index 000000000..f4ce56ac0 --- /dev/null +++ b/e2e/localization.spec.ts @@ -0,0 +1,167 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { expect, test, type Page } from '@playwright/test'; +import { stubSupabaseSession } from './helpers/auth'; + +// T12 (IN-01, SE-01, IN-03, IN-04, IN-11, IN-14) in the browser without a +// backend: every request off this origin is stubbed, so this proves the client +// only. The verify-debatelab recipe recipes/localization.spec.ts repeats it +// against real Auth, Postgres and Edge Functions. Screenshots go to the test's +// output directory. + +const catalog = (language: string) => JSON.parse(readFileSync(`src/locales/${language}.json`, 'utf8')); +const en = catalog('en'); +const es = catalog('es'); +const hu = catalog('hu'); +const site = JSON.parse(readFileSync('src/config/site.json', 'utf8')); +const BRAND = site.brand; +const LANGUAGES = { en: 'English', es: 'Español', hu: 'Magyar' } as const; +const SWITCHER = new RegExp(`^(${[en, es, hu].map((strings) => strings.langSwitcher.label).join('|')})$`); +const brand = (text: string) => text.replace('{{brand}}', BRAND).replace('{{product}}', site.product); + +const APP_ROUTES = ['/app', '/app/generate', '/app/attack', '/app/history', '/app/settings']; +const PUBLIC_ROUTES = ['/terms', '/privacy', '/tools', '/no-such-page']; + +test.beforeEach(async ({ page }, testInfo) => { + // Phones at 375 px, the width the QA report used; desktop keeps 1280. + if (testInfo.project.name === 'mobile-chromium') await page.setViewportSize({ width: 375, height: page.viewportSize()!.height }); + // Nothing leaves this origin: empty tables, and 404 for anything else. + await page.route((url) => url.hostname !== '127.0.0.1' && url.hostname !== 'localhost', (route) => { + const request = route.request(); + if (new URL(request.url()).pathname.includes('/rest/v1/')) { + // PostgREST answers a single-row read of an empty table with 406. + if ((request.headers().accept ?? '').includes('vnd.pgrst.object')) { + return route.fulfill({ status: 406, json: { code: 'PGRST116', message: 'JSON object requested, multiple (or no) rows returned' } }); + } + return route.fulfill({ headers: { 'content-range': '*/0' }, json: [] }); + } + return route.fulfill({ status: 404, body: '' }); + }); +}); + +async function shot(page: Page, name: string) { + await page.screenshot({ path: test.info().outputPath(`${name}.png`), fullPage: true }); +} + +async function choose(page: Page, language: keyof typeof LANGUAGES) { + await page.getByRole('button', { name: SWITCHER }).first().click(); + await page.getByRole('radiogroup').first().getByRole('radio', { name: LANGUAGES[language] }).click(); + await expect(page.locator('html')).toHaveAttribute('lang', language); + await page.keyboard.press('Escape'); +} + +test('every route has its own tab title in each language, and a switch retitles it', async ({ page }) => { + // 27 full page loads; the dev server compiles each route's chunk on first use. + test.setTimeout(240_000); + await stubSupabaseSession(page); + const titles: Record<string, Record<string, string>> = {}; + for (const language of ['en', 'es', 'hu'] as const) { + await page.goto(`/app?lang=${language}`); + await expect(page.locator('html')).toHaveAttribute('lang', language); + titles[language] = {}; + for (const route of [...APP_ROUTES, ...PUBLIC_ROUTES]) { + await page.goto(route); + await expect.poll(() => page.title(), { timeout: 30_000 }).not.toBe('DebateOwl'); + titles[language][route] = await page.title(); + } + } + writeFileSync(test.info().outputPath('document-titles.json'), JSON.stringify(titles, null, 2)); + + for (const language of ['en', 'es', 'hu'] as const) { + for (const route of APP_ROUTES) expect(titles[language][route]).toMatch(/ · DebateOwl$/); + for (const route of PUBLIC_ROUTES) expect(titles[language][route]).toMatch(/ \| DebateOwl$/); + } + expect(titles.hu['/app/attack']).toBe(`${hu.nav.attack} · DebateOwl`); + expect(titles.es['/no-such-page']).toBe(brand(es.pageHead.notFound.title)); + for (const route of [...APP_ROUTES, ...PUBLIC_ROUTES]) expect(titles.hu[route], route).not.toBe(titles.en[route]); + + // No reload: the switcher retitles the page on screen. + await page.goto('/app/generate'); + await choose(page, 'en'); + await expect(page).toHaveTitle(`${en.nav.generate} · DebateOwl`); + await choose(page, 'hu'); + await expect(page).toHaveTitle(`${hu.nav.generate} · DebateOwl`); +}); + +test('an empty Attack says the case is required in the interface language, only after submit', async ({ page }) => { + await stubSupabaseSession(page); + await page.goto('/app/attack?lang=hu'); + await expect(page.getByRole('button', { name: hu.attackForm.submit })).toBeVisible(); + await expect(page.getByText(hu.validation.caseTextRequired)).toHaveCount(0); + await shot(page, '01-attack-hu-before-submit'); + + await page.getByRole('button', { name: hu.attackForm.submit }).click(); + await expect(page.getByText(hu.validation.caseTextRequired)).toBeVisible(); + await expect(page.getByText(en.validation.caseTextRequired)).toHaveCount(0); + await shot(page, '02-attack-hu-required'); + + await choose(page, 'es'); + await expect(page.getByText(es.validation.caseTextRequired)).toBeVisible(); + await shot(page, '03-attack-es-required'); +}); + +test('sign-in and not-found pages carry the language switcher', async ({ page }) => { + await page.goto('/login'); + await expect(page.getByRole('heading', { level: 1, name: en.login.title })).toBeVisible(); + await shot(page, '01-login-en'); + await choose(page, 'hu'); + await expect(page.getByRole('heading', { level: 1, name: hu.login.title })).toBeVisible(); + // "Bejelentkezés a DebateOwl-ba" already names the app, so it gets no suffix. + await expect(page).toHaveTitle(hu.login.title); + await shot(page, '02-login-hu'); + + await page.goto('/no-such-page'); + await expect(page.getByRole('heading', { level: 1, name: hu.notFound.title })).toBeVisible(); + await expect(page).toHaveTitle(brand(hu.pageHead.notFound.title)); + await shot(page, '03-not-found-hu'); + await choose(page, 'es'); + await expect(page).toHaveTitle(brand(es.pageHead.notFound.title)); + await expect(page.getByText(es.notFound.message)).toBeVisible(); + await shot(page, '04-not-found-es'); +}); + +test('?lang= opens the landing page in that language, and /hu and /es are pages of their own', async ({ page }) => { + await page.goto('/?lang=hu'); + await expect(page.locator('html')).toHaveAttribute('lang', 'hu'); + await expect(page).toHaveTitle(brand(hu.pageHead.home.title)); + await expect.poll(() => new URL(page.url()).search).toBe(''); + await shot(page, '01-landing-lang-hu'); + + await page.goto('/es'); + await expect(page.locator('html')).toHaveAttribute('lang', 'es'); + await expect(page).toHaveTitle(brand(es.pageHead.home.title)); + // Still /es after the app takes over: the address and canonical stay Spanish. + expect(new URL(page.url()).pathname).toBe('/es'); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', `${site.origin}/es`); + await shot(page, '02-landing-es'); + + await page.goto('/hu'); + await expect(page.locator('html')).toHaveAttribute('lang', 'hu'); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', `${site.origin}/hu`); + // The choice is kept, not just shown once: Start leads into the app in Hungarian. + await page.getByRole('link', { name: hu.nav.startTrial }).first().click(); + await expect.poll(() => new URL(page.url()).pathname).not.toBe('/hu'); + await expect(page.locator('html')).toHaveAttribute('lang', 'hu'); + await page.reload(); + await expect(page.locator('html')).toHaveAttribute('lang', 'hu'); + await shot(page, '03-start-from-hu'); +}); + +test('a server refusal with a code reads in the interface language', async ({ page }) => { + await stubSupabaseSession(page); + // The account is suspended: the ai function answers 403 with its English reason and a code. + await page.route((url) => url.pathname.endsWith('/functions/v1/ai'), (route) => route.fulfill({ + status: 403, + json: { error: 'Your account has been suspended. Please contact support.', code: 'banned' }, + })); + await page.goto('/app/attack?lang=hu'); + await page.getByRole('textbox', { name: new RegExp(hu.attackForm.argument) }).fill('A tömegközlekedés legyen ingyenes, mert csökkenti a forgalmat.'); + await page.getByRole('button', { name: hu.attackForm.submit }).click(); + // Without a Current Debate, the tool may first ask whether to start one. + const once = page.getByRole('button', { name: hu.debate.offerNotNow }); + const refusal = page.getByText(hu.serverErrors.banned); + await expect(once.or(refusal).first()).toBeVisible(); + if (await once.isVisible()) await once.click(); + await expect(refusal).toBeVisible(); + await expect(page.getByText('Your account has been suspended.')).toHaveCount(0); + await shot(page, '01-attack-refused-hu'); +}); diff --git a/e2e/login.spec.ts b/e2e/login.spec.ts index 659677ba1..7af8d8f1a 100644 --- a/e2e/login.spec.ts +++ b/e2e/login.spec.ts @@ -11,7 +11,8 @@ test.describe('login passwordless flows', { tag: '@smoke' }, () => { await page.goto('/login', { waitUntil: 'domcontentloaded' }); const googleButton = page.getByRole('button', { name: 'Continue with Google' }); - const expectedRedirect = `${new URL(page.url()).origin}/app`; + // Google returns to the callback route with this sign-in's own PKCE flow id. + const expectedRedirect = new RegExp(`^${new URL(page.url()).origin.replace(/[.]/g, '\\.')}/auth/callback\\?sb_flow_id=[0-9a-f]{32}$`); await expect(googleButton).toBeVisible(); await expect(page.getByRole('tab', { name: 'Email' })).toBeVisible(); await expect(page.getByRole('tab', { name: 'Demo code' })).toBeVisible(); @@ -20,7 +21,7 @@ test.describe('login passwordless flows', { tag: '@smoke' }, () => { await googleButton.click(); const requestUrl = new URL((await requestPromise).url()); expect(requestUrl.searchParams.get('provider')).toBe('google'); - expect(requestUrl.searchParams.get('redirect_to')).toBe(expectedRedirect); + expect(requestUrl.searchParams.get('redirect_to')).toMatch(expectedRedirect); }); test('exchanges a demo-code magic-link hash for a Supabase session', async ({ page }) => { @@ -55,9 +56,12 @@ test.describe('login passwordless flows', { tag: '@smoke' }, () => { test('sends an email code and focuses the verification field', async ({ page }) => { let requestEmail: string | undefined; + let redirectTo: string | null = null; let releaseOtpResponse: (() => void) | undefined; const otpResponseReleased = new Promise<void>((resolve) => { releaseOtpResponse = resolve; }); - await page.route('**/auth/v1/otp', async (route) => { + // The request carries `?redirect_to=`, the callback the emailed link opens. + await page.route('**/auth/v1/otp**', async (route) => { + redirectTo = new URL(route.request().url()).searchParams.get('redirect_to'); const body = route.request().postDataJSON() as { email?: unknown }; requestEmail = typeof body.email === 'string' ? body.email : undefined; await otpResponseReleased; @@ -78,6 +82,7 @@ test.describe('login passwordless flows', { tag: '@smoke' }, () => { await expect(page.getByText('Code sent', { exact: true })).toBeVisible(); await expect(page.getByRole('textbox', { name: 'Verification code' })).toBeFocused(); expect(requestEmail).toBe('student@example.com'); + expect(redirectTo).toMatch(/\/auth\/callback\?sb_flow_id=[0-9a-f]{32}$/); }); test('shows a demo-code backend error while keeping the form editable', async ({ page }) => { diff --git a/e2e/model-matrix/generate.spec.ts b/e2e/model-matrix/generate.spec.ts index 87e63171c..17a2084c6 100644 --- a/e2e/model-matrix/generate.spec.ts +++ b/e2e/model-matrix/generate.spec.ts @@ -13,9 +13,9 @@ for (const model of MODEL_CATALOG) { await page.goto("/app/generate"); await page.getByRole("combobox", { name: "Select AI model for generate" }).click(); await page.getByRole("option", { name: new RegExp(`^${model.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`) }).click(); - await page.getByRole("textbox", { name: "Motion / Topic *" }).fill("This House would make public transport free at the point of use"); + await page.getByRole("textbox", { name: "Motion / topic *" }).fill("This House would make public transport free at the point of use"); const response = aiResponse(page, "generate"); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await 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/navigation-performance.spec.ts b/e2e/navigation-performance.spec.ts index f6f291100..4afcc650e 100644 --- a/e2e/navigation-performance.spec.ts +++ b/e2e/navigation-performance.spec.ts @@ -21,12 +21,12 @@ const publicRoutes = [ ] as const; const privateRoutes = [ - ['/app', { selector: 'main h1', text: 'DebateLab' }], + ['/app', { selector: 'main h1', text: 'What are you working on?' }], ['/app/generate', { selector: 'main h1', text: 'Generate' }], ['/app/attack', { selector: 'main h1', text: 'Attack' }], - ['/app/flowchart', { selector: 'main h3', text: 'Argument Flowchart' }], + ['/app/flowchart', { selector: 'main h1', text: 'Argument flowchart' }], ['/app/history', { selector: 'main h1', text: 'History' }], - ['/app/admin', { selector: 'main h3', text: 'Access Denied' }], + ['/app/admin', { selector: 'main h1', text: 'Access Denied' }], ] as const; const attackContent: ContentMarker = { selector: 'main h1', text: 'Attack' }; @@ -49,17 +49,16 @@ async function skipOnboarding(page: Page) { }); } -// Tools open from Home; the header carries only Home, Current debate, Library and History. +// Tools open from the Tools page; the header carries only Tools, Current debate, Library and History. function homeToolLink(page: Page, tool: string) { return page.locator(`main [data-tour="mode-${tool}"]`); } async function openHome(page: Page) { - if ((page.viewportSize()?.width ?? 1280) < 1024) { - await page.getByRole('button', { name: 'Open menu' }).click(); - await page.getByRole('navigation', { name: 'Mobile navigation' }).getByRole('link', { name: /^Home/ }).click(); + if ((page.viewportSize()?.width ?? 1280) < 768) { + await page.getByRole('navigation', { name: 'Mobile navigation' }).getByRole('link', { name: 'Tools', exact: true }).click(); } else { - await page.getByRole('navigation', { name: 'Main navigation' }).getByRole('link', { name: 'Home' }).click(); + await page.getByRole('navigation', { name: 'Main navigation' }).getByRole('link', { name: 'Tools' }).click(); } await expect(page).toHaveURL(/\/app$/); } @@ -91,7 +90,7 @@ test('workspaces stay unavailable without a collaboration endpoint', async ({ pa await setLocale(page, 'en'); await stubSupabaseSession(page); await page.goto('/app', { waitUntil: 'domcontentloaded' }); - await waitForContent(page, { selector: 'main h1', text: 'DebateLab' }); + await waitForContent(page, { selector: 'main h1', text: 'What are you working on?' }); await expect(page.locator('a[href="/app/workspaces"]')).toHaveCount(0); await page.goto('/app/workspaces', { waitUntil: 'domcontentloaded' }); @@ -119,7 +118,7 @@ test('a delayed destination chunk exposes the route fallback without replacing t await stubSupabaseSession(page); await skipOnboarding(page); await page.goto('/app', { waitUntil: 'domcontentloaded' }); - await waitForContent(page, { selector: 'main h1', text: 'DebateLab' }); + await waitForContent(page, { selector: 'main h1', text: 'What are you working on?' }); await page.evaluate(() => { window.__baselineAppLayout = document.querySelector('header')?.parentElement; @@ -187,14 +186,14 @@ test('a failed route chunk recovers after the offered reload', async ({ page }) await skipOnboarding(page); await page.goto('/app', { waitUntil: 'domcontentloaded' }); - await waitForContent(page, { selector: 'main h1', text: 'DebateLab' }); + await waitForContent(page, { selector: 'main h1', text: 'What are you working on?' }); await homeToolLink(page, 'attack').click(); await expect(page.getByRole('heading', { name: 'Unable to load this page' })).toBeVisible(); await page.getByRole('button', { name: 'Reload' }).click(); await expect.poll(() => attempts).toBe(2); await waitForContent(page, attackContent); - await expect(page.locator('header')).toBeVisible(); + await expect(page.getByRole('banner')).toBeVisible(); }); async function gotoHome(page: Page) { @@ -202,7 +201,7 @@ async function gotoHome(page: Page) { await stubSupabaseSession(page); await skipOnboarding(page); await page.goto('/app', { waitUntil: 'domcontentloaded' }); - await waitForContent(page, { selector: 'main h1', text: 'DebateLab' }); + await waitForContent(page, { selector: 'main h1', text: 'What are you working on?' }); } async function attackLink(page: Page) { @@ -366,14 +365,13 @@ test('subscription reads stay cached across sibling tool routes', async ({ page await route.fulfill({ json: { announcements: [] } }); return; } - if (url.pathname.endsWith('/rest/v1/rpc/effective_entitlement')) { - // The plan; the settings store reads it too, to pick default models. - subscriptionRequests.push('effective_entitlement'); - await route.fulfill({ json: [{ tier: 'trial', daily_limit: 5, can_access_pro_models: false }] }); - return; - } if (url.pathname.includes('/rest/v1/')) { - if (url.pathname.endsWith('/ai_requests')) { + const select = url.searchParams.get('select') ?? ''; + if (url.pathname.endsWith('/user_settings') && select.includes('subscription_tier')) { + subscriptionRequests.push('user_settings'); + } else if (url.pathname.endsWith('/subscriptions')) { + subscriptionRequests.push('subscriptions'); + } else if (url.pathname.endsWith('/ai_requests')) { subscriptionRequests.push('ai_requests'); } await route.fulfill({ @@ -390,7 +388,6 @@ test('subscription reads stay cached across sibling tool routes', async ({ page await page.goto('/app/generate'); await waitForContent(page, { selector: 'main h1', text: 'Generate' }); await expect.poll(() => subscriptionRequests.filter((name) => name === 'ai_requests').length).toBe(1); - const readsBeforeNavigation = [...subscriptionRequests]; await openHome(page); await homeToolLink(page, 'attack').click(); @@ -398,6 +395,5 @@ test('subscription reads stay cached across sibling tool routes', async ({ page // eslint-disable-next-line no-restricted-properties -- observation window: no extra subscription request may follow. await page.waitForTimeout(100); - expect(readsBeforeNavigation).toContain('effective_entitlement'); - expect(subscriptionRequests).toEqual(readsBeforeNavigation); + expect(subscriptionRequests).toEqual(['user_settings', 'ai_requests']); }); diff --git a/e2e/production/canary.spec.ts b/e2e/production/canary.spec.ts index b036e8c15..56347bd43 100644 --- a/e2e/production/canary.spec.ts +++ b/e2e/production/canary.spec.ts @@ -1,5 +1,6 @@ import { createClient } from "@supabase/supabase-js"; import { expect, test, type Page } from "@playwright/test"; +import { readAIResponse } from "../helpers/ai-response"; // Production canary. Everything here is read-only except the last two tests, // which make real AI requests as the dedicated canary account and run only on @@ -29,20 +30,27 @@ 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); } -// GPT-5.6 Luna is the Basic default, so this is the path most users take. -async function pickLuna(page: Page, feature: string) { +// Claude Haiku 5.5 is the Basic default, so this is the path most users take. +async function pickBasicDefault(page: Page, feature: string) { await page.getByRole("combobox", { name: `Select AI model for ${feature}` }).click(); - await page.getByRole("option", { name: /GPT-5\.6 Luna/ }).first().click(); + await page.getByRole("option", { name: /Claude Haiku 5\.5/ }).first().click(); } test("the production site loads and guards the app", async ({ page, baseURL }) => { + // The deploy hook can run while the previous HTML is still what the edge serves. + // The title still has to become DebateOwl; this only waits that window out. + test.setTimeout(200_000); const errors: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); - await page.goto("/"); - await expect(page).toHaveTitle(/DebateLab/); + await expect(async () => { + errors.length = 0; + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expect(page).toHaveTitle(/DebateOwl/); + }).toPass({ timeout: 3 * 60 * 1000, intervals: [1_000, 5_000, 10_000, 15_000] }); await page.goto("/login"); await expect(page.getByRole("button", { name: "Continue with Google" })).toBeVisible(); await page.goto("/app/generate"); @@ -81,7 +89,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(); }); @@ -89,8 +97,8 @@ test("the canary account signs in and loads its account", async ({ page }) => { test("the canary account gets a streamed answer from a real model", async ({ page }) => { await signInAsCanary(page, { aiRequest: true }); await page.goto("/app/ask"); - await pickLuna(page, "ask"); - await page.getByPlaceholder("Ask your debate coach...").fill("Reply with one short sentence: what is a rebuttal?"); + await pickBasicDefault(page, "ask"); + await page.getByPlaceholder("Ask your debate coach").fill("Reply with one short sentence: what is a rebuttal?"); const stream = page.waitForResponse((response) => response.url().endsWith("/functions/v1/ai-stream"), { timeout: 90_000 }); await page.getByRole("button", { name: "Send message" }).click(); const response = await stream; @@ -111,16 +119,15 @@ test("the canary account generates a case", async ({ page }) => { test.setTimeout(180_000); await signInAsCanary(page, { aiRequest: true }); await page.goto("/app/generate"); - await pickLuna(page, "generate"); - await page.getByRole("textbox", { name: "Motion / Topic *" }).fill("This House would make public transport free"); + 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)$/ }) .click(); - const response = await generated; - expect(response.status(), "ai generate status").toBe(200); - const body = await response.json() as { output: { contentions?: unknown[] } }; + const { status, body } = await readAIResponse(await generated); + expect(status, "ai generate status").toBe(200); expect(body.output.contentions, "generated contentions").toHaveLength(3); }); diff --git a/e2e/quality-backlog.spec.ts b/e2e/quality-backlog.spec.ts index 4154a0a7d..542e8c911 100644 --- a/e2e/quality-backlog.spec.ts +++ b/e2e/quality-backlog.spec.ts @@ -83,23 +83,79 @@ test('Factoids names the save action with its claim', async ({ page }) => { })).toBeVisible(); }); -test('Flowchart confirms selected-node deletion and reports success', async ({ page }, testInfo) => { +test('Flowchart deletes a selected node at once and Undo puts it back', async ({ page }, testInfo) => { await setLocale(page, 'en'); await stubSupabaseSession(page); await page.goto('/app/flowchart'); await page.getByText('Argument 1', { exact: true }).click(); - await page.getByRole('button', { name: 'Delete' }).last().click(); - await expect(page.getByRole('alertdialog', { name: 'Delete node?' })).toBeVisible(); - const confirm = page.getByRole('button', { name: 'Delete node' }); + const remove = page.getByRole('button', { name: 'Delete' }).last(); if (testInfo.project.name === 'mobile-chromium') { - const box = await confirm.boundingBox(); + const box = await remove.boundingBox(); expect(box).not.toBeNull(); await page.touchscreen.tap(box!.x + box!.width / 2, box!.y + box!.height / 2); } else { - await confirm.click(); + await remove.click(); } - await expect(page.getByText('Node deleted', { exact: true })).toBeVisible(); + // No confirmation: the node goes, and the toast offers Undo. + await expect(page.getByRole('alertdialog')).toHaveCount(0); await expect(page.getByText('Argument 1', { exact: true })).toHaveCount(0); + await page.getByRole('button', { name: 'Undo' }).click(); + await expect(page.getByText('Argument 1', { exact: true })).toHaveCount(1); +}); + +// VS-05: a long motion and long item titles once widened the dashboard past a phone screen. +for (const locale of locales) { + test(`Current Debate fits a 360px phone with sources attached in ${locale}`, { tag: '@smoke' }, async ({ page }) => { + const motion = 'This House believes that national governments should nationalise all critical digital infrastructure including undersea cables'; + await page.setViewportSize({ width: 360, height: 780 }); + await setLocale(page, locale); + await stubSupabaseSession(page); + await seedCurrentDebate(page, motion); + await page.addInitScript(({ motion }) => { + const debateId = '00000000-0000-4000-8000-00000000d001'; + const now = Date.now(); + const sources = [0, 1, 2].map((index) => ({ + id: `source-${index}`, title: `${motion} source ${index}`, content: 'Evidence', type: 'text', + debateId, createdAt: now - index, isActive: true, folder: 'General', + })); + const savedCases = [0, 1].map((index) => ({ + id: `case-${index}`, title: `${motion} case ${index}`, data: { motion }, createdAt: now - index, + debateId, side: index ? 'con' : 'pro', + })); + window.localStorage.setItem('debate-trainer-storage', JSON.stringify({ + state: { ownerUserId: '00000000-0000-0000-0000-000000000001', sourceLibrary: sources, savedCases, sourceFolders: ['General'] }, + version: 1, + })); + }, { motion }); + + await page.goto('/app/debate'); + await expect(page.getByTestId('debate-sources').getByText(`${motion} source 0`)).toBeVisible(); + // The Load button stays on screen beside its truncated title. + const load = await page.getByTestId('debate-side-pro').getByRole('button', { name: `${motion} case 0` }).boundingBox(); + expect(load).not.toBeNull(); + expect(load!.x + load!.width).toBeLessThanOrEqual(360); + + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(360); + }); +} + +// VS-09: picking a language closes the menu; the choice already persists. +test('language menu closes once a language is chosen', { tag: '@smoke' }, async ({ page }) => { + await setLocale(page, 'en'); + await page.goto('/'); + // Below lg the section links and the language control sit in the menu. + const language = page.getByRole('button', { name: 'Language' }); + const openMenu = page.getByRole('button', { name: 'Open menu' }); + await expect(language.or(openMenu).first()).toBeVisible(); + if (await openMenu.isVisible()) await openMenu.click(); + await language.click(); + const menu = page.getByRole('radiogroup', { name: 'Interface language' }); + await expect(menu).toBeVisible(); + + await menu.getByRole('radio', { name: 'Magyar' }).click(); + + await expect(page.getByRole('radiogroup')).toHaveCount(0); + await expect(page.locator('html')).toHaveAttribute('lang', 'hu'); }); 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/sparring.spec.ts b/e2e/sparring.spec.ts index 8facd239c..e69bcac41 100644 --- a/e2e/sparring.spec.ts +++ b/e2e/sparring.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { setLocale, stubSupabaseSession } from './helpers/auth'; +import { fulfillQuotaCheck } from './helpers/quota'; const corsHeaders = { 'access-control-allow-origin': '*', @@ -50,6 +51,7 @@ test('completes an LD sparring round through mocked opponent turns and judging', await route.fulfill({ status: 200, headers: corsHeaders, body: 'ok' }); return; } + if (await fulfillQuotaCheck(route, corsHeaders)) return; const body = route.request().postDataJSON() as { input?: { action?: string } }; const action = body.input?.action ?? 'unknown'; @@ -83,7 +85,9 @@ test('completes an LD sparring round through mocked opponent turns and judging', await draft.fill(content); await page.getByRole('button', { name: 'Submit speech' }).click(); if (turnLabel !== 'Second affirmative rebuttal') { - await expect(page.getByText(`Opponent response ${index + 1}:`)).toBeVisible(); + // The reply lands in the transcript and is pinned above the composer for the next speech. + await expect(page.getByRole('region', { name: 'Transcript' }).getByText(`Opponent response ${index + 1}:`)).toBeVisible(); + await expect(page.getByRole('article', { name: 'Opponent’s last speech' })).toContainText(`Opponent response ${index + 1}:`); } } @@ -104,6 +108,7 @@ test('uses the school 4-6-6-3 roles and coaching in the default round', async ({ await route.fulfill({ status: 200, headers: corsHeaders, body: 'ok' }); return; } + if (await fulfillQuotaCheck(route, corsHeaders)) return; const body = route.request().postDataJSON() as { input?: { action?: string } }; actions.push(body.input?.action ?? 'unknown'); await route.fulfill({ status: 200, headers: corsHeaders, body: JSON.stringify({ output: opponentSpeech(1) }) }); @@ -122,7 +127,7 @@ test('uses the school 4-6-6-3 roles and coaching in the default round', async ({ await introductor.fill('Our framework is civic responsibility. We define service as supervised work that directly benefits the local community.'); await page.getByRole('button', { name: 'Submit speech' }).click(); - await expect(page.getByText('Opponent response 1:')).toBeVisible(); + await expect(page.getByRole('region', { name: 'Transcript' }).getByText('Opponent response 1:')).toBeVisible(); await expect(page.getByRole('textbox', { name: 'Draft for Pro R1' })).toBeVisible(); await expect(page.getByText(/Build the core arguments with claims, warrants, support, and impacts/)).toBeVisible(); expect(actions).toEqual(['respond']); diff --git a/e2e/staging-switch.spec.ts b/e2e/staging-switch.spec.ts index 9f9d7ebee..1471350a2 100644 --- a/e2e/staging-switch.spec.ts +++ b/e2e/staging-switch.spec.ts @@ -30,7 +30,7 @@ for (const path of ['/', '/login']) { const navBox = await page.locator('nav.fixed').boundingBox(); expect(navBox!.y).toBeGreaterThanOrEqual(stripBox!.y + stripBox!.height - 1); } - await strip.getByRole('button', { name: 'Switch to Production' }).click({ timeout: 5_000 }); + await strip.getByRole('button', { name: 'Switch to production' }).click({ timeout: 5_000 }); await expect(strip).toHaveCount(0); expect(await page.evaluate(() => localStorage.getItem('debate-supabase-env'))).toBeNull(); @@ -52,7 +52,7 @@ test('switching in one tab reloads the other tabs onto the same project', async const stripIn = (page: typeof other) => page.getByRole('status').filter({ hasText: 'Staging backend' }); await expect(stripIn(other)).toBeVisible(); - await stripIn(switching).getByRole('button', { name: 'Switch to Production' }).click(); + await stripIn(switching).getByRole('button', { name: 'Switch to production' }).click(); await expect(stripIn(switching)).toHaveCount(0); await expect(stripIn(other)).toHaveCount(0); diff --git a/e2e/staging/authed-flows.spec.ts b/e2e/staging/authed-flows.spec.ts index 918515267..0dd7c9a89 100644 --- a/e2e/staging/authed-flows.spec.ts +++ b/e2e/staging/authed-flows.spec.ts @@ -1,5 +1,6 @@ import { createClient } from '@supabase/supabase-js'; import { expect, test, type Page } from '@playwright/test'; +import { readAIResponse } from '../helpers/ai-response'; test.describe.configure({ mode: 'serial' }); // A failed action throws well inside the test timeout, so the cleanup in `finally` still runs. @@ -93,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); @@ -113,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) => @@ -148,13 +149,16 @@ test('real staging session persists, starts a debate, writes and removes a sourc await step(page, 'complete one AI request in Ask', async () => { await page.goto('/app/ask'); - await page.getByLabel('Pros/Cons').click(); - await page.getByPlaceholder('Ask your debate coach...').fill('Reply with one short sentence confirming this staging request.'); + // Ask's modes live behind the Options popover since the design shell. + await page.getByRole('button', { name: 'Options' }).click(); + await page.getByRole('switch', { name: 'Pros/cons' }).click(); + await page.keyboard.press('Escape'); + await page.getByPlaceholder('Ask your debate coach').fill('Reply with one short sentence confirming this staging request.'); const aiResponse = page.waitForResponse((response) => response.request().method() === 'POST' && response.url().includes('/functions/v1/ai'), { timeout: 90_000 }); await page.getByRole('button', { name: 'Send message' }).click(); - expect((await aiResponse).ok()).toBe(true); + expect((await readAIResponse(await aiResponse)).status).toBe(200); const saveResponseButton = page.getByRole('button', { name: 'Save message' }).last(); await expect(saveResponseButton).toBeAttached({ timeout: 90_000 }); const assistantBubble = saveResponseButton.locator('..'); diff --git a/e2e/startup-shell.spec.ts b/e2e/startup-shell.spec.ts index 4fd7f0461..5f6a4fd41 100644 --- a/e2e/startup-shell.spec.ts +++ b/e2e/startup-shell.spec.ts @@ -16,7 +16,7 @@ test('shows a dark private startup shell while the entry module is delayed', { t await page.goto('/app', { waitUntil: 'commit' }); await expect(page.locator('[data-startup-shell]')).toBeVisible(); - await expect(page.locator('[data-startup-status]')).toHaveText('Loading DebateLab'); + await expect(page.locator('[data-startup-status]')).toHaveText('Loading DebateOwl'); await expect.poll(() => page.evaluate(() => ({ appShell: document.documentElement.classList.contains('app-shell'), dark: document.documentElement.classList.contains('dark'), @@ -37,7 +37,7 @@ test('mounts the app before Jam recorder modules resolve', { tag: '@smoke' }, as }); await page.goto('/app', { waitUntil: 'domcontentloaded' }); - await expect(page.getByRole('main').getByRole('heading', { name: 'DebateLab', exact: true, level: 1 })).toBeVisible({ timeout: 5_000 }); + await expect(page.getByRole('main').getByRole('heading', { name: 'What are you working on?', exact: true, level: 1 })).toBeVisible({ timeout: 5_000 }); expect(await page.evaluate(() => { const jam = (window as Window & { __jam__?: { exports?: { metadata?: unknown } } }).__jam__; return typeof jam?.exports?.metadata === 'function'; @@ -49,6 +49,6 @@ test('shows a recoverable message when the static main entry fails', { tag: '@sm await page.goto('/app', { waitUntil: 'domcontentloaded' }); await expect(page.locator('[data-bootstrap-error]')).toBeVisible(); - await expect(page.getByRole('heading', { name: 'Unable to load DebateLab' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Unable to load DebateOwl' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Reload' })).toBeEnabled(); }); diff --git a/e2e/voice-rounds.spec.ts b/e2e/voice-rounds.spec.ts index 6912fd813..08d039c33 100644 --- a/e2e/voice-rounds.spec.ts +++ b/e2e/voice-rounds.spec.ts @@ -1,5 +1,6 @@ import { expect, test, type Page, type Route } from '@playwright/test'; import { setLocale, stubSupabaseSession } from './helpers/auth'; +import { fulfillQuotaCheck } from './helpers/quota'; import { say, spokenByBrowser, stubSpeech } from './helpers/voice'; // Voice rounds without a microphone, a speech service, or an ElevenLabs key. @@ -81,6 +82,7 @@ async function routeSparring(page: Page, bodies: SparringBody[]) { await route.fulfill({ status: 200, headers: corsHeaders, body: 'ok' }); return; } + if (await fulfillQuotaCheck(route, corsHeaders)) return; const body = route.request().postDataJSON() as SparringBody; bodies.push(body); if (body.input.action === 'score') { @@ -108,7 +110,7 @@ async function startVoiceSparring(page: Page) { await page.getByTestId('voice-mode').click(); const notice = page.getByTestId('voice-notice'); - await expect(notice).toContainText('DebateLab never records or stores your audio.'); + await expect(notice).toContainText('DebateOwl never records or stores your audio.'); await expect(notice).toContainText('In Chrome, that sends your audio to Google'); await expect(notice).toContainText('sent as text to ElevenLabs'); await page.getByRole('button', { name: 'Turn on voice' }).click(); @@ -144,14 +146,14 @@ test('a spoken sparring round works on the browser voice when the server has no await startVoiceSparring(page); await speakSpeech(page, 'Affirmative constructive', 'Um, universal access removes the price barrier for every rider.'); - await expect(page.getByText('Opponent reply 1:')).toBeVisible(); + await expect(page.getByRole('region', { name: 'Transcript' }).getByText('Opponent reply 1:')).toBeVisible(); // The browser voice reads the reply, without the audio tag. await expect.poll(() => spokenByBrowser(page)).toBe('Opponent reply 1: a narrower pass reaches the same riders for less.'); expect(voiceRequests).toHaveLength(1); expect(voiceRequests[0]).toEqual({ feature: 'sparring', text: '[sighs] Opponent reply 1: a narrower pass reaches the same riders for less.', token: 'token-1' }); await speakSpeech(page, 'First affirmative rebuttal', 'A pass still leaves an eligibility gap, you know.'); - await expect(page.getByText('Opponent reply 2:')).toBeVisible(); + await expect(page.getByRole('region', { name: 'Transcript' }).getByText('Opponent reply 2:')).toBeVisible(); await expect.poll(() => spokenByBrowser(page)).toContain('Opponent reply 2:'); // After the missing-key answer the page does not ask the voice function again. expect(voiceRequests).toHaveLength(1); @@ -193,6 +195,7 @@ test('a spoken Crossfire exchange plays ElevenLabs audio through the voice funct await route.fulfill({ status: 200, headers: corsHeaders, body: 'ok' }); return; } + if (await fulfillQuotaCheck(route, corsHeaders)) return; const body = route.request().postDataJSON() as { voice?: boolean; input: { transcript: Array<Record<string, unknown>> } }; aiVoiceFlags.push(body.voice); // The server accepts only the turn fields it knows; measurements stay in the browser. @@ -252,8 +255,8 @@ test('voice mode is hidden with a reason where the browser cannot recognise spee }); for (const [locale, label, noticeLine, privacyLine] of [ - ['es', 'Ronda con voz', 'DebateLab nunca graba ni guarda tu audio.', 'ElevenLabs para la voz del oponente'], - ['hu', 'Hangos kör', 'A DebateLab soha nem rögzíti és nem tárolja a hangodat.', 'az ElevenLabsszel az ellenfél hangjához'], + ['es', 'Ronda con voz', 'DebateOwl nunca graba ni guarda tu audio.', 'ElevenLabs para la voz del oponente'], + ['hu', 'Hangos kör', 'A DebateOwl soha nem rögzíti és nem tárolja a hangodat.', 'az ElevenLabsszel az ellenfél hangjához'], ] as const) { test(`the voice notice and the privacy page are translated (${locale})`, async ({ page }) => { await setLocale(page, locale); diff --git a/embed/debate-timer/index.html b/embed/debate-timer/index.html new file mode 100644 index 000000000..de1cc7e65 --- /dev/null +++ b/embed/debate-timer/index.html @@ -0,0 +1,22 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <!-- The embeddable timer: framed on other sites, so it loads only its own + bundle (no app shell, analytics, recorder or service worker) and stays + out of search results in favour of the tool page. --> + <title>Debate timer | DebateOwl + + + + + + + +
+ + + diff --git a/eslint.config.js b/eslint.config.js index 4d719f838..794acde1c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -4,6 +4,12 @@ import reactHooks from "eslint-plugin-react-hooks"; import reactRefresh from "eslint-plugin-react-refresh"; import tseslint from "typescript-eslint"; +// The AI core and feature files behind src/lib/ai/client.ts, by alias or +// relative path, with or without an extension. The server's same-named +// modules under supabase/functions/ai/ stay importable. +const aiInternalImport = String.raw`^(?!.*supabase/functions/)(.*/)?ai/(request|errors|sources|text|features(/.*)?)(\.[cm]?[jt]sx?)?/?$`; +const aiInternalMessage = "Import AI requests and errors from @/lib/ai/client."; + export default tseslint.config( { ignores: [ @@ -116,6 +122,26 @@ export default tseslint.config( ], }, }, + { + // App code imports AI requests and errors through src/lib/ai/client.ts + // only. The core and feature files behind it are internal. + files: ["src/**/*.{ts,tsx}", "e2e/**/*.ts", "agentic/**/*.ts"], + ignores: ["src/lib/ai/**"], + rules: { + "no-restricted-imports": [ + "error", + { patterns: [{ regex: aiInternalImport, message: aiInternalMessage }] }, + ], + "no-restricted-syntax": [ + "error", + { + // esquery ends a regex at "/", so the pattern spells it as \x2F. + selector: `ImportExpression[source.value=/${aiInternalImport.replaceAll("/", "\\x2F")}/]`, + message: aiInternalMessage, + }, + ], + }, + }, { // Fixed sleeps pass or fail with machine speed. Wait for a condition. files: ["e2e/**/*.ts", "agentic/**/*.ts", ".claude/skills/verify-debatelab/**/*.ts"], diff --git a/index.html b/index.html index 9d35cd96c..45399ba7b 100644 --- a/index.html +++ b/index.html @@ -6,78 +6,46 @@ - DebateLab by Pi-Stack | AI Debate Prep, Cases & Rebuttals - - - - - - - - - + + DebateOwl + + + + + + + + + + + - - - - - + + + + + + + - - - - + + + +
-
+
- DebateLab -

Loading DebateLab

+ + DebateOwl +

Loading DebateOwl