diff --git a/.claude/skills/e2e/LICENSE b/.claude/skills/e2e/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/.claude/skills/e2e/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/.claude/skills/e2e/NOTICE b/.claude/skills/e2e/NOTICE new file mode 100644 index 000000000..6f9b5e675 --- /dev/null +++ b/.claude/skills/e2e/NOTICE @@ -0,0 +1,5 @@ +e2e +Copyright 2026 TesterArmy, Inc. + +This product includes software developed at TesterArmy, Inc. +(https://tester.army). diff --git a/.github/workflows/agentic-e2e.yml b/.github/workflows/agentic-e2e.yml index fa8638031..41c67b842 100644 --- a/.github/workflows/agentic-e2e.yml +++ b/.github/workflows/agentic-e2e.yml @@ -42,7 +42,7 @@ env: jobs: suite: - if: github.event.schedule != '13 5 * * 0' && (github.event_name != 'workflow_dispatch' || inputs.explore == '') + if: github.repository == 'Programcode/DebateOwl' && github.event.schedule != '13 5 * * 0' && (github.event_name != 'workflow_dispatch' || inputs.explore == '') runs-on: ubuntu-latest timeout-minutes: 45 # Four legs keep each well under the timeout: the page sweep makes about @@ -125,7 +125,7 @@ jobs: # The weekly bug bash explores with every persona; a manual run explores once. plan-explore: - if: github.event.schedule == '13 5 * * 0' || (github.event_name == 'workflow_dispatch' && inputs.explore != '') + if: github.repository == 'Programcode/DebateOwl' && (github.event.schedule == '13 5 * * 0' || (github.event_name == 'workflow_dispatch' && inputs.explore != '')) runs-on: ubuntu-latest outputs: legs: ${{ steps.plan.outputs.legs }} diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index 1046ef56b..279573711 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -19,6 +19,8 @@ concurrency: jobs: live-e2e: + # Forks have none of the secrets this job needs. + if: github.repository == 'Programcode/DebateOwl' runs-on: ubuntu-latest timeout-minutes: 45 steps: diff --git a/.github/workflows/model-matrix.yml b/.github/workflows/model-matrix.yml index 83fc40161..a532ba23b 100644 --- a/.github/workflows/model-matrix.yml +++ b/.github/workflows/model-matrix.yml @@ -19,6 +19,8 @@ concurrency: jobs: model-matrix: + # Forks have none of the secrets this job needs. + if: github.repository == 'Programcode/DebateOwl' runs-on: ubuntu-latest timeout-minutes: 45 steps: diff --git a/.github/workflows/prod-canary.yml b/.github/workflows/prod-canary.yml index 6d04d03b6..cf410ac57 100644 --- a/.github/workflows/prod-canary.yml +++ b/.github/workflows/prod-canary.yml @@ -23,6 +23,8 @@ concurrency: jobs: canary: + # Forks have none of the secrets this job needs. + if: github.repository == 'Programcode/DebateOwl' runs-on: ubuntu-latest timeout-minutes: 20 steps: diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..39cffa0f0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,112 @@ +DebateOwl +Copyright (c) 2026 Ádám Gáborjáni. All rights not expressly granted below are reserved. + +Required Notice: Copyright (c) 2026 Ádám Gáborjáni (https://debateowl.com) + +DebateOwl is source-available software, not open-source software. The licensor +offers it only under the PolyForm Strict License 1.0.0, reproduced in full +below, as narrowed by the Additional Limitations in this section. The +Additional Limitations are conditions of every license granted for the +software. Where they and the PolyForm Strict License differ, the Additional +Limitations control. Nothing in them limits any right you have under the +"Fair Use" section or under applicable law. + +## Additional Limitations + +1. Personal use only. The only permitted purpose is personal, noncommercial + use by an individual, running the software on hardware that individual + controls, for that individual's own benefit. The "Noncommercial + Organizations" section of the PolyForm Strict License does not apply. + +2. No hosting or service. You may not make the software, or any part of its + functionality, available to anyone other than yourself over a network. That + includes a public website, a hosted or shared instance, software as a + service, or an API, whether free or paid and whether open to the public or + to a limited group such as a class, club, school, team, or customers. + +3. No sale. You may not sell, rent, lease, sublicense, or otherwise charge for + the software, for access to it, or for anything that includes it. + +4. No redistribution. You may not distribute, publish, mirror, or share copies + of the software or any part of it, changed or unchanged, except as the terms + of service of the platform hosting the licensor's repository require. + +5. No changes or rebranding. You may not make changes or new works based on + the software, including any copy, version, or derivative that removes, + replaces, or alters the DebateOwl name, logo, or branding. + +6. Names and logos. These terms grant no trademark rights. The name + "DebateOwl", the owl logo and mascot, and the debateowl.com domain are + reserved by the licensor. You may not use them, or anything + confusingly similar, to name, brand, or promote any product, service, + website, or copy of the software. Accurately stating that something refers + to the original DebateOwl project is allowed. + +7. Third-party components. Files and packages that come from third parties + (for example npm dependencies, fonts, and vendored tools) remain under + their own licenses. These terms apply only to the licensor's own work. + +For any use these terms do not allow, ask for a separate license at +hello@debateowl.com. + +--- + +# PolyForm Strict License 1.0.0 + + + +## Acceptance + +In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses. + +## Copyright License + +The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose, other than distributing the software or making changes or new works based on the software. + +## Patent License + +The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software. + +## Noncommercial Purposes + +Any noncommercial purpose is a permitted purpose. + +## Personal Uses + +Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose. + +## Noncommercial Organizations + +Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding. + +## Fair Use + +You may have "fair use" rights for the software under the law. These terms do not limit them. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately. + +## No Liability + +***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.*** + +## Definitions + +The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms. + +**You** refers to the individual or entity agreeing to these terms. + +**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect. + +**Your licenses** are all the licenses granted to you for the software under these terms. + +**Use** means anything you do with the software requiring one of your licenses. diff --git a/README.md b/README.md index 6f109ff18..7ba939898 100644 --- a/README.md +++ b/README.md @@ -1,108 +1,124 @@ -# DebateLab +

+ DebateOwl logo +

-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. +

DebateOwl

-Live application: [debateowl.com](https://debateowl.com) (previously debate.pi-stack.org, which now redirects here) +

+ AI debate prep and practice for competitive debaters.
+ debateowl.com +

+ +

+ The DebateOwl home page with a generated case for the motion 'Should schools ban phones?' +

+ +DebateOwl helps students prepare a motion and practise it. You pick the motion you are debating, build cases and research for both sides, then practise against the AI. Everything you make is saved to that debate. + +The easiest way to use it is the hosted app at [debateowl.com](https://debateowl.com). This repository is its source code, published so people can read it, learn from it, and run it for themselves. It is source-available, not open source: see [License](#license). ## What it does -- **Current Debate.** One motion follows the signed-in user across the app. Sources and results attach to it automatically, both sides (A favor and En contra) are tracked, and older debates stay available. One-off mode runs a tool without saving to any debate. -- **Prepare.** Generate builds a full case. Brainstorm explores angles. Factoids finds cited evidence. Flowchart maps claims and rebuttals. -- **Practice.** Attack finds weak points. Ask is a streaming debate coach. Crossfire drills questioning. Sparring runs a timed round against the AI and scores it. -- **Library and History.** Sources and saved work, scoped to the Current Debate. -- **Account.** Google, email code, and demo-code sign-in; Basic and Pro plans through Stripe; sharing; admin announcements. -- **Platforms.** Web and installable PWA with an offline shell. -- **Languages.** English, Spanish, and Hungarian interface and AI output. +- **Current Debate.** One motion follows you around the app. Sources and results attach to it automatically, both sides are tracked, and older debates stay available. One-off mode runs a tool without saving. +- **Prepare.** Generate writes a full case you can read out loud. Brainstorm explores angles. Factoids finds cited evidence. Flowchart maps claims and rebuttals. +- **Practise.** Attack finds weak points. Ask is a streaming debate coach. Crossfire drills questioning. Sparring runs a timed round against the AI and scores it, with optional voice. +- **Free tools.** A [debate timer](https://debateowl.com/tools/debate-timer) that knows the speech order of each format, with no account needed. +- **Everywhere.** Web and installable app (PWA) with an offline shell. Interface and AI output in English, Spanish and Hungarian. -Collaborative Workspace exists in the code but is hidden and on hold. Its routes appear only when `VITE_COLLABORATION_URL` is set. +

+ The free DebateOwl debate timer set to Public Forum +   + DebateOwl on a phone +

-The route source of truth is [`src/App.tsx`](src/App.tsx). +## Tech stack + +- **Client:** React 18, TypeScript, Vite, Tailwind CSS, Zustand and TanStack Query, installable as a PWA. +- **Backend:** Supabase: Postgres with Row Level Security, Auth (Google, email code), and Deno Edge Functions. +- **AI:** models through OpenRouter, web research through Exa, and voice through ElevenLabs. All provider calls happen in Edge Functions, never in the browser. +- **Payments and hosting:** Stripe for plans, Vercel for the web app. +- **Testing:** Vitest, Deno tests, pgTAP for database policies, and Playwright for the browser. + +```text +Browser or PWA + -> React + Vite client + -> Supabase Auth, Postgres (RLS), Edge Functions (Deno) + -> OpenRouter, Exa, ElevenLabs, Stripe +``` -## Quick start +## Run it yourself -Requirements: +The license lets you run DebateOwl on your own computer for your own personal, non-commercial use. You need your own Supabase stack (local is fine) and your own AI provider keys. -- Node.js 22.x and npm, using the committed `package-lock.json` -- Deno 2.x for Edge Function tests -- 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 +Requirements: Node.js 22 and npm, and Docker (for the local Supabase stack). ```bash -git clone https://github.com/Programcode/DebateLab.git -cd DebateLab +git clone https://github.com/Programcode/DebateOwl.git +cd DebateOwl npm ci + +# 1. Start a local Supabase stack (Postgres, Auth, a local mail inbox). +# It applies every migration in supabase/migrations/. +npx supabase start +npx supabase status # note the API URL and the anon key + +# 2. Give the Edge Functions your own keys, then serve them. +cat > supabase/functions/.env <<'EOF' +OPENROUTER_API_KEY=your-openrouter-key +EXA_API_KEY=your-exa-key +EOF +npx supabase functions serve --env-file supabase/functions/.env + +# 3. In another terminal, start the client against the local stack. +VITE_LOCAL_PREVIEW=true \ +VITE_LOCAL_SUPABASE_URL=http://127.0.0.1:54321 \ +VITE_LOCAL_SUPABASE_ANON_KEY= \ npm run dev ``` -Vite serves on port `8080` by default. In a Superset workspace or other linked worktree, stay in the directory `git rev-parse --show-toplevel` returns. +Open and sign in with an email code. The code arrives in the local inbox that `npx supabase status` lists (Mailpit, usually ). Without `EXA_API_KEY`, web research is unavailable; without an OpenRouter key, the AI tools fail. -## Checks +Always set `VITE_LOCAL_PREVIEW` as shown. Without it the client connects to the hosted DebateOwl project instead of your stack. When you are done, stop the stack with `npx supabase stop`. + +## Development ```bash npm run lint npm run typecheck -npm test +npm test # unit tests +npm run test:edge # Edge Function tests (needs Deno 2) npm run build -npm run verify # the pull-request check, locally, in about 100 seconds -npm run verify:full # adds the full desktop and mobile browser suite -./.superset/db-gate.sh # migrations, pgTAP, and full-stack journeys on local Supabase -./.superset/db-gate.sh --db-only # migrations and pgTAP on Postgres alone ``` -[`docs/testing.md`](docs/testing.md) explains what each gate proves and covers WebKit, protected staging, live-provider runs, and the production canary. - -## How it fits together - -```text -Browser or PWA - -> React 18 + Vite client (Zustand stores, TanStack Query) - -> Supabase Auth, PostgreSQL with RLS, Edge Functions (Deno) - -> OpenRouter models, research providers, Stripe, Google APIs -``` - -- 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. - -Key entry points: - -- [`src/App.tsx`](src/App.tsx): routes and providers -- [`src/lib/stores/`](src/lib/stores/): client state and Supabase hydration, including the Current Debate -- [`src/lib/ai/client.ts`](src/lib/ai/client.ts): the browser's AI boundary -- [`supabase/functions/`](supabase/functions/): Edge Functions, with shared code in `_shared/` -- [`supabase/migrations/`](supabase/migrations/): database schema and policy history - -## Repository map +More detail lives in [`docs/`](docs/README.md): [architecture](docs/architecture.md), [development](docs/development.md), [testing](docs/testing.md) and [operations](docs/operations.md). Backend functions and migrations are described in [`supabase/README.md`](supabase/README.md). [`AGENTS.md`](AGENTS.md) holds the rules the project's coding agents follow. ```text src/ React client, stores, hooks, and unit tests packages/core/ Platform-neutral shared code: schemas, validation, languages supabase/ Edge Functions, migrations, pgTAP tests, local config -e2e/ Playwright browser, full-stack, live, staging, and canary tests -services/ Collaboration service for the hidden Workspace +e2e/, agentic/ Playwright and agent-driven browser tests public/ Static pages, PWA assets, and SEO files -scripts/ Build, docs, SEO, and test helpers -.github/workflows/ Quality, live-provider, model-matrix, staging, and canary CI -.superset/ Worktree dev server, port allocation, and database gate docs/ Architecture, development, testing, and operations guides -plans/ Plan index and every plan ``` -## Documentation +## License + +DebateOwl is licensed under the [PolyForm Strict License 1.0.0](https://polyformproject.org/licenses/strict/1.0.0) with additional limitations. The [LICENSE](LICENSE) file is the binding text. In plain words: + +- **You may** read the code, learn from it, and download and run it on your own computer for your own personal, non-commercial use. +- **You may not** host it for anyone else (no public website, shared instance, or service, free or paid), sell it, redistribute copies, change it, or publish a version of your own. +- **You may not** use the DebateOwl name, logo, or owl mascot for anything else. They are reserved. -- [`AGENTS.md`](AGENTS.md): the rules and commands coding agents follow -- [`docs/README.md`](docs/README.md): documentation index -- [`docs/architecture.md`](docs/architecture.md): runtime boundaries and request flows -- [`docs/development.md`](docs/development.md): setup and everyday workflows -- [`docs/testing.md`](docs/testing.md): verification gates -- [`docs/operations.md`](docs/operations.md): CI, Vercel, staging, deployment, and rollback -- [`supabase/README.md`](supabase/README.md): backend functions, migrations, and secret names -- [`plans/README.md`](plans/README.md): current work and backlog +Viewing and forking on GitHub are allowed by GitHub's own terms; a fork carries no extra rights. For anything else, such as using DebateOwl in a school or organisation, ask at [hello@debateowl.com](mailto:hello@debateowl.com). + +Third-party packages, fonts, and vendored tools keep their own licenses. ## Security -Provider keys, service-role keys, and webhook secrets stay in Supabase or protected CI environments, never in `VITE_*` variables or docs. Row Level Security and handler-level checks are the authorization boundary; client routing is not. Report suspected vulnerabilities privately instead of opening a public issue. +Please don't report vulnerabilities in public issues. See [SECURITY.md](SECURITY.md) for how to report one privately. ## Contributing -Work on a feature branch, preserve unrelated changes, start bug fixes with a failing test, and run the relevant gates before asking for review. Pushing, merging (which deploys the frontend), migrations, Edge Function deploys, and secret changes need Adam's authorization. The full contract is in [`AGENTS.md`](AGENTS.md). +Bug reports and ideas are welcome as [issues](https://github.com/Programcode/DebateOwl/issues). The project does not accept outside pull requests at the moment. + +Copyright © 2026 Ádám Gáborjáni. The DebateOwl name, logo, and owl mascot are reserved and are not licensed for any other use. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..eb5a5f399 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,36 @@ +# Security policy + +DebateOwl handles student accounts, debate work, and payments, so security reports are welcome and taken seriously. + +## Reporting a vulnerability + +Please report vulnerabilities privately. Do not open a public issue, pull request, or discussion. + +- **Preferred:** use GitHub's [private vulnerability reporting](https://github.com/Programcode/DebateOwl/security/advisories/new) for this repository. +- **Or email** [hello@debateowl.com](mailto:hello@debateowl.com) with "Security" in the subject. + +Include what you found, where (URL, endpoint, or file and line), the steps to reproduce it, and what an attacker could do with it. A short proof of concept helps. + +You can expect a first reply within 5 working days. Once a fix ships, we're happy to credit you in the release notes if you'd like. + +## Scope + +In scope: + +- The hosted app at [debateowl.com](https://debateowl.com) and its Supabase backend (Auth, database policies, Edge Functions). +- The source code in this repository. + +Out of scope: + +- Denial-of-service and load testing, spam, and social engineering. +- Findings that need a compromised device or browser. +- Reports from automated scanners without a demonstrated impact. +- Third-party services DebateOwl uses (Supabase, Vercel, Stripe, OpenRouter and others). Report those to the vendor. + +## Testing rules + +- Use only accounts you created yourself. Never access, change, or delete another user's data; if you reach some by accident, stop and tell us. +- Keep AI usage small, and don't make real payments, refunds, or chargebacks as part of testing. +- Give us a reasonable time to fix an issue before you disclose it publicly. + +Testing that follows these rules in good faith will not be treated as abuse. diff --git a/docs/images/home-desktop.jpg b/docs/images/home-desktop.jpg new file mode 100644 index 000000000..d0480d170 Binary files /dev/null and b/docs/images/home-desktop.jpg differ diff --git a/docs/images/home-mobile.jpg b/docs/images/home-mobile.jpg new file mode 100644 index 000000000..eddaec34a Binary files /dev/null and b/docs/images/home-mobile.jpg differ diff --git a/docs/images/timer-desktop.jpg b/docs/images/timer-desktop.jpg new file mode 100644 index 000000000..75e6b9361 Binary files /dev/null and b/docs/images/timer-desktop.jpg differ diff --git a/docs/screenshots/menu-layout/after-ask-empty-en-light.jpg b/docs/screenshots/menu-layout/after-ask-empty-en-light.jpg deleted file mode 100644 index f66a8f9e4..000000000 Binary files a/docs/screenshots/menu-layout/after-ask-empty-en-light.jpg and /dev/null 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 deleted file mode 100644 index b2fae34d2..000000000 Binary files a/docs/screenshots/menu-layout/after-ask-error-en-dark.jpg and /dev/null 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 deleted file mode 100644 index fb183dc5a..000000000 Binary files a/docs/screenshots/menu-layout/after-ask-error-en-light.jpg and /dev/null 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 deleted file mode 100644 index 8df4edef8..000000000 Binary files a/docs/screenshots/menu-layout/after-ask-filled-en-light.jpg and /dev/null 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 deleted file mode 100644 index b6e39bddf..000000000 Binary files a/docs/screenshots/menu-layout/after-ask-filled-es-light.jpg and /dev/null 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 deleted file mode 100644 index 19d6bab69..000000000 Binary files a/docs/screenshots/menu-layout/after-ask-filled-hu-dark.jpg and /dev/null 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 deleted file mode 100644 index d5cc2f060..000000000 Binary files a/docs/screenshots/menu-layout/after-ask-filled-hu-light.jpg and /dev/null 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 deleted file mode 100644 index 875abd2a4..000000000 Binary files a/docs/screenshots/menu-layout/after-ask-loading-en-light.jpg and /dev/null 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 deleted file mode 100644 index 840403c4f..000000000 Binary files a/docs/screenshots/menu-layout/after-ask-options-en-light.jpg and /dev/null 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 deleted file mode 100644 index d3d5ac919..000000000 Binary files a/docs/screenshots/menu-layout/after-ask-options-hu-light.jpg and /dev/null 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 deleted file mode 100644 index 2819bfe07..000000000 Binary files a/docs/screenshots/menu-layout/after-ask-sidebar-hu-dark.jpg and /dev/null 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 deleted file mode 100644 index 96024c2b9..000000000 Binary files a/docs/screenshots/menu-layout/after-gen-empty-en-light.jpg and /dev/null 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 deleted file mode 100644 index 7e5659ee2..000000000 Binary files a/docs/screenshots/menu-layout/after-gen-error-en-light.jpg and /dev/null 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 deleted file mode 100644 index c5d424a66..000000000 Binary files a/docs/screenshots/menu-layout/after-gen-filled-en-dark.jpg and /dev/null 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 deleted file mode 100644 index 5f6d0455b..000000000 Binary files a/docs/screenshots/menu-layout/after-gen-filled-en-light.jpg and /dev/null 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 deleted file mode 100644 index 4d2a366bf..000000000 Binary files a/docs/screenshots/menu-layout/after-gen-filled-es-light.jpg and /dev/null 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 deleted file mode 100644 index 8460b4d58..000000000 Binary files a/docs/screenshots/menu-layout/after-gen-filled-hu-dark.jpg and /dev/null 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 deleted file mode 100644 index 64c3b82f2..000000000 Binary files a/docs/screenshots/menu-layout/after-gen-filled-hu-light.jpg and /dev/null 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 deleted file mode 100644 index 930538a6d..000000000 Binary files a/docs/screenshots/menu-layout/after-gen-loading-en-light.jpg and /dev/null 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 deleted file mode 100644 index 1622abd74..000000000 Binary files a/docs/screenshots/menu-layout/after-gen-regenerating-en-light.jpg and /dev/null 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 deleted file mode 100644 index c12c35dbc..000000000 Binary files a/docs/screenshots/menu-layout/after-gen-saved-en-dark.jpg and /dev/null 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 deleted file mode 100644 index 8b7a7a23a..000000000 Binary files a/docs/screenshots/menu-layout/after-gen-saved-en-light.jpg and /dev/null 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 deleted file mode 100644 index bb1d2ce31..000000000 Binary files a/docs/screenshots/menu-layout/after-gen-saved-hu-dark.jpg and /dev/null 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 deleted file mode 100644 index d009cab7d..000000000 Binary files a/docs/screenshots/menu-layout/after-gen-saved-hu-light.jpg and /dev/null differ diff --git a/docs/screenshots/menu-layout/after-share-en-light.jpg b/docs/screenshots/menu-layout/after-share-en-light.jpg deleted file mode 100644 index e79e27bc2..000000000 Binary files a/docs/screenshots/menu-layout/after-share-en-light.jpg and /dev/null differ diff --git a/docs/screenshots/menu-layout/after-share-hu-dark.jpg b/docs/screenshots/menu-layout/after-share-hu-dark.jpg deleted file mode 100644 index db75c4f29..000000000 Binary files a/docs/screenshots/menu-layout/after-share-hu-dark.jpg and /dev/null 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 deleted file mode 100644 index 530d3acc5..000000000 Binary files a/docs/screenshots/menu-layout/before-ask-empty-en-light.jpg and /dev/null 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 deleted file mode 100644 index 1ab3ce8b0..000000000 Binary files a/docs/screenshots/menu-layout/before-ask-error-en-dark.jpg and /dev/null 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 deleted file mode 100644 index 30181be01..000000000 Binary files a/docs/screenshots/menu-layout/before-ask-error-en-light.jpg and /dev/null 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 deleted file mode 100644 index caddd0dbd..000000000 Binary files a/docs/screenshots/menu-layout/before-ask-filled-en-light.jpg and /dev/null 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 deleted file mode 100644 index 5595e76da..000000000 Binary files a/docs/screenshots/menu-layout/before-ask-filled-es-light.jpg and /dev/null 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 deleted file mode 100644 index 4e9ac1476..000000000 Binary files a/docs/screenshots/menu-layout/before-ask-filled-hu-dark.jpg and /dev/null 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 deleted file mode 100644 index 71fdb29f9..000000000 Binary files a/docs/screenshots/menu-layout/before-ask-filled-hu-light.jpg and /dev/null 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 deleted file mode 100644 index e458fbe29..000000000 Binary files a/docs/screenshots/menu-layout/before-ask-loading-en-light.jpg and /dev/null 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 deleted file mode 100644 index 4ca4e1bd6..000000000 Binary files a/docs/screenshots/menu-layout/before-ask-options-en-light.jpg and /dev/null 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 deleted file mode 100644 index 7c7729a39..000000000 Binary files a/docs/screenshots/menu-layout/before-ask-options-hu-light.jpg and /dev/null 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 deleted file mode 100644 index 4d4c3d743..000000000 Binary files a/docs/screenshots/menu-layout/before-ask-sidebar-hu-dark.jpg and /dev/null 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 deleted file mode 100644 index bdf8833b7..000000000 Binary files a/docs/screenshots/menu-layout/before-gen-empty-en-light.jpg and /dev/null 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 deleted file mode 100644 index 4549919d0..000000000 Binary files a/docs/screenshots/menu-layout/before-gen-error-en-light.jpg and /dev/null 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 deleted file mode 100644 index ae14a902e..000000000 Binary files a/docs/screenshots/menu-layout/before-gen-filled-en-dark.jpg and /dev/null 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 deleted file mode 100644 index dfe234358..000000000 Binary files a/docs/screenshots/menu-layout/before-gen-filled-en-light.jpg and /dev/null 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 deleted file mode 100644 index d8bfb1306..000000000 Binary files a/docs/screenshots/menu-layout/before-gen-filled-es-light.jpg and /dev/null 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 deleted file mode 100644 index 9b50e4c98..000000000 Binary files a/docs/screenshots/menu-layout/before-gen-filled-hu-dark.jpg and /dev/null 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 deleted file mode 100644 index bed3f81de..000000000 Binary files a/docs/screenshots/menu-layout/before-gen-filled-hu-light.jpg and /dev/null 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 deleted file mode 100644 index 404930541..000000000 Binary files a/docs/screenshots/menu-layout/before-gen-loading-en-light.jpg and /dev/null 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 deleted file mode 100644 index a9457a22d..000000000 Binary files a/docs/screenshots/menu-layout/before-gen-regenerating-en-light.jpg and /dev/null 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 deleted file mode 100644 index 5ab6b3051..000000000 Binary files a/docs/screenshots/menu-layout/before-gen-saved-en-dark.jpg and /dev/null 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 deleted file mode 100644 index d9d8bc96a..000000000 Binary files a/docs/screenshots/menu-layout/before-gen-saved-en-light.jpg and /dev/null 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 deleted file mode 100644 index 23733eefc..000000000 Binary files a/docs/screenshots/menu-layout/before-gen-saved-hu-dark.jpg and /dev/null 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 deleted file mode 100644 index fb170838a..000000000 Binary files a/docs/screenshots/menu-layout/before-gen-saved-hu-light.jpg and /dev/null differ diff --git a/docs/screenshots/menu-layout/before-share-en-light.jpg b/docs/screenshots/menu-layout/before-share-en-light.jpg deleted file mode 100644 index de910d818..000000000 Binary files a/docs/screenshots/menu-layout/before-share-en-light.jpg and /dev/null differ diff --git a/docs/screenshots/menu-layout/before-share-hu-dark.jpg b/docs/screenshots/menu-layout/before-share-hu-dark.jpg deleted file mode 100644 index 0c3b4ac4d..000000000 Binary files a/docs/screenshots/menu-layout/before-share-hu-dark.jpg and /dev/null differ diff --git a/docs/standards/async-and-state.md b/docs/standards/async-and-state.md index d80f49ab2..a079661de 100644 --- a/docs/standards/async-and-state.md +++ b/docs/standards/async-and-state.md @@ -6,50 +6,50 @@ 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)). -- 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)). +- 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/DebateOwl/pull/72#discussion_r4112813743)). +- Workspace research saved its sources into whichever personal debate was active when the response arrived ([#72](https://github.com/Programcode/DebateOwl/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/DebateOwl/pull/72#discussion_r4112813737)). ## Stale responses must not overwrite newer state When a load and a write can overlap, give the write a counter or request key and have the load discard its result if the counter moved while it ran. This is the pattern `debate-store.ts` and the context pins use now. -- Hydration replaced a debate created while it was loading ([#72](https://github.com/Programcode/DebateLab/pull/72#discussion_r4112813746)). A pin load overwrote a pin change made during the read ([#75](https://github.com/Programcode/DebateLab/pull/75#issuecomment-5873019386)). -- A load that started before a dismissal brought the dismissed announcement back ([#19](https://github.com/Programcode/DebateLab/pull/19#discussion_r3896612965)). -- A failed optimistic write undoes only its own change. Restoring a snapshot taken earlier also reverts other writes that succeeded since ([#75](https://github.com/Programcode/DebateLab/pull/75#issuecomment-5873818351)). -- Count against the store's current state, not a value captured at render, so two quick clicks see each other ([#77](https://github.com/Programcode/DebateLab/pull/77#issuecomment-5877394644)). -- Errors tied to one item carry that item's ID and render only beside it ([#19](https://github.com/Programcode/DebateLab/pull/19#discussion_r3896430653)). +- Hydration replaced a debate created while it was loading ([#72](https://github.com/Programcode/DebateOwl/pull/72#discussion_r4112813746)). A pin load overwrote a pin change made during the read ([#75](https://github.com/Programcode/DebateOwl/pull/75#issuecomment-5873019386)). +- A load that started before a dismissal brought the dismissed announcement back ([#19](https://github.com/Programcode/DebateOwl/pull/19#discussion_r3896612965)). +- A failed optimistic write undoes only its own change. Restoring a snapshot taken earlier also reverts other writes that succeeded since ([#75](https://github.com/Programcode/DebateOwl/pull/75#issuecomment-5873818351)). +- Count against the store's current state, not a value captured at render, so two quick clicks see each other ([#77](https://github.com/Programcode/DebateOwl/pull/77#issuecomment-5877394644)). +- Errors tied to one item carry that item's ID and render only beside it ([#19](https://github.com/Programcode/DebateOwl/pull/19#discussion_r3896430653)). ## Supersede in-flight work explicitly Starting new work for the same slot aborts the old request and hides its output. Track ownership with a request identity and check it before every state update in `then`, `catch`, and `finally`. -- A clarify request started while a stream was running; the old stream later appended text and cleared the loading flag ([#9](https://github.com/Programcode/DebateLab/pull/9#discussion_r3861289857)). -- Cancel in-flight work on unmount as well as on clear and load ([#9](https://github.com/Programcode/DebateLab/pull/9#discussion_r3861496792)). -- Undo during a section regeneration was reverted when the regeneration landed. Drop a result if the record it targets changed after the work started ([#89](https://github.com/Programcode/DebateLab/pull/89#issuecomment-5951346940)). -- Key deduplication by a stable identity (user ID), not by a rotating access token ([#65](https://github.com/Programcode/DebateLab/pull/65#discussion_r4082668135)). +- A clarify request started while a stream was running; the old stream later appended text and cleared the loading flag ([#9](https://github.com/Programcode/DebateOwl/pull/9#discussion_r3861289857)). +- Cancel in-flight work on unmount as well as on clear and load ([#9](https://github.com/Programcode/DebateOwl/pull/9#discussion_r3861496792)). +- Undo during a section regeneration was reverted when the regeneration landed. Drop a result if the record it targets changed after the work started ([#89](https://github.com/Programcode/DebateOwl/pull/89#issuecomment-5951346940)). +- Key deduplication by a stable identity (user ID), not by a rotating access token ([#65](https://github.com/Programcode/DebateOwl/pull/65#discussion_r4082668135)). ## React effects and refs -- Do not use an `active` closure flag in an effect that can return early without registering cleanup; a re-render can leave it `false` forever. Request-key refs already reject stale results ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649640)). -- Write refs that track the previous render inside an effect, not during render ([#20](https://github.com/Programcode/DebateLab/pull/20#discussion_r3896378571)). -- Derive view state from the same condition that chooses the rendered branch ([#20](https://github.com/Programcode/DebateLab/pull/20#discussion_r3896378587)). -- One-shot guards must flip after their first use ([#14](https://github.com/Programcode/DebateLab/pull/14#discussion_r3894645041)). +- Do not use an `active` closure flag in an effect that can return early without registering cleanup; a re-render can leave it `false` forever. Request-key refs already reject stale results ([#13](https://github.com/Programcode/DebateOwl/pull/13#discussion_r3893649640)). +- Write refs that track the previous render inside an effect, not during render ([#20](https://github.com/Programcode/DebateOwl/pull/20#discussion_r3896378571)). +- Derive view state from the same condition that chooses the rendered branch ([#20](https://github.com/Programcode/DebateOwl/pull/20#discussion_r3896378587)). +- One-shot guards must flip after their first use ([#14](https://github.com/Programcode/DebateOwl/pull/14#discussion_r3894645041)). ## Auth transitions and persisted state -- Persisted browser state records its owner. On sign-in, clear state whose owner differs or is missing. Do not use "was there a previous user" as the ownership check ([#13 review](https://github.com/Programcode/DebateLab/pull/13#issuecomment-5477866033)). -- Clear only the previous owner's data on an account switch, never the incoming user's ([#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641261)). -- Validate persisted state on every rehydrate. Zustand calls `migrate` only on a version change, so put validation in `merge` ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649636)). -- Do not serialize a whole store on every keystroke. Debounce drafts and cap stored history ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649632)). +- Persisted browser state records its owner. On sign-in, clear state whose owner differs or is missing. Do not use "was there a previous user" as the ownership check ([#13 review](https://github.com/Programcode/DebateOwl/pull/13#issuecomment-5477866033)). +- Clear only the previous owner's data on an account switch, never the incoming user's ([#12](https://github.com/Programcode/DebateOwl/pull/12#discussion_r3893641261)). +- Validate persisted state on every rehydrate. Zustand calls `migrate` only on a version change, so put validation in `merge` ([#13](https://github.com/Programcode/DebateOwl/pull/13#discussion_r3893649636)). +- Do not serialize a whole store on every keystroke. Debounce drafts and cap stored history ([#13](https://github.com/Programcode/DebateOwl/pull/13#discussion_r3893649632)). ## Timeouts and cancellation A timeout that rejects the caller's promise but leaves the work running still spends money and CPU. -- Pass one `AbortSignal` through every layer of the work: research, Exa, model calls, body reads ([#5](https://github.com/Programcode/DebateLab/pull/5#discussion_r3835805781), [#9](https://github.com/Programcode/DebateLab/pull/9#discussion_r3861289875)). A `Promise.race` timeout without cancellation leaves a DOCX conversion running ([#63](https://github.com/Programcode/DebateLab/pull/63#discussion_r4081818971)). -- Start the deadline and listen to the caller's signal before the first `await`, including `getSession()` ([#13 review](https://github.com/Programcode/DebateLab/pull/13#issuecomment-5477866033), [#16](https://github.com/Programcode/DebateLab/pull/16#discussion_r3895854139)). -- Every external fetch has a bound, and anything that delays app startup has a fail-open timeout ([#5](https://github.com/Programcode/DebateLab/pull/5#discussion_r3835805765), [#21](https://github.com/Programcode/DebateLab/pull/21#discussion_r3896608554)). -- A promise that wraps a callback API settles on every outcome, including cancel. The Google Picker promise never settled on cancel and left the panel disabled ([#63](https://github.com/Programcode/DebateLab/pull/63#discussion_r4081818959)). -- After a caller abort, normalize the error to `AbortError` so callers do not report it as a failure ([#16](https://github.com/Programcode/DebateLab/pull/16#discussion_r3895854139)). -- Secondary work after the main result (such as source-usage checks) gets its own budget and must not lose a result that already finished ([#69](https://github.com/Programcode/DebateLab/pull/69#discussion_r4093476756)). +- Pass one `AbortSignal` through every layer of the work: research, Exa, model calls, body reads ([#5](https://github.com/Programcode/DebateOwl/pull/5#discussion_r3835805781), [#9](https://github.com/Programcode/DebateOwl/pull/9#discussion_r3861289875)). A `Promise.race` timeout without cancellation leaves a DOCX conversion running ([#63](https://github.com/Programcode/DebateOwl/pull/63#discussion_r4081818971)). +- Start the deadline and listen to the caller's signal before the first `await`, including `getSession()` ([#13 review](https://github.com/Programcode/DebateOwl/pull/13#issuecomment-5477866033), [#16](https://github.com/Programcode/DebateOwl/pull/16#discussion_r3895854139)). +- Every external fetch has a bound, and anything that delays app startup has a fail-open timeout ([#5](https://github.com/Programcode/DebateOwl/pull/5#discussion_r3835805765), [#21](https://github.com/Programcode/DebateOwl/pull/21#discussion_r3896608554)). +- A promise that wraps a callback API settles on every outcome, including cancel. The Google Picker promise never settled on cancel and left the panel disabled ([#63](https://github.com/Programcode/DebateOwl/pull/63#discussion_r4081818959)). +- After a caller abort, normalize the error to `AbortError` so callers do not report it as a failure ([#16](https://github.com/Programcode/DebateOwl/pull/16#discussion_r3895854139)). +- Secondary work after the main result (such as source-usage checks) gets its own budget and must not lose a result that already finished ([#69](https://github.com/Programcode/DebateOwl/pull/69#discussion_r4093476756)). diff --git a/docs/standards/boundaries.md b/docs/standards/boundaries.md index c40398f1a..9f6e08890 100644 --- a/docs/standards/boundaries.md +++ b/docs/standards/boundaries.md @@ -6,51 +6,51 @@ Part of the [coding standards](../../CODING_STANDARDS.md). The client AI boundar Model output, Edge responses, URLs, and request bodies are untrusted until a validator accepts them. Validate the complete shape where the data enters, before rendering, autosave, or a cast. -- Validate the whole output with its schema before saving sources. A count check plus a cast let malformed output through ([#9](https://github.com/Programcode/DebateLab/pull/9#discussion_r3861289852)). -- Normalize optional fields to their primitive types. An object in `qualityLabel` reached React as a child ([#5](https://github.com/Programcode/DebateLab/pull/5#discussion_r3835805775)). -- Use `validateUrl` for every URL you render as a link, and store the value it returns, not the input ([#17](https://github.com/Programcode/DebateLab/pull/17#discussion_r3896309984), [#17 review](https://github.com/Programcode/DebateLab/pull/17#issuecomment-5481176881)). -- Validate raw types before coercing. `Boolean("false")` is `true` ([#29](https://github.com/Programcode/DebateLab/pull/29#discussion_r3937934126)). -- Invalid client input returns a 4xx, never a 500 from a property access on `null` ([#13 review](https://github.com/Programcode/DebateLab/pull/13#issuecomment-5477866033)). -- When the client validates locally, apply the same rules as the server, so a request the client accepted does not fail as a generic error ([#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641258)). -- Send only values the server contract accepts. Local-only source IDs made every AI request fail with 400 ([#66](https://github.com/Programcode/DebateLab/pull/66#discussion_r4085167559)). -- Prefer a typed result over `as` casts at call sites. Unchecked casts hide shape drift ([#18](https://github.com/Programcode/DebateLab/pull/18), single finding). +- Validate the whole output with its schema before saving sources. A count check plus a cast let malformed output through ([#9](https://github.com/Programcode/DebateOwl/pull/9#discussion_r3861289852)). +- Normalize optional fields to their primitive types. An object in `qualityLabel` reached React as a child ([#5](https://github.com/Programcode/DebateOwl/pull/5#discussion_r3835805775)). +- Use `validateUrl` for every URL you render as a link, and store the value it returns, not the input ([#17](https://github.com/Programcode/DebateOwl/pull/17#discussion_r3896309984), [#17 review](https://github.com/Programcode/DebateOwl/pull/17#issuecomment-5481176881)). +- Validate raw types before coercing. `Boolean("false")` is `true` ([#29](https://github.com/Programcode/DebateOwl/pull/29#discussion_r3937934126)). +- Invalid client input returns a 4xx, never a 500 from a property access on `null` ([#13 review](https://github.com/Programcode/DebateOwl/pull/13#issuecomment-5477866033)). +- When the client validates locally, apply the same rules as the server, so a request the client accepted does not fail as a generic error ([#12](https://github.com/Programcode/DebateOwl/pull/12#discussion_r3893641258)). +- Send only values the server contract accepts. Local-only source IDs made every AI request fail with 400 ([#66](https://github.com/Programcode/DebateOwl/pull/66#discussion_r4085167559)). +- Prefer a typed result over `as` casts at call sites. Unchecked casts hide shape drift ([#18](https://github.com/Programcode/DebateOwl/pull/18), single finding). ## Model output can omit what the prompt asked for -The shared JSON rules tell the model to omit a part it cannot fill. Map an omitted field to its documented empty value instead of failing the request ([#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641334)). A response variant that does not match the operation is an error the UI can recover from, not a silent return that leaves the page pending ([#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641293)). +The shared JSON rules tell the model to omit a part it cannot fill. Map an omitted field to its documented empty value instead of failing the request ([#12](https://github.com/Programcode/DebateOwl/pull/12#discussion_r3893641334)). A response variant that does not match the operation is an error the UI can recover from, not a silent return that leaves the page pending ([#12](https://github.com/Programcode/DebateOwl/pull/12#discussion_r3893641293)). ## Size limits are UTF-8 bytes -The Edge limit is on serialized bytes, so budget in bytes, across the whole serialized payload, before transport ([#66](https://github.com/Programcode/DebateLab/pull/66#discussion_r4085167588), [#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641325)). +The Edge limit is on serialized bytes, so budget in bytes, across the whole serialized payload, before transport ([#66](https://github.com/Programcode/DebateOwl/pull/66#discussion_r4085167588), [#12](https://github.com/Programcode/DebateOwl/pull/12#discussion_r3893641325)). -- Do not silently shorten what the user typed. Reject it with a visible message, or keep the truncation marker so the server can report it ([#66](https://github.com/Programcode/DebateLab/pull/66#discussion_r4085302451)). -- A value exactly at the limit is allowed. Check the boundary with a test on each side ([#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641317)). -- Check `response.ok` before reading a body, and read bounded bodies inside the timeout ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004292)). +- Do not silently shorten what the user typed. Reject it with a visible message, or keep the truncation marker so the server can report it ([#66](https://github.com/Programcode/DebateOwl/pull/66#discussion_r4085302451)). +- A value exactly at the limit is allowed. Check the boundary with a test on each side ([#12](https://github.com/Programcode/DebateOwl/pull/12#discussion_r3893641317)). +- Check `response.ok` before reading a body, and read bounded bodies inside the timeout ([#11](https://github.com/Programcode/DebateOwl/pull/11#discussion_r3872004292)). ## Fail closed, and say "unknown" when you do not know -- A failed entitlement read is indeterminate. Treating it as "not Pro" persisted a downgrade of the user's model choice ([#47](https://github.com/Programcode/DebateLab/pull/47#discussion_r4054031767)). -- Check the result of every write before reporting success ([#63](https://github.com/Programcode/DebateLab/pull/63#discussion_r4081818967)). Lint rejects unhandled promises; a `void` in front of one is correct only when the promise cannot reject or handles its own errors and tells the user ([#63 outside diff](https://github.com/Programcode/DebateLab/pull/63)). -- Catch the specific error you expect (a missing file, a missing column) and rethrow the rest ([#5](https://github.com/Programcode/DebateLab/pull/5#discussion_r3835805773)). -- Read provider per-item status before falling back to a generic message ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004284)). -- A fix for a permission change covers every write path, not only the one that failed first ([#44](https://github.com/Programcode/DebateLab/pull/44#discussion_r4049229043)). +- A failed entitlement read is indeterminate. Treating it as "not Pro" persisted a downgrade of the user's model choice ([#47](https://github.com/Programcode/DebateOwl/pull/47#discussion_r4054031767)). +- Check the result of every write before reporting success ([#63](https://github.com/Programcode/DebateOwl/pull/63#discussion_r4081818967)). Lint rejects unhandled promises; a `void` in front of one is correct only when the promise cannot reject or handles its own errors and tells the user ([#63 outside diff](https://github.com/Programcode/DebateOwl/pull/63)). +- Catch the specific error you expect (a missing file, a missing column) and rethrow the rest ([#5](https://github.com/Programcode/DebateOwl/pull/5#discussion_r3835805773)). +- Read provider per-item status before falling back to a generic message ([#11](https://github.com/Programcode/DebateOwl/pull/11#discussion_r3872004284)). +- A fix for a permission change covers every write path, not only the one that failed first ([#44](https://github.com/Programcode/DebateOwl/pull/44#discussion_r4049229043)). ## Controls must be honest A visible toggle or setting must change the request or deterministic post-processing. This is an existing team rule that reviewers enforced: -- `useActiveSources` did not change the opponent request ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004224)). -- Research instructions were attached when research returned nothing, inviting unsupported citations ([#5](https://github.com/Programcode/DebateLab/pull/5#discussion_r3835805784)). -- A privacy disclosure stays only while the code enforces it ([#22](https://github.com/Programcode/DebateLab/pull/22#discussion_r3896703404)). -- Tier defaults must reach every fallback path, not only a migration ([#47](https://github.com/Programcode/DebateLab/pull/47#discussion_r4054031797)). +- `useActiveSources` did not change the opponent request ([#11](https://github.com/Programcode/DebateOwl/pull/11#discussion_r3872004224)). +- Research instructions were attached when research returned nothing, inviting unsupported citations ([#5](https://github.com/Programcode/DebateOwl/pull/5#discussion_r3835805784)). +- A privacy disclosure stays only while the code enforces it ([#22](https://github.com/Programcode/DebateOwl/pull/22#discussion_r3896703404)). +- Tier defaults must reach every fallback path, not only a migration ([#47](https://github.com/Programcode/DebateOwl/pull/47#discussion_r4054031797)). ## Usage and cost records Every provider call that can bill gets recorded, including retries, repairs, research sub-calls, and cache-only usage. A request that did not complete records its observed usage with a null cost. -- Cache-only usage returned before pricing ([#5](https://github.com/Programcode/DebateLab/pull/5#discussion_r3835805777)). Repair attempts dropped the failed call's tokens ([#5 review](https://github.com/Programcode/DebateLab/pull/5#issuecomment-5379671525), [#9](https://github.com/Programcode/DebateLab/pull/9#discussion_r3861289886)). -- A stream that errors after its finish frame is incomplete ([#46](https://github.com/Programcode/DebateLab/pull/46#discussion_r4053914209)). +- Cache-only usage returned before pricing ([#5](https://github.com/Programcode/DebateOwl/pull/5#discussion_r3835805777)). Repair attempts dropped the failed call's tokens ([#5 review](https://github.com/Programcode/DebateOwl/pull/5#issuecomment-5379671525), [#9](https://github.com/Programcode/DebateOwl/pull/9#discussion_r3861289886)). +- A stream that errors after its finish frame is incomplete ([#46](https://github.com/Programcode/DebateOwl/pull/46#discussion_r4053914209)). ## Keep one copy of a contract -When two code paths implement the same privacy-sensitive payload or validation, move it to one helper. The two copies of the AI failure report had already drifted ([#18](https://github.com/Programcode/DebateLab/pull/18), single finding). Configuration that exists in two places (such as two CSP lists) changes together, with a test that compares them ([#15](https://github.com/Programcode/DebateLab/pull/15#discussion_r3894972424)). +When two code paths implement the same privacy-sensitive payload or validation, move it to one helper. The two copies of the AI failure report had already drifted ([#18](https://github.com/Programcode/DebateOwl/pull/18), single finding). Configuration that exists in two places (such as two CSP lists) changes together, with a test that compares them ([#15](https://github.com/Programcode/DebateOwl/pull/15#discussion_r3894972424)). diff --git a/docs/standards/database.md b/docs/standards/database.md index 983226718..b05a4ff70 100644 --- a/docs/standards/database.md +++ b/docs/standards/database.md @@ -4,21 +4,21 @@ Part of the [coding standards](../../CODING_STANDARDS.md). [supabase/README.md]( ## Constraints -- A `CHECK` passes when its expression is `NULL`. Test nullable columns explicitly with `IS NOT NULL` inside the branch that requires them ([#19](https://github.com/Programcode/DebateLab/pull/19#discussion_r3896430665)). -- Grant the privileges a client read path needs, and let RLS limit the rows. Revoking `SELECT` broke the daily usage count ([#70](https://github.com/Programcode/DebateLab/pull/70#discussion_r4102131041)). When a migration narrows grants, check every client write path that touches the table ([#44](https://github.com/Programcode/DebateLab/pull/44#discussion_r4049229043)). +- A `CHECK` passes when its expression is `NULL`. Test nullable columns explicitly with `IS NOT NULL` inside the branch that requires them ([#19](https://github.com/Programcode/DebateOwl/pull/19#discussion_r3896430665)). +- Grant the privileges a client read path needs, and let RLS limit the rows. Revoking `SELECT` broke the daily usage count ([#70](https://github.com/Programcode/DebateOwl/pull/70#discussion_r4102131041)). When a migration narrows grants, check every client write path that touches the table ([#44](https://github.com/Programcode/DebateOwl/pull/44#discussion_r4049229043)). ## Locks on tables with production rows -`npm run lint` runs squawk on new migrations. Check every `-- squawk-ignore` against the table's production size: an ignore is fine on a small table and wrong on a large one. Index builds on populated tables use `CREATE INDEX CONCURRENTLY` in their own file ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649726)), and long locks run at low traffic ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004307)). +`npm run lint` runs squawk on new migrations. Check every `-- squawk-ignore` against the table's production size: an ignore is fine on a small table and wrong on a large one. Index builds on populated tables use `CREATE INDEX CONCURRENTLY` in their own file ([#13](https://github.com/Programcode/DebateOwl/pull/13#discussion_r3893649726)), and long locks run at low traffic ([#11](https://github.com/Programcode/DebateOwl/pull/11#discussion_r3872004307)). ## Compare times on one clock -Do not compare a timestamp produced by an Edge Function with one produced by Postgres. Clock skew made the erase fence either accept writes after "Delete all data" or reject fresh requests. Take both times from the database, or compare counters ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649730)). +Do not compare a timestamp produced by an Edge Function with one produced by Postgres. Clock skew made the erase fence either accept writes after "Delete all data" or reject fresh requests. Take both times from the database, or compare counters ([#13](https://github.com/Programcode/DebateOwl/pull/13#discussion_r3893649730)). ## Destructive data changes -A migration that anyone can apply must not delete user data as a side effect. Put the deletion in a function, leave the call out of the migration, and run it as a separate step that needs explicit authorization for that environment. Write down the preservation and restore path before that step ([#72](https://github.com/Programcode/DebateLab/pull/72#discussion_r4112813734), [#71](https://github.com/Programcode/DebateLab/pull/71#discussion_r4103235907)). `20260926090100_legacy_debate_cleanup.sql` now follows this. +A migration that anyone can apply must not delete user data as a side effect. Put the deletion in a function, leave the call out of the migration, and run it as a separate step that needs explicit authorization for that environment. Write down the preservation and restore path before that step ([#72](https://github.com/Programcode/DebateOwl/pull/72#discussion_r4112813734), [#71](https://github.com/Programcode/DebateOwl/pull/71#discussion_r4103235907)). `20260926090100_legacy_debate_cleanup.sql` now follows this. ## Privileged clients -Use the generated `Database` type with `createClient(...)` rather than casting the client through `unknown` ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649719)). A service-role client filters by the user ID it validated from the bearer token, never by an ID from the request body ([#90 security review](https://github.com/Programcode/DebateLab/pull/90)). +Use the generated `Database` type with `createClient(...)` rather than casting the client through `unknown` ([#13](https://github.com/Programcode/DebateOwl/pull/13#discussion_r3893649719)). A service-role client filters by the user ID it validated from the bearer token, never by an ID from the request body ([#90 security review](https://github.com/Programcode/DebateOwl/pull/90)). diff --git a/docs/standards/reviews.md b/docs/standards/reviews.md index 453ce9540..8345778dc 100644 --- a/docs/standards/reviews.md +++ b/docs/standards/reviews.md @@ -6,16 +6,16 @@ Part of the [coding standards](../../CODING_STANDARDS.md). Check the PR body and PR bodies and commit messages are evidence. The second-pass reviews on #34 to #38 blocked two PRs for claims the diff contradicted. -- A claim of scope must survive the diff. "Nothing here is staging-dependent" is false when the diff removes the staging flag ([#37](https://github.com/Programcode/DebateLab/pull/37#issuecomment-5561106629)). "On every page" is false when the diff edits one file ([#38](https://github.com/Programcode/DebateLab/pull/38#issuecomment-5561106726)). -- A deletion shows the search behind it, covering dynamic imports, string-built paths, `e2e/`, and `page.evaluate` access that typecheck cannot see ([#34](https://github.com/Programcode/DebateLab/pull/34#issuecomment-5561106288), [#36](https://github.com/Programcode/DebateLab/pull/36#issuecomment-5561106513)). -- "No behavior change" holds for every input. `Response.json(undefined)` throws where `JSON.stringify` did not ([#36](https://github.com/Programcode/DebateLab/pull/36#issuecomment-5561106513)). -- Test counts in the commit and the PR body agree and come from the final head ([#35](https://github.com/Programcode/DebateLab/pull/35#issuecomment-5561106411)). -- Proof that used stubs says so: "This proves client dispatch only; it is not authenticated provider proof." ([#65](https://github.com/Programcode/DebateLab/pull/65#issuecomment-5795646211)) +- A claim of scope must survive the diff. "Nothing here is staging-dependent" is false when the diff removes the staging flag ([#37](https://github.com/Programcode/DebateOwl/pull/37#issuecomment-5561106629)). "On every page" is false when the diff edits one file ([#38](https://github.com/Programcode/DebateOwl/pull/38#issuecomment-5561106726)). +- A deletion shows the search behind it, covering dynamic imports, string-built paths, `e2e/`, and `page.evaluate` access that typecheck cannot see ([#34](https://github.com/Programcode/DebateOwl/pull/34#issuecomment-5561106288), [#36](https://github.com/Programcode/DebateOwl/pull/36#issuecomment-5561106513)). +- "No behavior change" holds for every input. `Response.json(undefined)` throws where `JSON.stringify` did not ([#36](https://github.com/Programcode/DebateOwl/pull/36#issuecomment-5561106513)). +- Test counts in the commit and the PR body agree and come from the final head ([#35](https://github.com/Programcode/DebateOwl/pull/35#issuecomment-5561106411)). +- Proof that used stubs says so: "This proves client dispatch only; it is not authenticated provider proof." ([#65](https://github.com/Programcode/DebateOwl/pull/65#issuecomment-5795646211)) ## Documents change with the code -The owning document changes in the same PR as the behavior it describes ([docs/README.md](../README.md#maintenance-rule) names the owners). Reviewers found a plan with two statuses for one feature, a release checklist missing `test:edge`, a migration marked applied in one file and pending in another, and new ports with no documentation ([#17](https://github.com/Programcode/DebateLab/pull/17#discussion_r3896181597), [#26](https://github.com/Programcode/DebateLab/pull/26#discussion_r3897947143), [#63](https://github.com/Programcode/DebateLab/pull/63#discussion_r4081818982)). Commands in plans run on the pinned CLI version ([#29](https://github.com/Programcode/DebateLab/pull/29#discussion_r3937934134)). +The owning document changes in the same PR as the behavior it describes ([docs/README.md](../README.md#maintenance-rule) names the owners). Reviewers found a plan with two statuses for one feature, a release checklist missing `test:edge`, a migration marked applied in one file and pending in another, and new ports with no documentation ([#17](https://github.com/Programcode/DebateOwl/pull/17#discussion_r3896181597), [#26](https://github.com/Programcode/DebateOwl/pull/26#discussion_r3897947143), [#63](https://github.com/Programcode/DebateOwl/pull/63#discussion_r4081818982)). Commands in plans run on the pinned CLI version ([#29](https://github.com/Programcode/DebateOwl/pull/29#discussion_r3937934134)). ## The diff holds only this change -A diff full of unrelated files usually means the branch was cut from a stale local `main`. A branch like that once put 20 plan files into a PR, and all three review findings landed on them ([#29](https://github.com/Programcode/DebateLab/pull/29#issuecomment-5546554434)). +A diff full of unrelated files usually means the branch was cut from a stale local `main`. A branch like that once put 20 plan files into a PR, and all three review findings landed on them ([#29](https://github.com/Programcode/DebateOwl/pull/29#issuecomment-5546554434)). diff --git a/docs/standards/security.md b/docs/standards/security.md index b975c7e77..48b98152a 100644 --- a/docs/standards/security.md +++ b/docs/standards/security.md @@ -4,33 +4,33 @@ Part of the [coding standards](../../CODING_STANDARDS.md). AGENTS.md "Hard bound ## Workflows -- `npm run lint` enforces SHA-pinned actions, `persist-credentials: false`, and a top-level `permissions:` block. Review what it cannot: each permission beyond `contents: read` is the smallest that works and has a comment saying why ([#27](https://github.com/Programcode/DebateLab/pull/27#discussion_r3898115822)). -- A job that loads protected secrets runs only on trusted refs. Check `github.ref == 'refs/heads/main'` for manual dispatch, and require `workflow_run.event == 'push'` for chained workflows, because a fork PR can have a branch named `main` ([#8](https://github.com/Programcode/DebateLab/pull/8#discussion_r3861144007), [#21](https://github.com/Programcode/DebateLab/pull/21#discussion_r3896484016)). -- A manual dispatch path enforces the same build gate as the automatic one ([#26](https://github.com/Programcode/DebateLab/pull/26#discussion_r3898040143)). -- Guard steps that depend on files an earlier conditional step creates ([#8](https://github.com/Programcode/DebateLab/pull/8#discussion_r3861005583)). +- `npm run lint` enforces SHA-pinned actions, `persist-credentials: false`, and a top-level `permissions:` block. Review what it cannot: each permission beyond `contents: read` is the smallest that works and has a comment saying why ([#27](https://github.com/Programcode/DebateOwl/pull/27#discussion_r3898115822)). +- A job that loads protected secrets runs only on trusted refs. Check `github.ref == 'refs/heads/main'` for manual dispatch, and require `workflow_run.event == 'push'` for chained workflows, because a fork PR can have a branch named `main` ([#8](https://github.com/Programcode/DebateOwl/pull/8#discussion_r3861144007), [#21](https://github.com/Programcode/DebateOwl/pull/21#discussion_r3896484016)). +- A manual dispatch path enforces the same build gate as the automatic one ([#26](https://github.com/Programcode/DebateOwl/pull/26#discussion_r3898040143)). +- Guard steps that depend on files an earlier conditional step creates ([#8](https://github.com/Programcode/DebateOwl/pull/8#discussion_r3861005583)). ## Where credentials may go -- Never print an access code or token, even in a plan's example command ([#8](https://github.com/Programcode/DebateLab/pull/8#discussion_r3861005595)). -- Session state written by a test lives outside directories CI uploads as artifacts ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004119)). -- 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)). +- Never print an access code or token, even in a plan's example command ([#8](https://github.com/Programcode/DebateOwl/pull/8#discussion_r3861005595)). +- Session state written by a test lives outside directories CI uploads as artifacts ([#11](https://github.com/Programcode/DebateOwl/pull/11#discussion_r3872004119)). +- A suite that writes a real session into a browser restricts its base URL to loopback ([#21](https://github.com/Programcode/DebateOwl/pull/21#discussion_r3896484019)). +- Documentation names the secret, never the account's email or password ([#68](https://github.com/Programcode/DebateOwl/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/DebateOwl/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 These were raised in review, judged valid, and accepted on purpose. Changing them needs Adam's decision, not a reviewer's. -- Live provider keys reach branch code in `live-e2e.yml`, `model-matrix.yml` and `agentic-e2e.yml` when someone with push access dispatches them on a branch. Since 2026-10-29 these no longer run on pull requests. Adam accepted the original pull-request exposure 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#opt-in-previews) describes the current setup. This one has no recorded decision yet. +- Live provider keys reach branch code in `live-e2e.yml`, `model-matrix.yml` and `agentic-e2e.yml` when someone with push access dispatches them on a branch. Since 2026-10-29 these no longer run on pull requests. Adam accepted the original pull-request exposure on PR #68; the mitigation is dedicated low-limit keys ([#68](https://github.com/Programcode/DebateOwl/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/DebateOwl/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/DebateOwl/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/DebateOwl/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 [`src/lib/observability.ts`](../../src/lib/observability.ts) is the only path to PostHog and Sentry. -- Events use a closed allowlist of names, and string attributes accept only listed values. An open string field lets a caller send debate content ([#22](https://github.com/Programcode/DebateLab/pull/22#discussion_r3896703404)). -- Error boundaries report a fixed error with fixed context, never the caught error, whose message can contain user input ([#16](https://github.com/Programcode/DebateLab/pull/16#discussion_r3895854129)). -- Count only events that really happened. A route event fired before the auth redirect recorded denied access as a visit ([#16 review](https://github.com/Programcode/DebateLab/pull/16#issuecomment-5480853309)). +- Events use a closed allowlist of names, and string attributes accept only listed values. An open string field lets a caller send debate content ([#22](https://github.com/Programcode/DebateOwl/pull/22#discussion_r3896703404)). +- Error boundaries report a fixed error with fixed context, never the caught error, whose message can contain user input ([#16](https://github.com/Programcode/DebateOwl/pull/16#discussion_r3895854129)). +- Count only events that really happened. A route event fired before the auth redirect recorded denied access as a visit ([#16 review](https://github.com/Programcode/DebateOwl/pull/16#issuecomment-5480853309)). diff --git a/docs/standards/tests.md b/docs/standards/tests.md index b137f61a0..3cc5588f2 100644 --- a/docs/standards/tests.md +++ b/docs/standards/tests.md @@ -6,38 +6,38 @@ Part of the [coding standards](../../CODING_STANDARDS.md). [docs/testing.md](../ Before you commit a test, break the code it covers and watch it fail. Reviewers caught these patterns: -- **Matching source text.** Reading a file and asserting it contains a string passes when the logic is wrong, and fails on reformatting. Exercise the behavior instead ([#9](https://github.com/Programcode/DebateLab/pull/9#discussion_r3861496818), [#14](https://github.com/Programcode/DebateLab/pull/14#discussion_r3894645049), [#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004244)). Where a workflow or config file is the subject, parse it and assert the condition, the query, and the failure exit together ([#27](https://github.com/Programcode/DebateLab/pull/27#discussion_r3898115828)). -- **Assertions anchored to nothing.** `return false;` occurs everywhere in a file. Anchor to the function that owns the guard ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004273)). -- **Test data that never reaches the limit.** An aggregate cap test with inputs below the cap proves nothing ([#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641309), [#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649694)). -- **One side of a boundary.** Assert that 12 is accepted and 13 is rejected, with inputs that differ only in count, so a different rule cannot cause the rejection ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649674)). -- **`&&` where you meant `||`.** A leak check that requires both fields to leak passes when one does ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649704)). -- **Uncovered branches named in the title.** If the test name says "duplicate turns", assert the duplicate branch ([#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641303), [#5](https://github.com/Programcode/DebateLab/pull/5#discussion_r3835805782)). -- **`JSON.stringify` on an `Error`.** It drops `message` and `stack`, so a privacy check on the stringified call misses a leaked error. Destructure and check the error message directly ([#18](https://github.com/Programcode/DebateLab/pull/18), single finding). -- **Superseded invariants.** After a later migration changes a constraint, assert against the migration in force ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004255)). +- **Matching source text.** Reading a file and asserting it contains a string passes when the logic is wrong, and fails on reformatting. Exercise the behavior instead ([#9](https://github.com/Programcode/DebateOwl/pull/9#discussion_r3861496818), [#14](https://github.com/Programcode/DebateOwl/pull/14#discussion_r3894645049), [#11](https://github.com/Programcode/DebateOwl/pull/11#discussion_r3872004244)). Where a workflow or config file is the subject, parse it and assert the condition, the query, and the failure exit together ([#27](https://github.com/Programcode/DebateOwl/pull/27#discussion_r3898115828)). +- **Assertions anchored to nothing.** `return false;` occurs everywhere in a file. Anchor to the function that owns the guard ([#11](https://github.com/Programcode/DebateOwl/pull/11#discussion_r3872004273)). +- **Test data that never reaches the limit.** An aggregate cap test with inputs below the cap proves nothing ([#12](https://github.com/Programcode/DebateOwl/pull/12#discussion_r3893641309), [#13](https://github.com/Programcode/DebateOwl/pull/13#discussion_r3893649694)). +- **One side of a boundary.** Assert that 12 is accepted and 13 is rejected, with inputs that differ only in count, so a different rule cannot cause the rejection ([#13](https://github.com/Programcode/DebateOwl/pull/13#discussion_r3893649674)). +- **`&&` where you meant `||`.** A leak check that requires both fields to leak passes when one does ([#13](https://github.com/Programcode/DebateOwl/pull/13#discussion_r3893649704)). +- **Uncovered branches named in the title.** If the test name says "duplicate turns", assert the duplicate branch ([#12](https://github.com/Programcode/DebateOwl/pull/12#discussion_r3893641303), [#5](https://github.com/Programcode/DebateOwl/pull/5#discussion_r3835805782)). +- **`JSON.stringify` on an `Error`.** It drops `message` and `stack`, so a privacy check on the stringified call misses a leaked error. Destructure and check the error message directly ([#18](https://github.com/Programcode/DebateOwl/pull/18), single finding). +- **Superseded invariants.** After a later migration changes a constraint, assert against the migration in force ([#11](https://github.com/Programcode/DebateOwl/pull/11#discussion_r3872004255)). ## End-to-end assertions prove completion A 200 status and some rendered text do not prove a flow finished. -- Wait for content that renders only after the state you are testing resolves. `body` is visible during the auth loading state ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004087)). -- A streamed answer requires its final event, no error event, and no fallback request ([#68](https://github.com/Programcode/DebateLab/pull/68#discussion_r4090563262)). -- Assert that the secondary call you claim to cover actually happened ([#68](https://github.com/Programcode/DebateLab/pull/68#discussion_r4090563266)). -- Send the request a browser really sends. A CORS check with only an `Origin` header passed while Chromium blocked the real POST ([#68](https://github.com/Programcode/DebateLab/pull/68#discussion_r4090563272)). +- Wait for content that renders only after the state you are testing resolves. `body` is visible during the auth loading state ([#11](https://github.com/Programcode/DebateOwl/pull/11#discussion_r3872004087)). +- A streamed answer requires its final event, no error event, and no fallback request ([#68](https://github.com/Programcode/DebateOwl/pull/68#discussion_r4090563262)). +- Assert that the secondary call you claim to cover actually happened ([#68](https://github.com/Programcode/DebateOwl/pull/68#discussion_r4090563266)). +- Send the request a browser really sends. A CORS check with only an `Origin` header passed while Chromium blocked the real POST ([#68](https://github.com/Programcode/DebateOwl/pull/68#discussion_r4090563272)). ## Deterministic async tests -- Wait on conditions, not fixed sleeps ([#9](https://github.com/Programcode/DebateLab/pull/9#discussion_r3861289865)). Lint blocks `waitForTimeout` in browser tests; a disable comment must name an observation window, a period in which something must not happen. -- Before asserting that something did not happen, drain pending work, or the negative assertion runs too early and always passes ([#9](https://github.com/Programcode/DebateLab/pull/9#discussion_r3861496807)). -- Route mocks by request content, not by call order, so a retry does not shift every later response ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004072)). -- Derive expected values from the loop index, not a counter the route handler mutates concurrently ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649614)). -- Fixtures with dates compute them from `Date.now()`. A hard-coded expiry turned four invite tests red on 2026-09-27 ([#74](https://github.com/Programcode/DebateLab/pull/74#issuecomment-5859465743)). -- Replace globals with `vi.stubGlobal`, which Vitest undoes after each test. A direct assignment to `window` leaks into later tests ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649664)). -- Prefer accessible names or test IDs over positional locators such as `.first()` ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004072)). +- Wait on conditions, not fixed sleeps ([#9](https://github.com/Programcode/DebateOwl/pull/9#discussion_r3861289865)). Lint blocks `waitForTimeout` in browser tests; a disable comment must name an observation window, a period in which something must not happen. +- Before asserting that something did not happen, drain pending work, or the negative assertion runs too early and always passes ([#9](https://github.com/Programcode/DebateOwl/pull/9#discussion_r3861496807)). +- Route mocks by request content, not by call order, so a retry does not shift every later response ([#11](https://github.com/Programcode/DebateOwl/pull/11#discussion_r3872004072)). +- Derive expected values from the loop index, not a counter the route handler mutates concurrently ([#13](https://github.com/Programcode/DebateOwl/pull/13#discussion_r3893649614)). +- Fixtures with dates compute them from `Date.now()`. A hard-coded expiry turned four invite tests red on 2026-09-27 ([#74](https://github.com/Programcode/DebateOwl/pull/74#issuecomment-5859465743)). +- Replace globals with `vi.stubGlobal`, which Vitest undoes after each test. A direct assignment to `window` leaks into later tests ([#13](https://github.com/Programcode/DebateOwl/pull/13#discussion_r3893649664)). +- Prefer accessible names or test IDs over positional locators such as `.first()` ([#11](https://github.com/Programcode/DebateOwl/pull/11#discussion_r3872004072)). ## Name what the test proves -A test that stubs the session and the network proves request construction, not backend behavior. Name it that way and keep fixture text neutral ([#65](https://github.com/Programcode/DebateLab/pull/65#discussion_r4082499616)). AGENTS.md states the general rule: stub sessions prove rendering only. +A test that stubs the session and the network proves request construction, not backend behavior. Name it that way and keep fixture text neutral ([#65](https://github.com/Programcode/DebateOwl/pull/65#discussion_r4082499616)). AGENTS.md states the general rule: stub sessions prove rendering only. ## Mock providers follow the contract -The mock provider applies the same rules the real path does, such as the contention count per length ([#9](https://github.com/Programcode/DebateLab/pull/9#discussion_r3861289845)). Otherwise tests and demos pass on output that production would reject. +The mock provider applies the same rules the real path does, such as the contention count per length ([#9](https://github.com/Programcode/DebateOwl/pull/9#discussion_r3861289845)). Otherwise tests and demos pass on output that production would reject. diff --git a/docs/standards/ui.md b/docs/standards/ui.md index d1c9e5a63..074cbdd1d 100644 --- a/docs/standards/ui.md +++ b/docs/standards/ui.md @@ -4,9 +4,9 @@ Part of the [coding standards](../../CODING_STANDARDS.md). [docs/architecture.md ## Every string is translated -- 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)). The glossary below is the one name per language for each tool, role, and format. +- 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/DebateOwl/pull/11#discussion_r3872004217), [#43](https://github.com/Programcode/DebateOwl/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/DebateOwl/pull/12#discussion_r3893641290)). +- Use the localized feature name everywhere in a catalog: "Interrogatorio", "Keresztkérdezés" ([#12](https://github.com/Programcode/DebateOwl/pull/12#discussion_r3893641268), [#13 review](https://github.com/Programcode/DebateOwl/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. @@ -29,24 +29,24 @@ Part of the [coding standards](../../CODING_STANDARDS.md). [docs/architecture.md ## Accessible names -- Icon-only links and buttons get an `aria-label`. The nav label hides below `xl`, which left the link nameless ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3894071703)). -- Repeated row actions include the row's name ("Delete "), so screen readers and role queries can tell them apart ([#19](https://github.com/Programcode/DebateLab/pull/19#discussion_r3896430613)). -- A label for a group of buttons uses `role="group"` and `aria-labelledby` ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004206)). -- Lists rendered from model output use index keys, because model strings can repeat ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004180)). +- Icon-only links and buttons get an `aria-label`. The nav label hides below `xl`, which left the link nameless ([#13](https://github.com/Programcode/DebateOwl/pull/13#discussion_r3894071703)). +- Repeated row actions include the row's name ("Delete <title>"), so screen readers and role queries can tell them apart ([#19](https://github.com/Programcode/DebateOwl/pull/19#discussion_r3896430613)). +- A label for a group of buttons uses `role="group"` and `aria-labelledby` ([#11](https://github.com/Programcode/DebateOwl/pull/11#discussion_r3872004206)). +- Lists rendered from model output use index keys, because model strings can repeat ([#11](https://github.com/Programcode/DebateOwl/pull/11#discussion_r3872004180)). ## Motion -When `shouldReduceMotion` is true, render a static element. Passing the flag to the phase but not to `initial`/`animate` still animates ([#20](https://github.com/Programcode/DebateLab/pull/20#discussion_r3896378557)). +When `shouldReduceMotion` is true, render a static element. Passing the flag to the phase but not to `initial`/`animate` still animates ([#20](https://github.com/Programcode/DebateOwl/pull/20#discussion_r3896378557)). ## Tell the user when the app refuses -- When a store action rejects input (too long, expired, invalid), the UI shows why. A submit button that silently does nothing looks broken ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649646)). -- Clear a composer only after the handler accepts the message, so a rejected draft is not lost ([#66 outside diff](https://github.com/Programcode/DebateLab/pull/66)). -- Enforce rules such as deadlines in the store, and add the UI guard as a second layer ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649625)). +- When a store action rejects input (too long, expired, invalid), the UI shows why. A submit button that silently does nothing looks broken ([#13](https://github.com/Programcode/DebateOwl/pull/13#discussion_r3893649646)). +- Clear a composer only after the handler accepts the message, so a rejected draft is not lost ([#66 outside diff](https://github.com/Programcode/DebateOwl/pull/66)). +- Enforce rules such as deadlines in the store, and add the UI guard as a second layer ([#13](https://github.com/Programcode/DebateOwl/pull/13#discussion_r3893649625)). ## Pending actions -- 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)). -- 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)). +- 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/DebateOwl/pull/26#discussion_r3897947151)). +- Side effects such as scrolling run on a success signal, not on "loading became false" ([#20](https://github.com/Programcode/DebateOwl/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/DebateOwl/pull/63#discussion_r4081818952)). +- 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/DebateOwl/pull/20#discussion_r3896378566)). diff --git a/model-context/Reader API.pdf b/model-context/Reader API.pdf deleted file mode 100644 index 68a982ee9..000000000 Binary files a/model-context/Reader API.pdf and /dev/null differ diff --git a/ops/staging-sync/debatelab-staging-sync.service b/ops/staging-sync/debatelab-staging-sync.service index 0eb0b8b2b..1215dc4bc 100644 --- a/ops/staging-sync/debatelab-staging-sync.service +++ b/ops/staging-sync/debatelab-staging-sync.service @@ -1,6 +1,6 @@ [Unit] Description=Sync DebateLab staging Supabase with main -Documentation=https://github.com/Programcode/DebateLab/blob/main/docs/staging.md +Documentation=https://github.com/Programcode/DebateOwl/blob/main/docs/staging.md Wants=network-online.target After=network-online.target diff --git a/ops/staging-sync/run.sh b/ops/staging-sync/run.sh index fa1d12958..32a314df1 100755 --- a/ops/staging-sync/run.sh +++ b/ops/staging-sync/run.sh @@ -14,7 +14,7 @@ exec 9>"$BASE/lock" flock -n 9 || { echo "Another sync is running."; exit 0; } if [ ! -d "$REPO/.git" ]; then - git clone --quiet https://github.com/Programcode/DebateLab.git "$REPO" + git clone --quiet https://github.com/Programcode/DebateOwl.git "$REPO" fi git -C "$REPO" fetch --quiet origin main sha="$(git -C "$REPO" rev-parse origin/main)" @@ -35,10 +35,10 @@ fail_query() { exit 1 } busy=0 -runs="$(gh run list --repo Programcode/DebateLab --workflow staging-e2e.yml --status in_progress --limit 100 \ +runs="$(gh run list --repo Programcode/DebateOwl --workflow staging-e2e.yml --status in_progress --limit 100 \ --json databaseId --jq '.[].databaseId')" || fail_query for id in $runs; do - testing="$(gh run view "$id" --repo Programcode/DebateLab --json jobs \ + testing="$(gh run view "$id" --repo Programcode/DebateOwl --json jobs \ --jq '[.jobs[] | select(.name == "staging-e2e" and .status == "in_progress")] | length')" || fail_query [ "$testing" = "0" ] || busy=1 done diff --git a/package-lock.json b/package-lock.json index 4591a4077..a0636cfa6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "vite_react_shadcn_ts", + "name": "debateowl", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "vite_react_shadcn_ts", + "name": "debateowl", "version": "0.0.0", "workspaces": [ "packages/*" diff --git a/package.json b/package.json index b82f35367..bbb0ab701 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,14 @@ { - "name": "vite_react_shadcn_ts", + "name": "debateowl", "private": true, + "license": "SEE LICENSE IN LICENSE", + "description": "DebateOwl: AI debate prep and practice for competitive debaters.", + "homepage": "https://debateowl.com", + "repository": { + "type": "git", + "url": "git+https://github.com/Programcode/DebateOwl.git" + }, + "author": "Ádám Gáborjáni", "version": "0.0.0", "type": "module", "engines": { diff --git a/plans/2026-09-20_131317-debate-workspace.md b/plans/2026-09-20_131317-debate-workspace.md deleted file mode 100644 index e1fbfabf3..000000000 --- a/plans/2026-09-20_131317-debate-workspace.md +++ /dev/null @@ -1,475 +0,0 @@ -# Debate workspace implementation plan - -**Status:** On hold and superseded by [the reduced-scope plan](2026-09-21-debate-workspace-no-bs.md). Collaborative Workspace is hidden in production. Kept as evidence. - -> For agentic workers: use `superpowers:executing-plans` or, when delegated execution is authorized, `superpowers:subagent-driven-development`. This is a master plan. Execute one reviewed slice at a time and keep its checkboxes current. Planning does not authorize implementation or deployment. - -**Goal:** Give a school debate squad one persistent, collaborative place to prepare both sides of a motion, develop speeches, share evidence, use AI, practise, review feedback, and export its work. - -**Architecture:** Keep Supabase as the authority for identity, workspace membership, metadata, durable documents, discussions, and audit records. Use Tiptap with Yjs for rich collaborative documents and a small self-hosted Hocuspocus service for authenticated synchronization, persistence acknowledgements, and serialized document commands. Keep existing personal tools working and connect them through an explicit workspace context. - -**Tech stack:** Existing React 18, TypeScript, Vite 7, React Router 7, Zustand 5, TanStack Query 5, Tailwind 3, Radix components, Lucide, Zod 4, Supabase, Deno Edge Functions, Vitest, Playwright, and pgTAP. Proposed additions are pinned Tiptap/ProseMirror collaboration packages, Yjs, Hocuspocus, and an audited IndexedDB provider. Keep Node 22 and npm with the committed lockfile. - -**Spec:** Sections 2 through 10 of this file are the product and technical specification. The complete request is captured here; implementation must not depend on the original conversation. - -## 1. Audience, ownership, evidence, and boundaries - -Owner: Adam. Audience: the implementation lead, bounded feature workers, security reviewer, release operator, and school pilot testers. Target repository: DebateLab. Planning checkout: `/home/codex/.t3/worktrees/DebateLab/debate-workspace-plan`, branch `plan/debate-workspace`, inspected base `b2ba178dee148d06d9bf89793f044add69d705b8`, dated 2026-09-20. A future executor must run `git rev-parse --show-toplevel`, inspect branch and status, and revalidate this plan against its current base before editing. Do not reuse paths from another checkout by assumption. - -This deliverable is planning only. Implementation, commits, pushes, PR actions, deployment, production migrations, secrets, email delivery, and purchases are separate actions requiring the applicable authorization. A future implementation approval does not authorize production changes. Keep existing user changes and sibling worktrees intact. This task creates one Markdown file only. - -Prerequisites for implementation are an authorized isolated checkout, Node 22, npm, Docker for database tests, Supabase CLI at least 2.115, and Deno as pinned by CI. Deployment additionally needs an approved long-lived WebSocket host, TLS and proxy configuration, a staging project, the Google OAuth application's approved redirect URIs, a secrets store, and an approved transactional email sender. Missing deployment credentials must not stop local implementation, but they block the corresponding live proof. - -Human inputs needed before a real pilot are operational, not missing product design: approve the hosting cost/target, configure Google credentials and consent through private channels, approve invitation email sender/domain, identify a school test squad and access to two Google test accounts, and approve production release. Default retention and limits below are usable product decisions. Review school/privacy obligations with the operator before collecting students' real data. No paid editor purchase is assumed. - -Source inspection establishes these facts: - -- `src/App.tsx` lazy-loads route tools. `src/components/layout/AppLayout.tsx` owns the app navigation. Workspace navigation does not yet exist. -- `src/lib/stores/session-store.ts` owns personal results, saved chats, active sources, source folders, and hydration. Saved chat creation writes a chat and its messages; it is not a shared streaming conversation log. Do not turn this store into the workspace document database. -- `src/lib/supabase/AuthProvider.tsx` resets/hydrates personal state across account changes. It also removes historical locally persisted Google credentials. New workspace caches must participate in this account boundary. -- `src/lib/stores/sparring-store.ts` and `src/lib/crossfire/` hold separate practice state. `src/pages/HistoryPage.tsx` loads saved content into route-specific stores. Existing handoffs are useful affordances, not a collaboration model. -- `supabase/migrations/20260129000000_init.sql` establishes user-owned cases, attacks, brainstorms, factoids, chats, messages, sources, and source files. `supabase/migrations/20260520000001_idempotency_and_sharing.sql` adds snapshot sharing. `supabase/functions/share/index.ts` accepts supplied item data and creates share codes. Public snapshot links must never become workspace invitations or live document authorization. -- `supabase/migrations/20260822000001_source_provenance_metadata.sql` and `SourceProvenance` in the session store provide URL, source type, quality, query, and capture-origin fields worth preserving. -- `src/lib/source-file-import.ts` already uses Mammoth and PDF.js. Its DOCX path extracts plain text and truncates oversized extracted source text. That behavior is unsuitable for importing a student's complete formatted speech without a preview and explicit decision. -- `src/lib/export-utils.ts` dynamically imports `docx` and passes a short-lived Google provider token to `export-gdoc`. `src/lib/pdf-export-utils.ts` already owns PDF generation. `src/components/features/export/GoogleDriveAuthModal.tsx` currently requests Drive access through app sign-in. `supabase/functions/export-gdoc/index.ts` explicitly rejects browser refresh tokens and handles generate/attack/brainstorm/factoids output. There is no durable multiple-Google-account connector. -- `src/lib/ai/client.ts` is the browser AI boundary. It currently gathers active personal sources and autosaves discovered sources into the personal library. Workspace calls must use an explicit target to avoid leaking private sources or saving shared research into the wrong library. -- `supabase/functions/ai/index.ts`, `supabase/functions/ai-stream/index.ts`, and their `_shared` modules enforce auth, model controls, research, usage, and accounting. Keep these controls authoritative. -- The installed tree includes `docx`, `mammoth`, `react-markdown`, `jspdf`, and `jspdf-autotable`; it does not include a rich editor, CRDT, or collaboration server. `react-markdown` is a renderer, not a collaborative authoring solution. -- `src/locales/en.json`, `src/locales/es.json`, and `src/locales/hu.json` are the supported locale files. `docs/testing.md`, `docs/operations.md`, `.github/workflows/deploy.yml`, and `.github/workflows/staging-e2e.yml` define the actual verification and release boundaries. `plans/README.md` has historical status labels and must not override source evidence. - -## 2. Product contract and executive decisions - -The workspace is the squad. Do not add a permanent Team entity, opponent roster, organization hierarchy, or team-transfer workflow. Creating a workspace asks for a motion and optional short title, then atomically creates owner membership and eight files. The owner is an editor with one extra power, workspace deletion. All collaborators edit all files, invite people, and remove non-owner members. An assignee denotes responsibility, never access. - -Each workspace has canonical sides `pro` and `con`. Spanish labels are `A favor` and `En contra`; English defaults are `For` and `Against`; Hungarian defaults are `Mellette` and `Ellene`. Store side identifiers, not translated labels. Each side begins with `Intro`, `R1`, `R2`, and a localized `Conclusion` default. Users may rename titles without altering document identity or side. Generated default names follow the workspace's initial language; changing app language does not rename user content. - -Workspaces remain visible in an account's searchable list until that person hides them or loses access. Starting another workspace never alters earlier memberships. Hide is a per-user preference with an obvious Hidden filter and does not revoke membership. Archive is a shared reversible status that moves a completed debate out of Active and permits reading/export; any member can reopen it. Show explicit archive confirmation and preserve old work under All workspaces. Removal is a deliberate access action and is the exception to continued old-workspace access. - -Files support rename, ordered placement within a side, move between sides, assignment, duplication, archive, and trash. Reorder uses a transaction over the side's ordered IDs with an expected metadata revision; retry visibly on concurrent reorder rather than overwriting. Integer positions are sufficient for dozens of files. Duplication copies current accepted content and citations into a new document ID, clears assignment, and records provenance; it does not copy active comments, proposals, or version history as if they happened on the duplicate. Archive keeps content and history. Trash lasts 30 days, is restorable by any member, and retains assignment when still valid. Permanent file purge is a scheduled retention operation after the recovery window, never a casual editor button. A citation to a trashed source keeps its immutable bibliographic snapshot. - -Every member can remove another non-owner member, including themselves through an explicit access action, but ordinary navigation offers Hide rather than Leave. Nobody can remove the owner. An owner can transfer ownership to an existing member after that member accepts. The old owner remains an editor. Transfer atomically changes owner identity and validates both memberships. An owner deleting their account must transfer owned shared workspaces or explicitly delete them first. Use a database constraint/trigger to prevent raw account deletion from cascading shared work; do not rely on the Settings page alone. A forced administrative erasure needs an approved transfer/deletion runbook. Never silently assign ownership to the oldest member. - -Workspace deletion is owner-only, with motion/title confirmation and a 30-day recovery window. Mark deleted and reject collaboration before acknowledging success. The owner can restore during that window. Members see an unavailable/deleted explanation, not a blank editor. After purge, content is gone according to the published retention policy and backup expiration; retain only minimized operational tombstones needed for idempotency and abuse handling. - -Personal snippets and templates are independent account content. A snippet is a reusable fragment; a template is a named full speech structure. Both use the supported document schema and owner-only RLS. Inserting creates an independent copy, with no later synchronization. Saving selected workspace text to personal content requires an explicit action explaining that the user keeps that copy outside the squad. Never share a user's whole template library with teammates or AI automatically. - -Non-goals: Google two-way synchronization, permanent teams, opposing-team collaboration, a generalized file drive, arbitrary third-party editor plugins, spreadsheet editing, LMS administration, live audio/video, and a new personal-skill scoring system. Practice feedback remains connected and useful, but this plan does not invent a cross-debate ranking algorithm. - -## 3. Architecture and technical gate - -### 3.1 Recommended editor and service boundary - -Use Tiptap's open-source ProseMirror editor with Yjs and the Hocuspocus provider. Begin with paragraphs, headings, bold, italic, underline, strike, lists, blockquotes, links, tables, inline citation references, and the marks/nodes required by suggestions. Support Markdown paste, keyboard shortcuts, import, and export. The canonical content is a versioned structured editor document encoded in Yjs, not a Markdown string. A Markdown source view must apply an explicit reviewed replacement and warn about rich features it cannot represent. Do not run two independently mutable canonical formats. - -Keep the editor behind a lazy workspace route and retain existing Tailwind/Radix/Lucide tokens. Use a document-focused layout, restrained motion, and readable dense controls. No design-system replacement, GSAP dependency, new icon family, or marketing-style page is needed. - -Yjs handles concurrent edits and reconnection. Disable the editor's ordinary history extension and use collaboration-aware undo with local transaction origins. Undo must undo the current user's edits, not a teammate's recent insertion. Selection/cursor state uses awareness and expires on disconnect. Derive identity and display metadata from authenticated membership; never trust a browser-supplied user ID as attribution. - -Hocuspocus runs as one separately deployable Node service initially. It owns document sessions and mutation ordering, not billing or a second account system. Supabase owns durable state. Deploy one active writer instance until the tested locking design permits more. Obtain a database advisory lock per loaded document with a dedicated database session; do not use transaction-pooling connections for session locks. Losing the lock/connection must terminate that document's writable session. This prevents split-brain during restarts and rolling deployment. Do not add Redis or a fleet before a measured need. - -A small service is justified because character collaboration, durable acknowledgements, server-side proposal acceptance, authenticated attribution, and removal of connected users require more than ephemeral broadcast. Do not host this persistent document process in an ordinary request-limited Edge Function. Edge Functions remain the boundary for AI, Google, and external calls. - -### 3.2 Persistence is a release gate - -Do not display Saved when an update has only reached a socket. The gateway must persist the accepted binary update and an idempotent client mutation ID, with a server sequence and authenticated author, before emitting a durable acknowledgement. The client keeps pending local updates until that acknowledgement. Yjs synchronization messages alone do not prove database persistence. The technical spike must demonstrate the exact Hocuspocus hooks needed to validate, durably append, apply, and broadcast in that order, including synchronization-handshake updates. If available hooks cannot guarantee this without a fragile fork, stop and compare a maintained alternative provider/server or an approved managed collaboration service. Do not quietly ship debounced persistence with a misleading Saved label. - -For each update, serialize by document, validate epoch and schema, authorize the current member, enforce byte/rate limits, apply to a bounded temporary document to validate resulting content, persist, then publish. Invalid state must never poison the live in-memory document. This costs work but closes the trust boundary. Deduplicate retries by document/epoch/client mutation ID; reject reuse with different payload bytes. Authenticated author identity comes from the connection, never Yjs client IDs. Use one small persisted update batch per client roughly every 100-250 ms, with a maximum one-second flush interval under typing. Tune from measurements without weakening durability. - -Persist periodic compacted binary state plus update tail and a derived current JSON/text projection at the same sequence. Compaction locks the document, captures sequence N, writes a verified state at N, and prunes only tail entries at or below N after successful commit and backup policy checks. Never derive a fresh Y.Doc from JSON on every reload; that loses CRDT identities. User-visible history stores independent complete states and does not depend on keeping every low-level update forever. - -Create automatic history versions after five minutes of changed durable content and when a document becomes idle for one minute, coalescing near-duplicates. Always create versions for named saves, before/after restoration, accepted proposals, and import. Keep automatic versions for 90 days, then a daily version for one year; keep named/import/restore versions until document purge. Show these rules in history. Deduplicate identical content hashes. Capture only durable content, never awareness. - -Restore is a new recorded revision, not deletion of history. Acquire the document lock, compare the current sequence shown in the preview, create a pre-restore version, increment a document epoch, initialize the selected content in that new epoch, and reconnect clients. Outstanding old-epoch operations must not merge back and undo the restoration. Preserve their local draft in a recovery view and offer a new reviewed proposal/copy. This intentional epoch boundary also makes incompatible schema migration safe. Undo history resets with a clear notice after restore. Comments remain historical and show detached anchors; proposals against the prior epoch become stale. Never claim that old anchors automatically belong to restored text. - -### 3.3 Alternatives and evidence - -- Last-write-wins database updates cannot satisfy simultaneous character editing or collaborative undo. Reject them as the core editor protocol. -- OT is possible but requires an authoritative transform service and editor-specific operations. Yjs already covers offline merge and the chosen editor bindings; a custom OT engine adds risk without a product benefit. -- Supabase Broadcast transports events but is not a durable CRDT log, a merge algorithm, or a strict revocation mechanism. Use it only for minimal invalidation hints if useful. Do not build a custom collaborative provider just to avoid one service. -- A raw ProseMirror implementation gives control but requires more authoring UX work. Lexical or a maintained higher-level editor is the fallback if the spike fails compatibility or suggestion requirements. Switching is a conscious architecture review, not parallel implementation of several editors. -- Commercial Tiptap comments/history/tracked-change offerings must not be mistaken for features included in the open-source core. The snapshot extension is documented in a private registry. Implement the bounded product-specific review model below using stable editor APIs, or propose an explicitly priced dependency only after the spike proves maintaining it would be worse. - -Primary documentation checked during planning: [Tiptap collaborative editing](https://tiptap.dev/docs/hocuspocus/guides/collaborative-editing), [Hocuspocus hooks](https://tiptap.dev/docs/hocuspocus/server/hooks), [Hocuspocus persistence](https://tiptap.dev/docs/hocuspocus/guides/persistence), [Tiptap snapshot extension](https://tiptap.dev/docs/collaboration/documents/snapshot), and [Yjs UndoManager](https://docs.yjs.dev/api/undo-manager). These support the selection, but exact package versions and hook ordering must be proven against locked versions in Phase 0. No package version is invented here. - -## 4. Data model, APIs, and authorization - -### 4.1 Additive schema - -Use new workspace tables instead of making every existing personal table polymorphic. Workspace-owned rows survive the departure of their original contributor. Every content row has workspace ID, creator attribution, timestamps, and where mutable a revision. Composite foreign keys ensure child IDs belong to the supplied workspace. Index membership by user, active files by workspace/side/order, and versions/messages by parent and sequence. Generate migration names with `supabase migration new` during implementation. - -Proposed relations: - -- `workspaces`: ID, owner ID, motion, title, initial language, active/archived/deleted state, access epoch, revision, timestamps, deleted timestamp. Owner FK blocks accidental user cascade. -- `workspace_members`: workspace/user composite key, joined timestamp. No viewer/editor role column; all members edit. Removal deletes active membership and produces an audit event. `workspace_preferences`: workspace/user, hidden, last-opened, last-document; only that user edits preferences. -- `workspace_invites`: workspace, digest of random token/code, kind, optional encrypted target email and normalized email keyed hash, issuer, expiry, max uses, use count, revoked timestamp. Keep secret invite values and email targets outside general member-selectable views. Return an invitation code once at creation. -- `workspace_documents`: ID, workspace, side, title, order, assignee, state, epoch, latest durable sequence, schema version, import provenance, timestamps. Validate assignee membership in the same transaction; clear assignment on member removal. -- Private `document_states`, `document_updates`, and `document_versions`: binary Yjs state/tail, durable sequence, author, mutation ID, schema/epoch, hash, projections, and version metadata. Only the gateway writes raw CRDT state. Authorized member read APIs expose history previews, not unrestricted private-schema access. -- `workspace_threads` and `workspace_messages`: named thread, optional topic/document/side context, ordered append-only messages, requester and model attribution, request ID, streaming/final/interrupted status, bounded output. Use unique request/message IDs for retries. Human messages remain attributable; revisions show edited/deleted notices rather than silently rewriting chat history. -- `workspace_sources` and `workspace_source_folders`: copy the useful current source shape and provenance fields, plus canonical URL/content hash, bibliographic metadata, source revision, annotations, archived timestamp. Avoid a forced migration of private sources. Separate source annotations may be rows keyed to source and author when threads are needed. -- `workspace_comments` and `workspace_comment_replies`: document/epoch, relative-position anchors, quoted context, author, body, resolved state, timestamps; replies immutable except author's explicit edit with revision. Mentions are parsed member IDs, not free-form email lookups. `workspace_notifications`: recipient, event ID, read timestamp, minimal payload. -- `workspace_proposals` and `workspace_proposal_hunks`: document/epoch/base sequence/hash, human or AI origin, creator/request reference, selected content and relative anchors, original/replacement structured fragments, status, accept/reject actor and sequence. Store server-validated payloads, never executable editor commands supplied by a model. -- `personal_templates`: user ID, kind snippet/template, title, content/schema version, tags, revision, timestamps. No workspace ID. Independent copies on insert. -- `workspace_artifacts`: workspace, tool, author, immutable validated tool input/output snapshot, related document/version/source IDs, timestamps. Use for generated cases, attacks, brainstorms, factoids, flowcharts, practice snapshots, and feedback. Do not reuse a foreign user's private result ID as shared authorization. -- `workspace_audit_events`: append-only operation ID, authenticated actor, workspace/document, action, before/after revision IDs, timestamp. General events contain no document body or OAuth secrets. Detailed content stays in authorized history. -- Private `google_connections`: user, Google subject, display email, granted scopes, encrypted refresh token, token-key version, revoked/reauth status. Unique user/Google-subject pair. Private `google_oauth_attempts`: single-use state digest, PKCE verifier, initiating app user/session, nonce, permitted return route, expiry. Member-visible exports never expose these rows. - -### 4.2 API contracts - -Expose a small number of named operations, not a generic arbitrary-table mutation endpoint. Client inputs use Zod; server inputs are independently validated. Missing membership returns a uniform unavailable response without disclosing motion/title. - -- `create_workspace({ motion, title, language, requestId })` atomically creates owner/member/eight defaults and returns workspace ID. Retrying returns the original workspace. -- `change_workspace_access({ workspaceId, action, targetUserId?, inviteId?, expectedRevision, requestId })` validates current membership and owner restrictions in one transaction. Code/email invite creation and redemption are server endpoints with rate limits and atomic use accounting. -- `update_workspace_document({ workspaceId, documentId, title?, side?, orderedIds?, assigneeId?, state?, expectedRevision, requestId })` handles metadata only. It cannot modify document content. -- Collaboration handshake sends a bearer credential in the authenticated protocol message, never a URL query string. Document identity includes document ID and epoch. Gateway resolves workspace membership itself. A client mutation carries mutation ID, epoch, bounded binary update, and expected schema version; durable acknowledgement returns mutation ID and server sequence. -- `restore_document({ documentId, versionId, expectedSequence, requestId })` and `decide_proposal({ proposalId, hunkIds, decision, expectedProposalRevision, requestId })` execute through the same serialized gateway authority as edits. -- Workspace AI request accepts workspace ID, thread ID, optional document/selection references, explicit context selections, operation, and request ID. It never accepts a browser-asserted membership or another account's personal-source payload as trusted context. -- Import/export accepts explicit connection ID for Google operations, target IDs, format/options, and idempotency key. The handler loads authorized content itself for workspace exports. - -### 4.3 RLS and connected-client removal - -Enable RLS on every exposed table and grant only required operations to `authenticated`; no workspace access for `anon`. Membership is queried from current database rows, not editable metadata or stale JWT claims. A narrowly scoped membership helper may need `SECURITY DEFINER` to avoid recursive membership RLS. Put it in a private schema, fix search path, qualify objects, verify `auth.uid()`, revoke PUBLIC/anon execution, and grant only the required call path. Security-invoker APIs remain the default. Every UPDATE checks both previous and resulting scope; immutable scope columns cannot be moved between workspaces. - -Prevent direct member-table mutation, owner changes, invitation use-count updates, audit insertion, and proposal acceptance from browsers. Use audited transactional functions or handlers. Do not expose private CRDT tables, token ciphertext, or provider credentials through views. Views returning user data must be security-invoker or deliberately restricted. - -The gateway checks current membership and epoch before each accepted update and each content delivery. Access-changing transactions increment workspace access epoch and write an outbox event. After removal commits, purge that user's subscriptions, awareness, queued outbound content, and writable sessions. Database authorization and the update append share a lock/order with membership mutation so a removed user cannot win a race after revocation. Broadcast delivery must use the same serialized access boundary or recheck membership before sending. Prove no new content after revocation acknowledgement with an already-connected hostile client, not only a fresh-login test. Existing downloaded text cannot be remotely erased; explain that fact in access management. - -If Supabase Realtime is used for metadata invalidation, channels are private and topics include workspace ID/access epoch. Policies on `realtime.messages` require current membership and the exact topic; client publishing is denied unless a narrowly specified event needs it. Payloads contain opaque IDs/revisions only, never document/chat text, email addresses, or invite secrets. Rotate topics on access changes and fetch all actual content through fresh RLS. Supabase's documented channel-join authorization is not proof of immediate revocation. See [Realtime authorization](https://supabase.com/docs/guides/realtime/authorization). - -Email invites return the same message whether the address has an account. Redeem requires a logged-in user whose verified email matches the invite target. Do not use user metadata as email verification. Default email tokens have at least 128 bits entropy, are single use, and expire after seven days. Shareable workspace codes use at least 80 bits entropy, expire after 48 hours, and allow up to the remaining workspace member capacity. Any member can rotate/revoke codes. Removal rotates live codes to prevent immediate re-entry with a remembered code; an explicit new invite can restore membership. Limit invite creation to 20 per member/hour and 50 per workspace/day, redemption failures to 10 per actor/IP/15 minutes, and enforce workspace member cap 30. Use generic errors and avoid account enumeration through timing or search. Quotas fail closed if the limiter is unavailable. - -The existing `clear_current_user_data()` clears personal session data. Extend its product copy and tests so clearing personal data never erases shared workspaces. Offer explicit personal template deletion and Google disconnect separately. Account deletion follows the owner-transfer rule and removes personal connector secrets and notifications while anonymizing retained shared authorship where legally appropriate. - -## 5. Editing, suggestions, comments, and AI change safety - -Human suggestion mode and AI suggestions share the same review object but differ in creation. A human toggles Suggesting in the editor; typing, deletion, and formatting produce visible pending hunks against accepted content, with author attribution. Teammates see suggested insertions/deletions and can reply. The editor may use marks/decorations and a temporary suggestion branch, but proposed text must not become accepted canonical content simply because it is rendered inline. Concurrent edits inside suggestions, IME composition, paste, lists, link changes, and overlap require spike coverage before adoption. Direct editing remains available to all members, so suggestion mode is a collaboration convention, not a permission restriction. - -Comments use Yjs relative positions plus document epoch and quoted context. If the anchor range is deleted, show the comment under Detached comments with its quote. Do not attach it to arbitrary nearby text. Replies form one-level chronological threads; resolve/reopen is available to members and audited. Only the author edits their comment text; other members can resolve it and report abuse. Workspace mentions offer only current members, notify in-app once per event, and link to the specific thread. Email mention notifications are opt-in and never reveal content to a removed recipient. - -AI has no document-write credential or tool. It returns schema-validated proposals. Each hunk shows original text, insertion/deletion/replacement, author/model, target file, and explanation. Users can accept/reject a hunk, selected independent hunks, or the whole proposal. Formatting and citation changes are visible in the diff. Rejecting changes no document. Whole-proposal acceptance is atomic; if any hunk is stale, accept none and explain which targets changed. Partial acceptance applies only selected valid non-overlapping hunks, with one auditable operation. - -Acceptance acquires the same document lock as ordinary updates. Re-read membership, document epoch, proposal state, relative anchors, exact original structured fragment, and surrounding context/hash. If unrelated content moved, resolve the anchors and accept when the target is still exact. If target content changed, the anchor disappeared, the epoch changed, or another proposal overlaps, mark stale. Do not use fuzzy string replacement. Offer Regenerate against current text and Copy proposed text; a regeneration creates a fresh proposal and never implicitly accepts it. Compare proposal revision and use a unique decision operation ID so simultaneous reviewers accept at most once. Persist document update, decision, and audit attribution atomically; an accepted marker without its document edit is invalid. - -Audit distinguishes model/requester, human acceptor, and resulting sequence. Server-applied proposals do not enter every client's local undo stack. Offer an explicit Revert accepted change action producing another reviewed inverse proposal; if its target changed, it is stale. A normal user's own typing still uses local collaboration-aware undo/redo. Do not promise that Ctrl-Z will revert arbitrary teammates' accepted proposals. - -## 6. Shared chat, AI context, sources, and personal reuse - -Workspace AI threads are shared, named, searchable, and grouped by purpose through optional labels such as Research, Drafting, and Coaching. Thread names remain user-editable. Show the whole-workspace visibility notice before first send. Persist a human message and request ID before generation; append a server-owned assistant message and checkpoints, then finalize or mark interrupted. Multiple human sends queue per thread with visible status so two clients cannot overwrite one response. Cancellation is explicit and attributed; allow the requester to cancel their run, and retain its partial response marked incomplete. A teammate arriving halfway through a stream receives persisted partial content and subsequent updates. Closing the initiating browser must not silently lose the shared conversation; if the chosen server execution cannot continue, persist interrupted state and offer retry. - -Private Ask remains private. New workspace UI does not load the personal `askMessages` store. Explicitly copying a private conversation into a workspace previews exactly what will be shared. Tool invocations carry a discriminated context: private account, or workspace/thread/document/side. Route change, account change, or workspace change cancels stale client work and prevents late results from updating the new context. - -AI context is assembled server-side from authorized, durable versions. Any workspace document/source/thread is eligible; eligibility does not mean every token is included. Default priority is instruction and selected fragment, current document, current thread's recent messages, pinned sources, then relevant passages from both sides and other named threads. Begin with deterministic full-text search and bounded excerpts; do not add a vector service before retrieval evaluation shows a need. Users see the selected documents/sources and omitted/truncated items, can pin/exclude context for this request, and can ask for another side explicitly. - -Initial maximum input context is 24,000 tokens or 60% of the chosen model window, whichever is smaller, after reserving system instructions and output. Use a verified model-appropriate tokenizer or a conservative byte-bound fallback with server provider-limit protection. Allocate 40% to current document/selection, 25% to selected sources, 20% to recent thread context, and 15% to retrieved workspace passages; unused budget can flow to the next priority. Summaries carry source revision IDs and are invalidated after edits. No unsupported claim of complete-workspace review when material was omitted. Context manifests record IDs, revisions, hashes, token counts, and selection reasons without copying text into logs. - -Treat imported text, evidence, chat, and AI-generated text as untrusted prompt data. They cannot change system instructions, authorize network requests, choose arbitrary provider models, or modify membership. Reuse `_shared` URL validation and research limits. Recheck membership before persisting/delivering an AI response. Quota and model entitlement are charged to the requesting user, never silently to the owner; shared visibility does not grant another member's Pro access. Add per-workspace request concurrency limits as abuse protection while retaining existing per-user accounting. A retry uses a new attempt under the original request lineage and must not duplicate a completed message or hide charge attribution. - -Shared sources reuse current provenance concepts and quality warnings. Copy personal evidence into a workspace explicitly; preserve original source ID as provenance without granting private-row access. Organize through folders and tags, searchable title/content, and per-user context selection. Do not make a shared global `isActive` checkbox toggle everyone's AI context. - -Deduplicate within a workspace by normalized public URL and exact normalized-content hash, offering Merge metadata or Keep separate when versions differ. Never deduplicate across users/workspaces in a way that reveals private content. Preserve query and captured-via values, original filename/URL, imported timestamp, extractor version, and warnings. Research findings are candidate evidence, not automatically verified facts. Source annotations distinguish a quoted passage from a teammate's interpretation. - -Insert citations as structured source ID plus immutable bibliographic snapshot and optional locator/quote. Export uses stable document order to number citations and produces a deduplicated bibliography. Editing source metadata updates future insertions; existing citations show an update available and are refreshed deliberately. Deleting or archiving evidence cannot erase cited text or silently rewrite the bibliography. Citation links use validated safe URL schemes. Personal snippets/templates with citations carry the snapshot; insertion prompts to copy their evidence into the target workspace or keep a disclosed external citation. - -## 7. Import, multiple Google accounts, and export - -### 7.1 Import and trust boundary - -Import Markdown, DOCX, and Google Docs into independent new DebateLab documents by default. Choose workspace/side and preview title, structure, formatting changes, unsupported items, and citations. Replacing an existing file is an explicit reviewed proposal with a pre-change version, never the default import action. Bulk import uses stable per-item IDs, progress, cancel, and retry without duplicate files. - -Start with an 8 MB input limit matching the existing source importer, 32 MB decompressed DOCX limit, 1,000 ZIP entries, 200,000 document characters, and 10,000 nodes. Reject unsupported or oversized content with a useful export/split alternative; do not silently truncate speeches. Check extension, MIME, ZIP signatures, expansion ratio, parse time, depth, and link schemes. Run parsing in a worker with a cancel/time limit. Disable external XML entities and remote image fetching. Strip script/style/event handlers and unsupported embeds; sanitize imported HTML through a narrowly configured, audited sanitizer if the selected conversion path emits HTML. Mammoth's presence is not a sanitization guarantee. A new sanitizer dependency is justified by this trust boundary if schema-only conversion cannot avoid HTML safely. - -Google import fetches a selected file through Google's APIs, not an arbitrary URL fetcher. Preserve original document ID, canonical original URL, title, account label, imported time, and revision marker where available. Do not expose the connector ID or OAuth token to teammates. Include all supported document tabs/sections in preview; let the user choose split files or one ordered file. Comments/suggestions from Google are imported only when the API and access grant expose them reliably; otherwise state exactly what was omitted before confirmation and preserve the original link. Default import is the accepted/current body; never claim full Google revision-history migration. - -### 7.2 Google accounts are connectors, not app identities - -Keep DebateLab sign-in separate from Link Google account. Linking a school account must not switch the app user, merge Supabase identities, or sign the user out. Use server-managed authorization-code OAuth with PKCE, state, nonce, exact redirect allowlist, ten-minute one-use attempts, and a binding to the initiating app user/session. Verify issuer, audience, subject, and verified email on the returned identity. Account labels are display data; Google subject is the stable connection identity. - -Request `openid email` and the narrow `drive.file` access needed for user-selected files and exports. Use Google Picker to grant access to existing files; pasting an arbitrary private Docs URL does not grant access under `drive.file`. If access is absent, ask the user to select that file through Picker. Do not silently expand to full Drive or Gmail scopes. Native Capacitor uses the current supported external-browser Picker/OAuth path and app return handling, proved separately from desktop popups. - -Store encrypted refresh tokens only server-side in a non-exposed schema or secrets vault. Encryption uses an approved server-held key and key version with a documented rotation procedure. The app receives connection ID, account label, and status only. Google Picker may require a short-lived access token in browser memory; mint/return it only for the selected connection, never persist it in localStorage/sessionStorage or analytics. Scrub query params and token-bearing errors. Serialize refresh per connection, preserve a valid refresh token if Google omits a replacement, atomically store replacements, and mark invalid grants as reconnect-required. Disconnect revokes the provider grant when possible and always removes local token material. A failed provider revocation must be disclosed without retaining a usable local credential. - -Every import/export shows the account chooser, with the last-used account preselected but visible before confirmation. Reject another user's connection ID. Two Google accounts with the same-looking display name still remain distinguishable by email. The export recipient account belongs to the requesting user; exporting to school Drive does not automatically grant teammates Drive access. State this in the result. Never automatically send invitations or share exported Google files. - -Primary sources: [Google web-server OAuth](https://developers.google.com/identity/protocols/oauth2/web-server), [Drive scopes](https://developers.google.com/workspace/drive/api/guides/api-specific-auth), [web Picker](https://developers.google.com/workspace/drive/picker/guides/web-picker), and [desktop/mobile Picker](https://developers.google.com/workspace/drive/picker/guides/desktop-mobile-picker). Recheck current API and school-domain restrictions during the connector spike. - -### 7.3 Export contract - -Offer individual document and complete-workspace PDF, DOCX, Markdown, and Google Docs exports. PDF and Word are required completion gates; Markdown is useful portability. Export other useful workspace content through an optional appendix: sources/bibliography, selected chat threads, tool outputs, and practice feedback. Default packet contains accepted documents ordered A favor then En contra, then each side's explicit file order, then bibliography. Allow side selection, manual export ordering, cover page, motion, assignments, date, page numbers, and table of contents for multi-file packets. Exclude archived/trashed files by default and show their exclusion. - -Take an immutable export manifest with exact durable document/source revisions before rendering. Concurrent edits cannot make the packet internally inconsistent. Default exports contain accepted content only. Offer an explicit Review copy with comments, suggestions, authors, and statuses in an appendix or visible markup; never silently accept suggestions to export. Pending proposals produce a clear count before download. Normal packets omit private invite data, account email, operational audit details, and personal templates not inserted into documents. - -Build one workspace document-to-export representation shared by DOCX, PDF, Markdown, and Google renderers. Reuse installed `docx` and existing PDF/font infrastructure where fidelity permits. Preserve heading levels, lists, links, citation locators, tables, page breaks, and all English/Spanish/Hungarian glyphs. Never rasterize all text merely to get a screenshot-like PDF. Test copyable/searchable text, multi-page tables, long URLs, and font embedding. The converter may use the editor's canonical JSON directly; do not write separate ad hoc Markdown parsers per output. - -Google export is a new copy and returns a durable file link; retry must not create unbounded duplicates. Persist an export job/request ID and provider file ID before content population. If a timeout occurs after creation, retry completes or reports that same file. Do not automatically delete the user's file on ambiguous errors. Downloads use safe normalized filenames and provide a fallback when browser download/popup permission fails. Large packets use a bounded server job in the already-approved service if browser measurements fail, not a new job platform by default. - -## 8. Workspace experience and integration of current tools - -Desktop uses a left file/side navigator, central document, and collapsible right panel for AI, evidence, comments, and history. Keep the motion and saved/offline state visible. The overview shows all eight default slots, assignees, last activity, unanswered comments, and pending proposals without requiring each file to open. New workspace creation reaches the first editable Intro directly, with an invite control and a clear switch to En contra. Avoid a project-setup wizard for information users do not need. - -At phone widths, use one main pane at a time with Files, Write, Evidence, and Chat controls, and a contextual review sheet. Preserve selection when opening evidence or AI. Keep editing and accepting/rejecting a change available on mobile; do not hide essential functions behind desktop-only hover. Keyboard covers neither caret nor save status. iOS selection, composition, paste, external OAuth, and browser back behavior need real-device pilot checks beyond Chromium emulation. - -Accessibility gates include labelled toolbar controls, headings/landmarks, visible focus, keyboard file reorder alternatives, focus restoration after dialogs, text labels alongside cursor colors, sufficiently large touch targets, 200% zoom without clipped controls, reduced motion, and screen-reader descriptions of changes. Announce save/disconnect and proposal results sparingly through live regions. Provide a linear diff view in addition to colorized inline markup. - -Integrate current tools through an explicit context parameter and shared feature components. First add workspace actions that open the appropriate existing tool with motion, side, selected text, and source references, preserving a Return to workspace destination. Then embed those feature components in the workspace panel and store results as workspace artifacts. Do not mount entire route pages that depend on global private stores inside another page. - -Mapping: Generate builds a draft proposal; Attack critiques selected speech and offers individual proposed improvements; Brainstorm produces a shared artifact and selected insert proposals; Factoids searches evidence into workspace sources; Ask uses named shared threads; Crossfire and Sparring start from a pinned speech revision and save a chosen feedback artifact; Flowchart builds an argument map from explicit selected content and saves a workspace-owned map; Timer runs in a compact panel with no shared ticking database row; Library opens the workspace evidence view with an explicit private-library copy action; History shows workspace activity/artifacts and preserves personal history separately. Existing sharing remains snapshot-based; workspace documents are never made public merely because a legacy Share control is present. Disable that inherited control until a deliberate snapshot preview can enforce the correct scope. - -Practice attempts are private by default when launched as personal practice, even if their input came from a workspace. A clear Share feedback action copies the chosen attempt and scorecard into the workspace. Workspace group-practice mode explicitly says it is shared before recording. Personal improvement can later use the consenting user's attempts without exposing teammates' drafts or treating AI scores as validated assessments. - -## 9. Offline behavior, performance, privacy, and operations - -Store document drafts with an account/workspace/document/epoch key in IndexedDB, not the personal store's generic persisted state. Offline editing is available only for previously opened, locally cached documents. Show Offline, changes on this device; server Saved appears only after durable ack. Cache comments/history only as needed, and never cache OAuth secrets. Metadata actions, invitations, AI, proposal decisions, and version restore require connectivity; do not pretend queued destructive actions already succeeded. - -On reconnect, reauthenticate and recheck membership/epoch before sending pending updates. Valid updates merge through Yjs. Removed-member changes cannot be uploaded; retain a clearly labelled recovery copy of already-held local content for that account, with manual export and discard, rather than deleting unsent writing. An epoch mismatch after restore opens a recovery comparison. Logout clears normal cached server content and offers export/discard for unsynced drafts; explicit forced logout locks pending recovery to the old account and never exposes it to the next signed-in account. Browser storage is not a secure boundary against someone controlling the same device; tell students to sign out on shared school computers. IndexedDB quota/eviction failure must show that local protection is unavailable while online durable saving can continue. - -Initial engineering budgets: 30 members/workspace, 100 files/workspace, 200,000 characters/file, 20 concurrently typing clients/document, 2 MB maximum individual sync frame, 10 accepted update batches/client/second with burst 20, and bounded post-decode document memory. Reject oversized pastes before transmission and offer import/split. A full sync larger than one frame uses the tested protocol's chunking or fails with a recoverable message; never accept an unbounded compressed payload. Load only the active editor, page history/chat, and unload inactive documents after a bounded idle interval. - -Pilot targets measured on a midrange phone and ordinary school Wi-Fi: local keystroke-to-paint p95 under 50 ms, remote visible edit p95 under 500 ms, durable ack p95 under one second in-region, opening a cached recent workspace under one second and a 50 KB cold document under two seconds after auth. No character loss after a gateway kill/restart once Saved was shown. Browser memory stays under 200 MB for an ordinary eight-file workspace. Record corpus/device/network alongside results. Slow external model responses do not block typing or navigation. - -Observe durable-save latency, unacknowledged-update age, reconnects, denied writes, malformed updates, orphan anchors, stale proposal rate, export failures, Google refresh failures, AI context truncation, and queue backlog. Alert on persistence failures immediately and sustained acknowledgement p95 above five seconds. Reuse `src/lib/observability.ts`; log request IDs and error classes, never motion text, documents, chat, evidence excerpts, invite codes, Google account email, or tokens. Exclude authenticated editor DOM from session replay and diagnostic captures. Audit access changes, restores, trash/recovery, accepted proposals, and connector actions with minimal metadata. - -Back up database state and verify restoration of binaries, versions, and connector-key references in an isolated environment. Encryption-key backup has a separate protected procedure. Retention jobs are idempotent and distinguish live updates from version history and deletion tombstones. A service health endpoint is not adequate backup proof. - -## 10. Rollout, compatibility, and risk register - -Use a server-owned workspace entitlement/rollout flag with separate create, edit, AI, Google, and import/export switches. Client flags control presentation only. Existing private routes and exports keep working during migration. No automatic conversion of old chats/sources/cases into shared content. Existing content enters a workspace only through a previewed Copy to workspace action. Keep compatibility with the previous deployed editor schema while clients reconnect; reject unsupported newer schemas with an update prompt before accepting writes. - -Deploy additive schema before server code, then gateway/Edge Functions, then frontend. A canary squad runs first. Keep a rollback client able to read/export the new format before enabling creation. Rollback disables new writes and external operations, preserves durable state, allows supported read/export, and reverts app/service code only when schema-compatible. Never roll back by dropping new tables or deleting users' documents. Drain and acknowledge pending saves before service shutdown. Test a browser left open across upgrade and rollback. - -Risks and stop conditions: - -- Collaboration persistence hooks cannot enforce the save contract: stop Phase 0; choose a maintained alternative after evidence. No homemade replacement protocol under deadline. -- Human suggestion implementation breaks IME, rich marks, overlapping edits, or local undo: stop editor expansion; compare maintained/licensed solutions with an explicit cost decision. Do not remove suggestion mode from the final scope. -- Revoked users receive new content or accepted writes: security release blocker, including an existing connected client and queued traffic. -- Editor schema migration corrupts anchors or imports: freeze rollout, preserve binary backups, prove a reversible migration in an isolated database. -- Google school policy blocks app access: continue local/DOCX/Markdown paths, but label Google pilot blocked until school admin consent; never broaden scopes as a workaround without approval. -- Google token state can be confused across app accounts: block connector release until replay, CSRF, reconnect, and wrong-connection tests pass. -- Export loses Hungarian glyphs, tables, references, or accepted paragraphs: block the relevant export release; never silently provide a degraded packet. -- Service/database failure loses an acknowledged edit, applies an AI proposal twice, or marks an unapplied proposal accepted: data-loss blocker. -- Import bomb/XSS or foreign-workspace access succeeds: security blocker before any student pilot. -- Real hosting, sender, protected staging, or OAuth credentials absent: local work continues; live acceptance remains explicitly blocked. Production substitution is forbidden. -- Task scope drifts into a whole-app rewrite: keep private routes, extract only features needed by an integration slice, and leave unrelated cleanup out. - -## 11. Execution sequence and reviewable slices - -Each numbered slice is independently reviewable. Before implementation, write its focused test, run it and record the intended failure, implement only its behavior, rerun the focused check, inspect the diff, then run the required broader gate. Tests below are concrete scenarios, not permission to mock away auth or CRDT behavior. Proposed new paths are explicitly labelled; existing paths come from repository inspection. Create follow-on detailed slice plans only during authorized execution if a slice needs more than one PR. Do not prewrite an entire application in this master plan. - -### Phase 0: prove the difficult boundaries - -**P0.1 Editor and durability spike.** Proposed new paths: `src/lib/workspace/editor-schema.ts`, `src/test/workspace/collaboration.test.ts`, `services/collaboration/package.json`, `services/collaboration/src/server.ts`, and `services/collaboration/src/server.test.ts`. Modify existing `package.json` and `package-lock.json` only after dependency/license review. Keep the spike isolated from production routes. - -- [x] Resolve current compatible Tiptap/Yjs/Hocuspocus versions from official docs and package manifests, pin exact versions, inspect licenses, and verify React 18/Node 22 compatibility. Record rejected alternatives and extension licensing. -- [x] Write a two-client actual-Y.Doc test that edits the same paragraph, changes list formatting, inserts a link, disconnects, reconnects, and checks identical structured results. Write a local undo test proving a teammate's text survives. -- [x] Add a gateway test that forces database append failure, duplicates an update, and kills the process before and after durable ack. Assert no Saved acknowledgement before commit and no acknowledged loss after restart. -- [x] Prove the full update interception path, including sync handshake. Record hook order from the selected version and ensure rejected mutations never appear to other clients. -- [x] Add a restore-epoch test where an offline client's old tail returns. Assert the selected version remains current and old edits enter recovery. -- [x] Run `npx vitest run src/test/workspace/collaboration.test.ts`; add and run a proposed root `test:collaboration` npm script for the real Node-service tests. No spike is accepted on a mocked Yjs merge. - -**P0.2 Suggestions and import/export fidelity spike.** Proposed new paths: `src/test/workspace/review.test.ts`, `src/test/workspace/document-conversion.test.ts`, and small fixture files under `src/test/workspace/fixtures/` during implementation. - -- [x] Build one representative bilingual/hungarian speech fixture with headings, nested lists, links, a table, citation, and Unicode/IME edits. -- [x] Test human suggested insert/delete/format, overlapping real edits, accepting one hunk, rejecting all, and undo/redo. Prove quote/comment anchors survive unrelated insertions and detach on deletion. -- [x] Round-trip the fixture through supported Markdown, DOCX, and the PDF renderer. Compare structured semantics and extracted visible text, not binary file equality. -- [x] Decide whether the open-source suggestion implementation is maintainable before committing the UI architecture. Record the tested ceiling and package choices in the implementation PR. - -**Exit:** Architecture is selected using runnable evidence. A failed gate stops dependent work, not the entire vision. No live rollout yet. - -### Phase 1: secure workspace foundation - -**P1.1 Schema and membership.** New migration generated in existing `supabase/migrations/`; proposed tests `supabase/tests/workspaces_rls_test.sql` and `supabase/tests/workspace_lifecycle_test.sql`. Existing reference migration: `supabase/migrations/20260821100000_clear_current_user_data.sql`. - -- [ ] Write pgTAP cases for owner, member, outsider, unauthenticated, removed member, forged IDs, cross-workspace child references, and owner-account deletion. -- [ ] Implement workspace/membership/doc metadata and atomic create with eight defaults. Test replayed request ID creates one workspace only. -- [ ] Implement metadata revisions, assignment validation/clear-on-removal, reorder, archive, trash/recovery, deletion and accepted ownership transfer. -- [ ] Assert non-owner deletion and direct owner/member escalation fail, all members can manage non-owner access, and clearing personal data preserves shared content. -- [ ] Run `./.superset/db-gate.sh`. Review policies and function grants against the attack matrix, including service-role handler checks separately from RLS. - -**P1.2 Invites.** Proposed new paths `supabase/functions/workspace-invites/index.ts`, `supabase/functions/workspace-invites/index_test.ts`, and `supabase/tests/workspace_invites_test.sql`; update existing `supabase/config.toml` only with the required authentication contract. - -- [ ] Write tests for expired/rotated code, wrong verified email, concurrent last-use redemption, replay, removed-user old code, uniform nonexistent-email response, and limiter failure. -- [ ] Implement hashed tokens, atomic redemption, cap/rate checks, and sender abstraction only at the real external mail call boundary. Local tests use a controlled sink and never send actual email. -- [ ] Verify invite acceptance returns existing membership idempotently and creates no permanent team entity. -- [ ] Run native Edge tests and database gate. Configure a real sender only under separate authorized staging work. - -**P1.3 Workspace discovery and structure UI.** Modify `src/App.tsx`, `src/components/layout/AppLayout.tsx`, the three existing locale files, and relevant navigation tests. Proposed new paths `src/pages/WorkspacesPage.tsx`, `src/pages/WorkspacePage.tsx`, `src/lib/workspace/api.ts`, `src/lib/workspace/queries.ts`, `src/test/workspace/navigation.test.tsx`, and `e2e/workspaces.spec.ts`. - -- [ ] Write a create/join/reopen-old-workspace UI test with two workspaces and different memberships; prove creating the second leaves the first accessible. -- [ ] Add lazy routes `/app/workspaces` and `/app/workspaces/:workspaceId`, searchable Active/Archived/Hidden/All views, overview assignments, and file controls. Use TanStack Query for server rows and component state for view selection. -- [ ] Add English, Spanish, Hungarian labels, mobile controls, loading/unavailable states, and keyboard reorder. -- [ ] Run focused Vitest tests plus the existing `src/test/components/AppLayout.test.tsx` and locale test, then browser tests. Mock sessions prove presentation only. - -**Exit:** A secure workspace shell is usable behind an internal flag. Do not advertise collaborative drafting until Phase 2 passes. - -### Phase 2: reliable collaborative drafting - -**P2.1 Gateway persistence and access.** Proposed new paths `services/collaboration/src/persistence.ts`, `services/collaboration/src/auth.ts`, and their `.test.ts` siblings; new generated migrations for private document state/update/version tables. Extend the Phase 0 server. - -- [ ] Turn the spike's red failure cases into permanent regression tests for append/ack ordering, advisory-lock loss, duplicate mutation, malformed update, cross-document identity, token expiry, and removal during editing. -- [ ] Implement durable sequence/state projection, per-document lock, pending replay, strict size limits, and authenticated author audit. -- [ ] Test a connected removed member cannot read newly typed text or deliver pending writes after access-change acknowledgement. -- [ ] Run service tests and pgTAP. Exercise process restart against a real local database under the documented exclusive stack lock. - -**P2.2 Editor, presence, and offline recovery.** Proposed new paths `src/components/features/workspace/WorkspaceEditor.tsx`, `src/lib/workspace/collaboration.ts`, `src/lib/workspace/offline.ts`, `src/test/workspace/offline.test.ts`, and `e2e/workspace-collaboration.spec.ts`. Modify `src/lib/supabase/AuthProvider.tsx` for cache/session disposal and existing build-boundary test configuration as needed. - -- [ ] Write tests for two simultaneous users, local-only undo, remote cursor identity, route/account switch, IME composition, a 30-second disconnect, IndexedDB failure, and a revoked offline user reconnecting. -- [ ] Integrate one editor per active file, awareness, explicit save states, unsaved recovery, and account-scoped cache clearing. -- [ ] Add real two-context browser tests and a test database account fixture with cleanup limited to generated IDs. Keep credentials and authenticated captures out of artifacts. -- [ ] Run focused tests, full credential-free verification, service tests, and the real collaboration harness. Measure the agreed latency/memory budgets. - -**P2.3 History and recovery.** Proposed new paths `src/components/features/workspace/VersionHistory.tsx`, `services/collaboration/src/versions.ts`, corresponding tests under `src/test/workspace/` and the service test directory. - -- [ ] Write tests for named version, automatic coalescing, restoration race, epoch mismatch, detached comments, and recovery before purge. -- [ ] Implement history preview, selected-version diff, restore as a new epoch, compaction, and retention jobs with idempotent checkpoints. -- [ ] Prove compaction produces the same state and never prunes updates above its captured sequence. Restore a backup in isolation. - -**Exit:** A squad can write, reconnect, recover, and restore safely. This is a coherent internal pilot, not the complete Google Docs replacement. - -### Phase 3: human review and reusable writing - -**P3.1 Comments and mentions.** Proposed paths `src/components/features/workspace/CommentPanel.tsx`, `src/lib/workspace/comments.ts`, `src/test/workspace/comments.test.ts`, and generated comment/notification migration. - -- [ ] Write cases for insert-before-anchor, deleted anchor, epoch restore, reply edit authorization, mention dedupe, and recipient removal before notification delivery. -- [ ] Implement threaded comments, resolution, member-only mention picker, and in-app notification inbox. -- [ ] Verify keyboard/screen-reader navigation, mobile selection retention, and no unauthorized notification payloads. - -**P3.2 Suggestion mode and decisions.** Proposed paths `src/components/features/workspace/ProposalReview.tsx`, `src/lib/workspace/suggestions.ts`, `services/collaboration/src/proposals.ts`, and corresponding focused tests. - -- [ ] Write the stale/overlap/concurrent accept test matrix from section 5 before implementing the decision API. -- [ ] Implement visible human suggestions and granular/whole review, audit, idempotency, rejection, and inverse-change proposals. -- [ ] Test acceptance failure halfway through the database transaction rolls back both content and status. Test a malicious client cannot mark a proposal accepted through direct table updates. -- [ ] Run service/DB/browser checks; replay the Phase 0 rich-text/IME corpus on desktop, mobile Chromium, and WebKit. - -**P3.3 Personal snippets/templates.** Proposed paths `src/components/features/workspace/TemplatePicker.tsx`, `src/lib/workspace/templates.ts`, `src/test/workspace/templates.test.ts`, and generated owner-only template migration. Add a personal Templates section to existing `src/pages/LibraryPage.tsx` without changing private source behavior. - -- [ ] Write tests that another collaborator cannot read the template library, insertion is a copy, later template edits do not rewrite a speech, and citations carry disclosed provenance. -- [ ] Implement save selection, save whole structure, rename/update/duplicate/delete personal item, and insertion preview. -- [ ] Run focused component/RLS tests and existing Library tests. - -**Exit:** Human collaborative review and reusable personal writing are complete before AI proposal acceptance depends on them. - -### Phase 4: shared knowledge and AI coworker - -**P4.1 Shared sources.** Proposed paths `src/components/features/workspace/WorkspaceSources.tsx`, `src/lib/workspace/sources.ts`, `src/test/workspace/sources.test.ts`, and workspace source migrations. Reuse `src/lib/source-import.ts`, `src/lib/source-file-import.ts`, and `src/components/features/shared/SourceQualityList.tsx` through explicit targets. - -- [ ] Write tests for private-to-shared copy, no cross-workspace dedupe leak, different versions of one URL, per-user source selection, source deletion with retained citations, and provenance preservation. -- [ ] Implement folders/tags, annotation, evidence insertion, bibliography preview, and source update notices. -- [ ] Verify existing private-library tests still pass. - -**P4.2 Shared threads and bounded context.** Proposed paths `src/lib/workspace/chat.ts`, `src/components/features/workspace/WorkspaceChat.tsx`, `supabase/functions/_shared/workspace-context.ts`, `supabase/functions/_shared/workspace-context.test.ts`, and new chat migrations. Modify existing `src/lib/ai/client.ts`, `supabase/functions/ai/index.ts`, and `supabase/functions/ai-stream/index.ts` only at explicit context boundaries. - -- [ ] Write tests for private-source exclusion, foreign thread/document IDs, membership removed during generation, context over limit, both-side retrieval, summary invalidation, interrupted stream, and two senders. -- [ ] Implement named shared threads, server-persisted requests/messages, one active generation per thread, reconnect replay, context manifest, and quota charged to requester. -- [ ] Preserve current provider/model/research/accounting controls and test the old private Ask path unchanged. -- [ ] Run existing AI/client/Ask tests, new context tests, native handlers, and authenticated multi-user staging when authorized. - -**P4.3 AI document proposals.** Extend `services/collaboration/src/proposals.ts` and `src/components/features/workspace/ProposalReview.tsx`; proposed new `supabase/functions/_shared/workspace-proposals.ts` and tests. - -- [ ] Write malformed/oversized proposal, invented citation, stale base, duplicate request, partial review, formatting-only change, and prompt-injection test cases. -- [ ] Implement AI draft/rewrite/critique output into the existing review system. The model receives no direct edit operation. -- [ ] Confirm the original document hash remains unchanged after generation and rejection; only authorized human acceptance changes it. - -**Exit:** A squad researches, chats, and reviews AI help without private-content leaks or silent document changes. - -### Phase 5: document portability and Google connectors - -**P5.1 Local import/export.** Proposed paths `src/lib/workspace/document-import.ts`, `src/lib/workspace/document-export.ts`, `src/components/features/workspace/ImportDialog.tsx`, `src/components/features/workspace/ExportDialog.tsx`, and `src/test/workspace/document-io.test.ts`. Reuse existing export libraries and formatting utilities; retain old `src/lib/export-utils.ts` callers. - -- [ ] Write corpus tests for Markdown/DOCX, compressed bombs, scripts/unsafe URLs, giant tables, imported loss notices, all locale glyphs, citation numbering, archived-file exclusion, and exports during live edits. -- [ ] Implement previewed independent-copy import and revision-manifest exports. Add Word/PDF document and workspace packet options plus review appendices. -- [ ] Open representative DOCX in Word or a compatible real application and inspect rendered PDF pages as well as extracted text. Record limitations explicitly. -- [ ] Run focused conversion tests, existing export tests, and the production bundle gate. - -**P5.2 Multiple Google connectors.** Proposed paths `supabase/functions/google-connect/index.ts`, `supabase/functions/google-connect/index_test.ts`, `supabase/functions/_shared/google-connections.ts`, `src/lib/google-connections.ts`, and `src/components/features/export/GoogleAccountPicker.tsx`; new private token/attempt migrations. Modify existing auth modal and settings only where connector selection is needed. - -- [ ] Write OAuth state replay, wrong app user/session, PKCE failure, wrong issuer/audience, expired callback, refresh race, omitted refresh-token replacement, revoked connection, and wrong connection-owner tests. -- [ ] Implement linking/disconnecting, encryption, scoped Picker token, account chooser, reconnect, and exact return-URI behavior. Keep app login separate. -- [ ] Prove two linked accounts can each be selected without changing DebateLab identity, including browser refresh and session switch. -- [ ] Run local handler/RLS tests. Real consent, refresh, revocation, and school-domain tests require separately supplied staging accounts and authorization. - -**P5.3 Google import/export integration.** Proposed path `supabase/functions/import-gdoc/index.ts` with handler tests; modify existing `supabase/functions/export-gdoc/index.ts`, `src/lib/export-utils.ts`, `src/test/export-google-docs.test.ts`, and workspace import/export dialogs. - -- [ ] Write selected-file authorization, all-tab preview, missing grant, account mismatch, provider timeout after file creation, and idempotent retry tests. -- [ ] Add workspace structured-document export and Google import using connection IDs. Preserve legacy private export compatibility until its UI migrates to the same account chooser. -- [ ] Verify original Google documents remain unchanged, two accounts remain separate, and exported files have the expected content and recipient account. - -**Exit:** Students can move existing work in and final packets out with measured fidelity and explicit account choice. - -### Phase 6: connected tools and release quality - -**P6.1 Drafting/research integration.** Modify existing `src/pages/GeneratePage.tsx`, `src/pages/AttackPage.tsx`, `src/pages/BrainstormPage.tsx`, `src/pages/FactoidsPage.tsx`, `src/pages/AskPage.tsx`, and their feature components one tool per PR. Proposed shared contract path `src/lib/workspace/tool-context.ts` and `src/test/workspace/tool-context.test.ts`. - -- [ ] For each tool, write a test that explicit workspace context reaches the server, results appear in that workspace, and private route state remains private. -- [ ] Start with prefilled handoff plus return link, then extract/embed the feature UI as necessary. Save immutable artifacts and offer reviewable insertion. -- [ ] Test switching workspaces mid-request and opening the old personal route immediately afterward. - -**P6.2 Practice/map/history integration.** Modify existing `src/pages/CrossfirePage.tsx`, `src/pages/SparringPage.tsx`, `src/pages/FlowchartPage.tsx`, `src/pages/TimerPage.tsx`, `src/pages/HistoryPage.tsx`, and relevant existing tests in separate reviewable PRs. - -- [ ] Write tests for pinned input revision, explicit feedback sharing, private practice visibility, shared map copy, and non-persistent timer ticks. -- [ ] Add workspace entry/return actions, artifacts, and later embedded controls without rewriting private stores wholesale. -- [ ] Prove every current tool has a usable workspace path; a collection of links that loses context is not completion. - -**P6.3 Release hardening and pilot.** Modify existing `docs/architecture.md`, `docs/development.md`, `docs/testing.md`, `docs/operations.md`, `supabase/README.md`, `nginx.conf`, and `.github/workflows/deploy.yml` only as required. Proposed new operational guide `docs/workspaces.md` and end-to-end suite `e2e/staging/workspaces.spec.ts`. - -- [ ] Add gateway CI/service startup, database fixtures, failure injection, CSP WebSocket/Picker origins, health/readiness, graceful shutdown, backups, and rollback instructions. Do not loosen CSP to broad wildcards. -- [ ] Run the complete journeys below with a canary squad, both browser contexts and actual devices. Fix observed task friction before widening rollout. -- [ ] Inspect current-head CI/review evidence and confirm release authorization before production operations. Record exact commit, enabled flags, migration/service versions, checks, and exceptions. - -## 12. Verification commands and acceptance journeys - -Use the repository's Node version on Adam's VPS: - -```sh -export PATH=/home/codex/.nvm/versions/node/v22.23.2/bin:$PATH -npm run docs:check -npm run lint -npm run typecheck -npm test -- --coverage -npm run test:edge -npm run build -CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run test:e2e -npm run verify -./.superset/db-gate.sh -CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run test:e2e:webkit -``` - -Do not redundantly run both every constituent and `verify` when nothing changed; the block lists existing supported commands and their full-gate shortcut. The service needs a proposed `npm run test:collaboration` command added in P0.1; it does not exist on the inspected base. The proposed focused test paths likewise become runnable only in their owning slices. Add new collaboration paths to selected coverage deliberately without reducing current thresholds to hide regressions. - -`verify` proves docs, lint, types, selected coverage, native Edge tests, build, and desktop/mobile Chromium. It does not prove pgTAP, WebKit, staging, the new service, or production. The database wrapper owns stack cleanup even after failure. A real multi-client integration suite must hold the same exclusive database lock through setup, service execution, and teardown; implement that as an extension to the existing `.superset/db-gate.sh` workflow or a documented wrapper before relying on it. Never start a second stack in another worktree. Protected staging uses `npm run test:e2e:staging` only with all three existing staging values supplied privately, rejects production, and disables captures. Extend its cleanup to only the workspace IDs created by that run. - -Required final journeys: - -1. Create a motion workspace, reach eight default files, invite by code and by email, and join as a second student. Target under two minutes without coaching, excluding email delivery. Assign R1, open it from the overview, and verify both members can edit it. Create a new workspace with different collaborators and reopen the first from All. -2. Two students type in the same paragraph, insert evidence, use undo/redo, disconnect one device for 30 seconds, reconnect, reload, and compare results. Both converge; no acknowledged text disappears. Cursor labels identify the correct people. -3. Suggest a replacement, comment on it, reply and mention a teammate, accept one hunk, reject another, then restore an earlier version while the second client is offline. The offline edits cannot resurrect the pre-restore document silently. -4. Share research in a named thread, ask AI to critique both sides, inspect its context manifest, ask for a rewrite, and reject it. Original content remains exact. Accept a new proposal while another member changes the target; stale detection prevents overwritten writing. Two simultaneous accept clicks yield one edit. -5. Save a private reusable template, insert it into another workspace, and update the original template. The inserted speech remains unchanged and other members cannot browse the private library. -6. Import Markdown, DOCX, and a school Google Doc as new files; compare preview and final formatting. Link a personal Google account without changing the DebateLab login. Export through each account deliberately. Original Google files remain unchanged. -7. Export one speech and a complete PDF/Word packet in each locale. Confirm accepted-only content, file/side order, assignments, long tables, hyperlinks, citations, bibliography, selectable text, and Hungarian accents. Export a review appendix deliberately and verify comments/proposals are labelled. -8. Remove a connected member, rotate invitations, and test their online/offline clients, guessed IDs, old invitation, history, chat, source, and export access. All new access fails. Owner removal fails. Ownership transfer and owner account-deletion safeguards work at the database boundary. -9. Kill the gateway after Saved, restart it, fail a compaction write, fail Google after remote file creation, and interrupt AI streaming. Recover durable text, retry one export file, and show an interrupted shared response. Restore a backup into isolation. -10. Complete the create/edit/evidence/AI-review/export flows using only keyboard, screen reader, 200% zoom, mobile Chromium, and WebKit; include an actual iOS device pilot. No essential operation is hover-only or requires a desktop-width screen. - -Every release PR needs current-head quality, database, browser, and new service checks as relevant. Read substantive review findings on the exact head; pending or rate-limited review is not a clear review. Local green results do not prove GitHub green. Report `verified`, `partially_verified`, or `blocked`, with which authenticated/live journeys actually ran. Final completion requires all eight explicit product areas, not merely a collaborative text field. - -## 13. Requirement traceability - -- [ ] Workspace belongs to one changing squad, no opponent and no permanent team: sections 2, 4; P1.1/P1.3; journey 1. -- [ ] Both sides, eight school defaults, custom files, assignment without permissions: sections 2, 8; P1.1/P1.3; journeys 1 and 7. -- [ ] Old workspaces remain findable when a new team forms: section 2; P1.3; journey 1. -- [ ] Code/email invitations, every member manages access, owner-only deletion: section 4; P1.1/P1.2; journeys 1 and 8. -- [ ] Character collaboration, presence/cursors, polished rich Markdown authoring, correct undo/redo: sections 3, 8, 9; P0.1/P2.1/P2.2; journeys 2 and 10. -- [ ] Saved history, version browsing/restoration, human suggestions, comments/replies/mentions: sections 3 and 5; P0.2/P2.3/P3.1/P3.2; journey 3. -- [ ] Rename/reorder/archive/delete/duplicate/recovery rules and assignments: section 2; P1.1/P1.3/P2.3; journeys 1, 3, and 8. -- [ ] AI may use documents, shared sources and workspace chats with safe bounded context: section 6; P4.1/P4.2; journey 4. -- [ ] AI writing/research/critique with explicit reviewable additions/deletions/replacements and human decisions: section 5; P3.2/P4.3; journey 4. -- [ ] Granular and whole proposals, concurrency, stale handling, audit attribution: sections 4 and 5; P2.1/P3.2/P4.3; journeys 4 and 9. -- [ ] Current tools connect to the workspace through a phased migration: section 8; P6.1/P6.2; journeys 4 and 10. -- [ ] Named shared workspace AI threads; outside chats stay private: section 6; P4.2; journeys 4 and 8. -- [ ] Shared evidence organization, annotations, provenance, dedupe, citation insertion, bibliography: sections 6 and 7; P4.1/P5.1; journeys 4 and 7. -- [ ] Personal snippets/templates stay private and reusable across workspaces: sections 2 and 6; P3.3; journey 5. -- [ ] Independent-copy Google Docs/DOCX/Markdown import and provenance: section 7; P0.2/P5.1/P5.3; journey 6. -- [ ] Multiple Google accounts with explicit per-operation selection and safe tokens: section 7; P5.2/P5.3; journeys 6 and 8. -- [ ] Useful content exports to PDF/Word, individual and workspace packets, formatting and review treatment: section 7; P5.1/P5.3; journey 7. -- [ ] Fast obvious creation, invitation, assignment, side switching, evidence insertion, AI review, export: sections 2 and 8; P1.3/P2.2/P4/P5/P6.3; journeys 1, 4, 7, and 10. -- [ ] English/Spanish/Hungarian, accessibility, responsive/mobile, offline/degraded recovery, performance: sections 8 and 9; every UI slice and P6.3; journeys 2, 7, 9, and 10. -- [ ] Privacy, abuse prevention, audit, observability, owner deletion, stale clients, invitations, AI races, import sanitation, token storage: sections 4 through 10; P0/P1/P2/P3.2/P4/P5/P6.3; journeys 8 and 9. -- [ ] Full vision delivered through coherent phases, with security gates, rollback, current-head verification, and separate deployment authority: sections 1, 10, 11, and 12. diff --git a/plans/2026-09-21-debate-workspace-no-bs.md b/plans/2026-09-21-debate-workspace-no-bs.md deleted file mode 100644 index 2638fbe12..000000000 --- a/plans/2026-09-21-debate-workspace-no-bs.md +++ /dev/null @@ -1,147 +0,0 @@ -# Debate Workspace: no-BS replacement plan - -**Status:** On hold. Collaborative Workspace is hidden in production and renders only when `VITE_COLLABORATION_URL` is set. - -Adam's first working journey: sign in, enter a motion once, land in Intro, invite a teammate by code, assign R1, and write in the same paragraph from two browsers. Switch between A favor and En contra, disconnect one browser, reconnect, reload, and reopen the workspace tomorrow with the same text and last-opened file. Create another workspace with different collaborators and still find the first in the archive. - -**First-milestone done means** that journey works against real local Auth, PostgreSQL and the collaboration service through a private Tailscale URL. Saved survives a service restart; undo leaves the teammate's work intact; removal blocks subsequent reads and writes. File controls, history/restore, human suggestions and comments work on desktop and mobile. A shell, mocked session, or passing spike is not this deliverable. - -This supersedes the old 20-slice execution plan in `plans/2026-09-20_131317-debate-workspace.md`. Keep that file as evidence. There are **three vertical milestones and three new PRs**, plus finishing the already-open #55, for four PRs total. No additional spike, research milestone, component PR, or follow-on planning document. - -This file contains future implementation instructions. This planning task authorizes only this Markdown file. No implementation, PR mutation, commit, push, merge, deployment, migration, credential configuration, or staging/production operation was performed. - -## Verified starting point - -Inspected on 2026-09-21 at approximately 05:05 UTC, in `/home/codex/.t3-v2/worktrees/DebateLab/debate-workspace-no-bs-plan-20260921`, branch `plan/debate-workspace-no-bs-20260921`. Initial working tree was clean. HEAD and live `origin/main` were both `b7c848008aab73a986505de4e76c2bbc412a2b5b`; live origin was checked with `git ls-remote`, without changing this checkout. - -- [#52](https://github.com/Programcode/DebateLab/pull/52) merged as `7b1e20c`; [#53](https://github.com/Programcode/DebateLab/pull/53) as `483f47c`; [#54](https://github.com/Programcode/DebateLab/pull/54) as `b7c8480`. Reuse them. -- [#55](https://github.com/Programcode/DebateLab/pull/55) is OPEN and mergeable at `7b8c1e09f1d92cbb64a0dd915deb48fac3f8e388`. [Run 35530461129](https://github.com/Programcode/DebateLab/actions/runs/35530461129) passed quality, database-tests and browser-tests. Docker was skipped, as expected for a PR. CodeRabbit is green but its comment explicitly says Free-plan summary/walkthrough only. Copilot could not review because of quota. Neither is substantive review. The PR body records local Terra approval of this head; recover that evidence or obtain a fresh review during execution. -- Vercel automatically deployed #55 to Preview, deployment `6556422066`, and `b7c8480` to Production, deployment `6556073910`; both GitHub deployment statuses report success. These are frontend deployment records, not proof of workspace backend rollout. No workspace route exists in `src/App.tsx` or `src/lib/route-modules.ts`. -- [Protected staging run 35528795048](https://github.com/Programcode/DebateLab/actions/runs/35528795048) at `b7c8480` passed its existing staging browser step. Later runs `35529337933` and `35531398398` were skipped. That existing suite checks login/reload, a source and bounded AI, not Debate Workspace. No staging or production endpoint/database was exercised by this planning task. Actual remote workspace migrations, invitation configuration and collaboration hosting remain unverified. - -Source evidence that changes implementation: - -- `services/collaboration/src/server.ts` already intercepts live and reconnect updates before persistence/broadcast and emits durable acknowledgements. Its storage/auth are injected and its locks are in-memory. `services/collaboration/README.md` explicitly calls it a spike. Supply real adapters and revocation handling; do not redo the protocol experiment. -- `src/lib/workspace/{schema,editor-schema,review,document-conversion}.ts` supplies the editor schema, collaboration binding, relative anchors, proposal operations and conversion fixtures. Review is currently an in-process model, not a secure persisted decision API or a complete suggestion-mode UI. Conversion supports a limited corpus; ordered lists and other schema nodes can lose semantics, and PDF fidelity needs work. Reuse the tested pieces without claiming they are finished. -- #54 supplies workspace/member/document metadata, RLS and transactional RPCs: `create_workspace`, `update_workspace_document`, `change_workspace_access`, `change_workspace_state`. Custom document creation and content duplication still need an operation. #55 adds the invitation handler, secure redemption and retryable delivery. Its code needs `WORKSPACE_INVITE_SECRET`; actual email needs an approved relay. -- `src/lib/supabase/env.ts` currently chooses hardcoded production/staging endpoints and defaults to production. A Vite environment variable alone does not select local Supabase. Fix the explicit local preview boundary in milestone 1 before opening any trial link. -- `src/lib/ai/client.ts` gathers personal sources and autosaves discovered sources into the personal library. Existing practice/map pages use personal stores. Workspace context must reach both server authorization and result persistence. -- Existing Google export uses `drive.file` and the app sign-in provider token. `AuthProvider.tsx` still retains that access token in sessionStorage. This is not a multi-account connector. Replace this coupling for workspace Google operations in milestone 3; reuse export formatting and granted scope. - -The complete supplied skill catalog and applicable global, parent and repository instructions were inspected. `plan`, `superpowers:writing-plans`, `unslop` and `ponytail` guide this document; TypeScript, Supabase, Tailscale and Context7 guidance informed source/operational checks. Adam's explicit instructions override microtasks, giant code samples, extra plan files and repeated review ceremonies. - -## Architecture to implement - -Keep React 18, Node 22, npm and the committed lockfile. Use the installed Tiptap 3.31.3, Hocuspocus 4.7.0 and Yjs 13.6.32. Supabase remains the authority for identity, current membership, metadata and durable state. One Node collaboration process owns live documents; enforce one writer per document with a dedicated PostgreSQL session advisory lock, and close writers if the lock connection dies. No Redis, second account system, managed collaboration purchase or new editor evaluation. - -The canonical document is the existing structured Yjs document. JSON/text is a projection at a known durable sequence, not another writable copy. Keep binary updates, snapshots and versions in private storage behind authorized operations. Serialize append, restore and proposal acceptance. Persist before Saved or broadcast. Recheck current membership for incoming operations and outgoing content, including awareness. Coordinate access changes with the gateway so successful removal acknowledgement comes only after queued delivery and subscriptions are fenced. A stale JWT or successful channel join is insufficient. - -Use existing RPCs for metadata, with their expected workspace revision and request IDs. Add only data used by the current milestone. New workspace children need workspace-scoped foreign keys and membership RLS; private snippets and Google connections need account ownership. Never move personal rows into shared scope implicitly. Keep TanStack Query for server metadata and account-scoped IndexedDB for local document recovery. Add editor bindings or a persistence package only where the installed APIs leave a concrete gap. - -Retain the existing app style and controls. Lazy-load workspace/editor/converters. Desktop gets files, document and a collapsible working panel; phones show one pane at a time. Each milestone includes English, Spanish and Hungarian copy, keyboard operation, visible focus, labelled controls, non-color-only diffs/cursors, 200% zoom, IME and touch editing. No redesign project. - -## Milestone 1: a squad can write and review a case - -Outcome: the first journey above, with an actual editor and safe recovery. This is the first usable collaborative release, even before AI and Google are connected. - -- [ ] Recheck #55's exact head, CI and review record. Finish its existing review/fix/merge cycle under execution authorization; do not recreate invitations. If its head changes, discard stale approval and rerun affected checks. This is a dependency inside this milestone, not another featureless milestone. Build from the resulting main head. -- [ ] Wire `/app/workspaces`, `/app/workspaces/:workspaceId` and the invitation route into navigation. Creation opens Intro immediately and creates Intro, R1, R2 and Conclusion on each side, using the existing eight-file transaction. Show a clear localized A favor/En contra switch, assignee beside each file and in the overview, archive/all views and per-user last-file/side preference. One squad prepares both sides; no Team entity or opponents. Creating another workspace leaves old memberships intact. Explicit removal revokes access to old content too; archive never does. -- [ ] Add code copy/paste, email invite/retry, redemption after login, rotate/revoke and member removal using #55. Preserve its code strength and add readable grouping rather than inventing a weak PIN. All members can edit/invite/remove non-owners; only the owner deletes. Expose existing transfer safeguards where needed to leave/delete an owner account. Scrub invitation tokens from URLs after redemption and from telemetry. -- [ ] Add custom file creation, rename, reorder with keyboard controls, assignment, archive, trash/restore and duplication. Duplicate accepted content and citations atomically into a new file; do not copy live proposals/history as if they happened there. Preserve #54 revision checks and recovery windows. No permanent purge job in this release. -- [ ] Mount the real editor with formatting, headings, lists, links, tables, Markdown shortcuts/paste, collaboration-aware undo/redo, presence and authenticated cursor labels. Use one active editor per tab. Finish PostgreSQL persistence, access fencing, schema/size limits and restart recovery in the same PR. Keep the spike's 2 MiB ceiling explicit until a real document requires more; reject oversized work before losing it. -- [ ] Persist account/workspace/document/epoch drafts and pending acknowledgements. Show Saving, Saved, Offline and Recovery honestly. Reconnect rechecks membership and epoch before sending. Logout/account switch must not reveal another user's draft; offer recovery/export before discarding unsent work. Storage failure must warn. No offline metadata deletion, invitation, restore or proposal acceptance. -- [ ] Add named and automatic durable versions, browse/preview and restore. Snapshot changed content on idle and before restore/accepted proposals. Restore compares the previewed sequence, starts a new epoch and preserves the pre-restore version. Old offline edits become a recoverable draft, never silently merge into the restored epoch. Keep versions during the trial; defer pruning, not history. -- [ ] Finish human Suggesting mode, inline proposed insertions/deletions/replacements/format changes, per-change accept/reject, inline comments, replies, resolution and member-only @mentions with in-app notifications. Reuse `review.ts` anchors and stale checks. Suggesting must leave accepted content unchanged until a decision. Persist content update and decision atomically under the document lock; concurrent accepts apply once. Detached comments keep their quote. Test real composition/paste, not only the spike's composition-state helper. - -Likely ownership: coordinator owns `src/App.tsx`, `src/lib/route-modules.ts`, `src/components/layout/AppLayout.tsx`, new `src/pages/WorkspacesPage.tsx` and `WorkspacePage.tsx`, `src/components/features/workspace/`, `src/lib/workspace/`, `src/lib/supabase/env.ts`, `src/lib/supabase/AuthProvider.tsx`, `services/collaboration/src/`, additive `supabase/migrations/`, `supabase/tests/`, all three locale files and the existing database wrapper. Update `docs/testing.md`, `docs/development.md` and service README with runnable local commands. Do not generate a component-per-PR inventory. - -Smallest meaningful red tests: new `src/test/workspace/navigation.test.tsx` creates/reopens two workspaces and exposes assignment without restricting edits. Extend real provider/service tests for PostgreSQL append failure, restart after Saved, lock loss, stale restore epoch and connected-member revocation during a queued send. Extend `review.test.ts` for actual editor transaction interception and concurrent persisted accept rollback. Extend lifecycle pgTAP for custom-file duplication, outsider access and cross-workspace IDs. New `e2e/workspace-collaboration.spec.ts` must use two real local Auth users, not session stubs. - -Focused gates: those Vitest files, `npm run test:collaboration`, invitation handler tests if changed, and `./.superset/db-gate.sh`. Extend that wrapper in this PR to hold the same lock through full local Auth/API/service browser integration and guaranteed cleanup; its current database-only run is insufficient. Broader gates: the shared completion gate below, including desktop/mobile Chromium and WebKit. - -Exact browser journey: create “Schools should ban phones”; join from a second account by code and a third by a locally captured email invitation; assign R1 to the second account; both edit R1, format a link, undo/redo and see each other's cursors. Rename/reorder/duplicate a custom file, trash and restore it. Suggest a replacement, reply and mention, accept one change and reject another. Disconnect browser two for 30 seconds, type, reconnect and reload. Save a version, restore while browser two is offline, and recover its old draft without resurrecting it. Restart the gateway after Saved. Remove browser two and prove it receives neither new text nor accepted writes. Create a second motion and reopen the first from the archive. Repeat essential editing/review on a phone viewport and with keyboard only. - -Finish with an explicit-file commit and PR titled `feat(workspace): collaborative case writing`, one fresh substantive review focused on durability/revocation, exact-head CI and CodeRabbit babysitting, then authorized merge. Run the merged build through the private trial procedure below and give Adam its URL and this journey. Cleanup belongs to the coordinator. - -## Milestone 2: practise from the case and keep the results - -Outcome: Adam selects an argument, sends it into Attack, Crossfire or Sparring without retyping the motion, reviews feedback, and returns later to the same case, sources, chats and results. Personal improvement drives the workspace; team preparation shares the same evidence and writing. - -- [ ] Add workspace sources with provenance, search, quoted evidence and citation insertion. Explicit Copy to workspace preserves attribution without exposing the private library. Citations retain bibliographic snapshots when a source changes or disappears. Reuse existing source import/quality UI; shared selection must not change every member's AI context. -- [ ] Add named shared chat threads. Outside Ask chats stay private. Persist messages/request IDs and assistant progress or interrupted status so reload never loses a turn. Serialize generation per thread, deduplicate retries, and charge existing quotas to the requester. -- [ ] Add an explicit private-or-workspace context at the shared AI boundary. Server loads authorized durable documents, shared sources and selected threads; reject foreign IDs, bound context and show included/omitted material. Recheck membership before returning or storing results. Imported text cannot authorize operations. No new vector service. -- [ ] Reuse the milestone 1 proposal path for AI edits. Additions, deletions, replacements, formatting and citation changes have clear diffs. Generation/rejection leaves the document byte-for-byte unchanged. Only a human decision can mutate it; stale or overlapping targets require regeneration. The model never receives a direct editing capability. -- [ ] Connect Generate/case, Factoids/evidence, Brainstorm/questions, Attack, Ask, Flowchart, Crossfire and Sparring using explicit workspace/document/version/side context and a Return to workspace action. Reuse existing features, not entire store-dependent pages embedded inside another page. Persist validated result snapshots, practice progress/checkpoints and feedback against the originating workspace and pinned document version. Late results after navigation must not land in another workspace. Flowchart edits remain resumable. Existing private routes keep their private persistence. -- [ ] Workspace-started sessions clearly announce shared scope; their results remain attached automatically. Personal practice stays private, with explicit copy/share into the workspace. Do not silently share historical private sessions or use teammates' work for personal coaching. - -Likely ownership: `src/lib/ai/client.ts`, `src/lib/workspace/`, workspace panels, the existing Generate/Attack/Brainstorm/Factoids/Ask/Flowchart/Crossfire/Sparring pages and their stores, `supabase/functions/{ai,ai-stream}/`, `_shared/` context handling, additive workspace source/thread/artifact migrations and locales. Extract only the context/persistence seams each flow needs. - -Smallest meaningful red tests: new `src/test/workspace/tool-context.test.ts` proves each tool preserves motion/side/version and saves once to its origin after a route switch. New Edge context tests deny private/foreign content and a revoked requester during generation. Extend proposal tests to reject stale AI changes and forged citations. Database tests prove workspace sharing versus private Ask/library isolation. One browser test reloads a partly completed practice session and a completed result from a second member's account. - -Focused gates: workspace context/client tests, existing tests for the touched practice flows, `npm run test:edge`, `npm run test:collaboration` and the locked database/integration gate. Broader gates: shared completion gate, with old private-tool regression journeys as well as workspace tests. - -Exact browser journey: reopen milestone 1's motion, add evidence and insert its citation; start a named team thread and ask about both sides. Reject an AI rewrite, then accept one independent change in a new proposal. Select R1 and launch Attack, questions, Flowchart, Crossfire and Sparring with prefilled inputs. Interrupt/reopen practice, finish it, reload, and have a teammate open the attached feedback. Switch workspaces mid-request and prove the result stays with the original. Open ordinary Ask and verify no shared thread or source leaked into it. - -Finish with commit/PR `feat(workspace): connect evidence and practice`, fresh substantive review focused on context isolation and result durability, exact-head CI/CodeRabbit babysitting and authorized merge. Serve the merged private trial and give Adam the complete practice journey; perform the same cleanup. Live AI/provider proof requires already-authorized local provider access. A fixture response proves wiring only and must be labelled as such; do not call this milestone fully verified without a real authorized practice run. - -## Milestone 3: reuse writing and move documents in and out - -Outcome: Adam imports an existing speech, reuses a private template, prepares it with teammates, and downloads a useful PDF/Word packet. Google school/personal account choice is explicit and does not change DebateLab identity. - -- [ ] Add account-private snippets and speech templates in the existing Library, reusable across workspaces by independent copy. Save selected text or speech structure deliberately; teammates cannot browse the private library. Later template edits never rewrite inserted content. -- [ ] Finish Markdown and DOCX independent-copy import with preview, provenance and clear unsupported-content notices. Reuse `document-conversion.ts` but close silent omission paths for supported editor nodes, ordered lists, marks, links and tables. Do not use the source importer's truncated plain text for speeches. Bound input and ZIP expansion, parse time and document size; reject unsafe URLs/embeds and never mount imported HTML unsanitized. -- [ ] Export individual documents and ordered whole-workspace PDF/Word packets from a captured durable revision manifest. Include motion, sides, file order, assignees, citations/bibliography and selected practice artifacts. Default to accepted content, exclude trash/archive visibly, and offer labelled review appendices. Preserve selectable multilingual text, real links, lists and paginated tables. Reuse docx, jsPDF and existing font/export utilities. -- [ ] Add multiple Google connections separate from app login, with a visible account chooser for every import/export. Use connection IDs and server-side ownership checks. Keep refresh tokens encrypted and server-only; short-lived Picker tokens stay in memory. Bind one-use state/PKCE/nonce and exact callbacks to the app user, serialize refresh, preserve omitted refresh tokens and clear local custody on disconnect. Remove sessionStorage token dependence from the migrated export path without breaking legacy export. -- [ ] Reuse the existing `drive.file` grant and export-gdoc formatting. Import user-selected Google Docs as independent copies with source URL/account label/revision provenance and preview all supported tabs. Never sync back. Export creates a new Google document in the chosen account; retries return/finish the same provider file. No automatic Drive sharing. Missing file grants require Picker selection, not broader scope. - -Likely ownership: existing `src/lib/workspace/document-conversion.ts`, `src/lib/export-utils.ts`, `src/lib/pdf-export-utils.ts`, `src/pages/LibraryPage.tsx`, export/workspace dialogs, `src/lib/supabase/AuthProvider.tsx`, `supabase/functions/export-gdoc/index.ts`, new import/connection handlers, private OAuth tables and owner-only template policies. Update `supabase/README.md` with configuration names only. - -Smallest meaningful red tests: extend `document-conversion.test.ts` with numbered lists, nested marks, long tables, hyperlinks and Hungarian accents plus unsupported-input notices and decompression limits. Test export while another member edits, preserving one revision manifest. New template RLS tests deny teammates and prove copied content independence. Native OAuth handler tests cover wrong connection owner, callback replay, refresh races, omitted refresh token, disconnect, school denial and timeout after remote file creation. Keep existing Google export tests passing. - -Focused gates: conversion/template/Google tests, native Edge handlers, locked database gate and rendered PDF/DOCX inspection in a real reader. Broader gates: shared completion gate, mobile/WebKit import/download/account-picker paths and regression of all earlier workspace journeys. - -Exact browser journey: save an R1 paragraph as a private snippet and a speech as a template; insert both into a second workspace, modify the originals and verify the copies stay unchanged. Import Markdown and Word speeches, inspect previews and edit the imported copies. Select a school Google account to import a Doc, then a personal account for export, without changing app identity or the original Doc. Export one speech and both-side PDF/Word packets; inspect headings, page breaks, tables, links, citations and accented text. A teammate must be denied access to the template library and either connection. - -Finish with commit/PR `feat(workspace): document portability and private reuse`, fresh substantive review focused on token custody and fidelity, exact-head CI/CodeRabbit babysitting and authorized merge. Serve the merged private trial, supply the packet/reuse journey and clean up. The narrow decision gate here is existing Google application/callback/key availability. If it requires new credentials, callback configuration, encryption-key provisioning, school approval or broader scopes, stop that integration for concrete authorization. Continue local imports/exports; mark Google live proof blocked, never complete the full contract on mocked OAuth. - -## One completion procedure for every milestone - -The implementation coordinator owns coding, tests, commits, PRs, reviews, fixes, CI monitoring, merge, preview verification and cleanup. Adam tries the link and gives product feedback. Do not ask him to run commands or manage routine handoffs. The steps below are part of each milestone's exit, not another milestone. - -1. On an authorized implementation branch, confirm root/branch/status/base, use Node 22 and `npm ci`, preserve unrelated changes, write the named failing behavior test, observe the intended failure, then implement the journey. Keep infrastructure in the PR that uses it. Use coherent commits with explicit staged paths. -2. Run focused tests while editing. At final head run `npm run verify` once. It already includes docs:check, lint, typecheck, unit coverage, test:collaboration, test:edge, build and desktop/mobile Chromium. Also run `./.superset/db-gate.sh`, the new locked real-Auth collaboration journey, and `CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run test:e2e:webkit`. Install WebKit if absent. Do not reuse an unrelated dev server. Repeat only affected checks after fixes, then invalidate any broader result affected by those fixes. -3. Review the final diff yourself, then obtain one fresh substantive branch review. Use configured Terra reviewer for normal reviews and Sol high for security-sensitive credential/access work. Require concrete findings about the milestone's failure cases and record the reviewed SHA. Fix valid findings and re-review changed risk areas. No separate spec-review ceremony for every component. -4. Under explicit execution/PR authorization, push the feature branch and open one PR with outcome, tested journey, current SHA and environment limitations. Monitor `gh pr view` and `gh pr checks` for that exact head, read CodeRabbit comments/threads and resolve substantive findings. Recheck after every push. CodeRabbit Free summary is recorded as summary-only, with the fresh substantive review supplying the actual review; do not buy a plan or pretend a walkthrough approved code. Pending, failed or rate-limited review is not a pass. -5. Merge only after current-head required checks and substantive review pass and no actionable findings remain. Do not weaken protection. If Actions minutes are the sole blocker, use the standing exception only with satisfactory local evidence, CodeRabbit disposition, explicit exception reporting and protection permitting merge. Otherwise keep fixing or report the genuine blocker. Verify merge SHA and base ancestry, update the execution checkout safely, then build and verify that merged SHA for the private link. -6. Record local, exact-head CI, private Tailscale, existing staging and production evidence separately. A normal stub browser pass is rendering proof only. Report verified, partially_verified or blocked, including unavailable live AI/Google/email checks. No staging run, backend deployment or remote migration is authorized by this plan. - -## Private Tailscale trial: minimum safe procedure - -Implement the missing local integration/preview support in milestone 1, reuse it thereafter. Tailscale CLI help and [official Serve guidance](https://tailscale.com/docs/use-cases/application-testing/share-local-dev-server-with-team) were checked. Existing `svc:debate` already points at port 8080, and host ports 443, 3773 and 3873 already serve other tools. Never reuse, stop or reconfigure those services. - -1. Inspect `tailscale status`, `tailscale serve status --json`, Funnel status and listener/process ownership. Choose unused loopback and tailnet HTTPS ports, recording actual values and the merged SHA. Candidate ports are 18480 for the app proxy and 18443 for Serve; verify before binding. Missing tailnet login, HTTPS capability or permission is a specific blocker, not permission to change tailnet policy. -2. Extend the existing `.superset/db-gate.sh` lifecycle to hold the shared database lock for full local integration and a bounded trial session. Use full local Supabase Auth/API plus the collaboration process, not only `supabase db start`. Record that the active preview owns the stack and lock. Do not attach to someone else's containers. Use isolated test accounts and a controlled invitation mail sink, never send external trial mail. Use test-only fixture values; no operational credential creation or rotation. If required local credentials cannot be supplied within authorized test setup, stop before serving. -3. Add an explicit local-preview mode to `src/lib/supabase/env.ts` with a separate auth storage key, local URLs and a fail-closed missing-config check. Ignore the production/staging localStorage toggle in that mode. Build static assets from the merged SHA. Use the existing Nginx pattern as a task-owned loopback proxy for those assets, only the required local Supabase Auth/REST/Functions paths and the collaboration WebSocket. Never serve the repository directory, `.env`, database port, Studio, mail inbox admin UI or service-role API. Reject unexpected hosts/origins; configure the exact preview origin in local handlers and WebSocket validation. No broad CORS wildcard. -4. Browser traffic must use the private HTTPS origin and WSS, not the viewer's localhost. Preserve app authentication and RLS behind Tailscale. Disable session replay/content logging and keep secrets server-side; only the local public client key may enter the bundle. Validate that missing preview configuration aborts and that no request reaches known production/staging endpoints. Verify unauthorized document and sensitive-path requests fail before sharing. -5. Start only the recorded route with `tailscale serve --bg --https=18443 http://127.0.0.1:18480`, substituting the verified unused ports. Never use Funnel. Read back Serve configuration and verify it still targets the owned proxy. From an authenticated tailnet browser run the milestone's real two-user journey, reload and WebSocket reconnect; confirm non-tailnet access fails. Record actual URL, merged SHA, trial lifetime and which external providers are real or unavailable. Give Adam that verified URL, never an invented hostname or a link to the existing port-8080 service. -6. Keep the preview available for the stated trial window under an active owning task. Preserve trial writing in a local protected snapshot/export before cleanup and prove it can be restored for the next milestone; do not silently erase it with `--no-backup`. No snapshot, credential or authenticated capture belongs in Git. At trial end disable only the allocated Serve endpoint, using the installed CLI's targeted `--https=18443 off` form after confirming syntax, never `serve reset`. Stop only owned PIDs/containers, stop the local Supabase stack through the wrapper and release its lock. Verify the trial URL is unavailable and pre-existing Serve entries/listeners are unchanged. On failure run the same cleanup; report any preservation failure before deleting data. - -## Authorization and stop conditions - -Planning authorizes none of the execution above. Future implementation authorization must include the intended PR/babysitting operations and local private trial. A known complication must be settled before an external write: PR pushes trigger Vercel Preview, and merges to main have triggered Vercel Production and protected staging automation. If the future instruction still prohibits those side effects, do not push or merge until Adam authorizes the exact automatic effects or an authorized operator provides an isolated non-deploying path. Do not silently disable CI or change hosting configuration. Complete local work and its trial preparation while that specific gate is pending. - -Stop the affected operation for new provider/sender/OAuth credentials, encryption keys, broader OAuth scopes, redirect/consent configuration, purchases/billing, a production WebSocket hosting target, tailnet policy changes, remote migrations/functions, or destructive data operations. Present the exact target/change and reversible path. Never use production as a substitute. An execution merge instruction does not override this plan's explicit no-staging/no-production boundary without a concrete later authorization. - -Data-loss or authorization failures block the milestone itself: acknowledged text lost, revoked users receiving new content, foreign workspace access, partial proposal commit, cross-account draft/token leakage, or silently dropped exported content. Preserve failing cases and fix them; do not downgrade the required behavior. - -## Requirement coverage and deliberate deferrals - -- Milestone 1 owns motion-once persistence, changing squads without a permanent team/roster, both sides, eight defaults, custom-file lifecycle, assignments without editing restrictions, code/email joins, member-managed access, owner-only deletion and retained old workspaces except explicit revocation/deletion. -- Milestone 1 owns real character collaboration, rich text/Markdown authoring, links, local undo/redo, presence/cursors, offline/reconnect recovery, saved history/restore, human suggestion mode, inline comments, replies and @mentions. Production email delivery is a named operational deferral until an approved sender exists; the handler and local email journey are not deferred. -- Milestone 2 owns shared evidence/provenance/citations, shared named workspace chats versus private outside chats, authorized AI context across documents/sources/chats, reviewable AI diffs and human-only acceptance, and the complete case/evidence/Attack/questions/Flowchart/Crossfire/Sparring return-and-resume loop. -- Milestone 3 owns the account-private snippet/template library, independent Markdown/DOCX/Google imports, Google provenance without sync, useful document/whole-workspace PDF and Word export, multiple Google accounts and explicit per-operation choice, narrow-scope reuse and private token custody. Live Google consent is an operational deferral only when the named authorization gate blocks it; it remains a required feature, not a removed requirement. -- All milestones own aligned English/Spanish/Hungarian copy, mobile/responsive behavior, accessibility, authentication/RLS, revocation races and data-loss prevention. Security and recovery are never deferred to a hardening PR. -- **Coach Memory is explicitly deferred until the core workspace is usable and the three journeys pass.** Its next deliverable is private, evidence-linked recurring weaknesses across the user's own practice sessions and one recommended next drill. It may consume milestone 2's attributed practice records with explicit user control. No teammate profiling, scoring framework, memory schema or background extraction in these three PRs. -- Defer permanent teams, opponent access and two-way Google sync because they contradict the product contract. Defer a new design system, broad personal-store rewrite, vector database, multi-node collaboration, paid editor, managed hosting, automatic history pruning and permanent purge because the first release does not need them. Keep existing retention behavior truthful; never advertise deletion that has not occurred. - -Plan self-check: every locked decision above has an owner or named deferral. The old plan's merged work is reused, unproven spike boundaries are identified, each milestone ends in a working trial, and no implementation was performed to produce this file. diff --git a/plans/2026-09-25_074255-current-debate-foundation.md b/plans/2026-09-25_074255-current-debate-foundation.md deleted file mode 100644 index b390a9501..000000000 --- a/plans/2026-09-25_074255-current-debate-foundation.md +++ /dev/null @@ -1,105 +0,0 @@ -# PR 1: Current Debate - -**Status:** Shipped in #72 (merged 2026-09-27 as `5ee0b05`). Kept as the record. - -## What Adam wants - -DebateLab's existing features feel like separate tools. A student enters the same motion repeatedly, manually carries material between pages, and can accidentally use sources from an older topic. Adam wants a lightweight personal debate context now, without rebuilding the postponed collaborative Workspace. - -Current Debate is the personal foundation that a future Workspace can add teammates and shared editing around. It is not a document editor, team system, or smaller copy of the failed Workspace. - -## Product rules - -- One Current Debate follows the signed-in user throughout the app. -- It represents the whole motion because students prepare both A favor and En contra. The debate itself has no fixed side. -- Results record A favor, En contra, both, or neutral when relevant. -- Generate, Attack, Brainstorm, Factoids, Ask, Flowchart, Crossfire, and Sparring results attach automatically to the Current Debate. -- Every source belongs to exactly one debate. There is no global source library because each school debate is usually a different topic. -- If a user enters another motion in a feature, DebateLab must ask whether to switch to an existing debate, create a new debate, or use the motion once without attaching it. -- One-off work stays outside every debate unless the user later attaches it. -- After five days without meaningful work, DebateLab blocks the app and asks whether the user is still preparing the Current Debate. The only choices are Continue, Start a new debate, or Switch debate. -- Starting another debate does not delete the old one. Old debates remain available until the user deletes them. -- Each debate has structured settings plus short free-form instructions for format, roles, judge expectations, agreed definitions, school conventions, and writing preferences. -- Keep collaborative Workspace hidden and untouched. - -## Exactly where Current Debate lives - -### Desktop header - -Replace the crowded feature navigation with: - -`DebateLab | Home | Current debate ▾ | Library | History | spacer | Language | Theme | Settings` - -- Current debate sits immediately after Home and before Library. -- Do not keep Generate, Attack, Ask, Crossfire, Sparring, More, or any other feature in the header. -- The compact header control says `Current debate` and has a chevron. Its accessible label includes the current motion. -- Clicking it opens a small popover with the full motion, last activity, source/output counts, and four actions: Open debate, Switch debate, Start new debate, and Use one-off mode. -- If no debate exists, the control says `Start debate`. -- Language, theme, and Settings remain grouped on the far right. - -### Mobile header - -- First row: DebateLab logo/name on the left, language, theme, and menu on the right. -- Second compact row: `Current debate ▾` plus the motion truncated to one line. -- The mobile menu contains only Home, Current debate, Library, History, and Settings. -- Do not put every feature back into the mobile menu. - -### Home - -- Keep Home as the main tool launcher. -- At the top, show the Current Debate motion, last activity, sources, output count, and an Open debate action. -- Keep the existing Prepare and Practice tool groups below it. -- Opening a tool from Home automatically uses the Current Debate unless the user deliberately chooses one-off mode. - -### Current Debate dashboard - -- Show the full motion first. -- Show A favor and En contra as equal sections, with their latest relevant work and counts. -- Show debate-specific sources, recent work, and direct actions for the existing preparation and practice tools. -- This page organizes existing results. It does not add files, collaborative editing, comments, teammates, or Workspace concepts. - -### Inside every feature - -- Directly below the feature title, show a compact strip: `Working in: <motion>` with the selected side where relevant. -- The strip offers Change and Use once actions. -- The form starts with the Current Debate motion already filled. Do not silently overwrite text the user has already edited. -- A different motion triggers the Switch, Create, or Use once choice before submission. -- PR 2 will add the detailed “Using 6 sources and 2 outputs” control above the submit button. - -### Library and History - -- Library shows sources for the Current Debate only. Switching debates switches the Library. -- A user must select or create a debate before adding or importing a source. -- History defaults to the Current Debate, with older debates and one-off work still reachable. It must never flatten all material into one context again. - -## Data migration - -- Keep legacy cases, attacks, brainstorms, factoids, chats/messages, flowcharts, sparring sessions, and sources created within the last 30 days. -- Put that recent material in an Unsorted view. Suggest a debate using exact motion/title matches, but require the user to confirm attachment. -- Delete older legacy material and dependent source files/messages in the migration. -- This is a one-time cleanup. Do not add permanent 30-day deletion for new debates. -- Write and test the migration locally, but do not apply the destructive migration remotely without Adam's explicit authorization for that environment. - -## Proof - -- Database tests for ownership, current-debate switching, source isolation, and the 30-day cutoff. -- Focused UI/store tests for the five-day blocking gate, motion mismatch choices, and automatic result attachment. -- One desktop/mobile browser journey covering two debates and proving their motions, sources, and results never mix. -- Run locally at final head: - -```bash -PATH=/home/codex/.nvm/versions/node/v22.23.2/bin:$PATH npm run verify -./.superset/db-gate.sh -``` - -GitHub Actions are currently unreliable. Record local results in the PR and do not weaken tests to satisfy CI. - -## Agent prompt - -Implement PR 1 from `plans/2026-09-25_074255-current-debate-foundation.md` in a fresh worktree from current `origin/main`. - -Adam wants DebateLab's existing tools to feel like one product. Build a personal Current Debate that carries the whole motion, both sides, debate-specific sources, and automatically saved results across every existing feature. Follow the plan's header, mobile, Home, debate-dashboard, feature-strip, Library, History, five-day gate, one-off, and migration behavior exactly. Preserve DebateLab's current visual style. - -Do not revive collaborative Workspace, build a document editor, add teams, add a second storage system, or create abstractions for future Workspace work. Use the smallest changes that fit the existing stores and tables. Start with focused failing tests, then implement the complete browser journey. - -Run focused checks while working, followed by `npm run verify` and `./.superset/db-gate.sh` once at final head because GitHub Actions are currently unreliable. Commit, push, and open a PR titled `feat: add personal current debate context`. Include exact local results. Do not merge, deploy, or apply the destructive migration remotely. diff --git a/plans/2026-09-25_074255-debate-ai-quality-modules.md b/plans/2026-09-25_074255-debate-ai-quality-modules.md deleted file mode 100644 index 7d788e30a..000000000 --- a/plans/2026-09-25_074255-debate-ai-quality-modules.md +++ /dev/null @@ -1,78 +0,0 @@ -# PR 2: Debate-aware AI and smaller modules - -**Status:** Shipped in #75 (merged 2026-09-28 as `f0f2f75`). Kept as the record. - -## What Adam wants - -Once Current Debate exists, every DebateLab feature should understand the same preparation instead of behaving like a blank tool. The model should receive useful debate context automatically, while the student can see and change what will be sent. The current prompts also need deliberate improvement, and the oversized AI Edge Function needs smaller internal files so future fixes do not require understanding an 88 KB entrypoint. - -This PR improves the existing product. It does not restart Workspace or create a new AI platform. - -## How debate context follows the user - -- Every request receives the Current Debate motion and the requested side or both sides. -- Every request may use that debate's sources, settings, free-form instructions, and saved outputs. -- DebateLab chooses relevant outputs automatically using simple, predictable rules: feature, side, artifact type, pinned state, and recency. -- The server loads and authorizes the real text. The browser sends IDs and preferences, never trusted context text. -- No source, result, instruction, or chat may cross into another debate. - -### Visible override inside each feature - -Place one compact context control after the feature inputs and directly above the primary submit/start button: - -`Using 6 sources, your A favor case, and 2 recent arguments [Review]` - -- Review opens a sheet or popover listing included sources and outputs with checkboxes. -- Users can include or exclude items for that request. -- The default selection returns on the next request unless the user pins an output or changes a source's active state for the debate. -- Show excluded-by-size counts honestly. Never claim the model saw omitted content. -- In one-off mode, the control says that no Current Debate material will be used or saved. -- Keep the compact `Working in: <motion>` strip from PR 1 below the page title. - -## Prompt work - -- Give Generate, Attack, Brainstorm, Factoids, Ask, Crossfire, and Sparring their own small prompt modules. -- Keep shared language, citation, research, injection, schema, and safety rules in one common module. -- Improve each existing prompt against two representative local examples, one for each side where applicable. Do not invent a prompt CMS or giant evaluation suite. -- Generate should produce speech-ready claims, warrants, evidence use, impacts, and weighing for the requested role. -- Attack should identify the actual target, explain the flaw, and provide a usable response instead of generic criticism. -- Brainstorm should create genuinely different strategic directions. -- Factoids should return cuttable sourced material and label uncertainty. -- Ask should answer the requested mode directly and use only context it received. -- Crossfire should stay concise, track real exchanges, and never invent concessions. -- Sparring should remain in role during the round and give evidence-linked feedback afterward without pretending it heard delivery or confidence. -- Replace the old global custom-prompt path with the Current Debate's structured settings and short free-form instructions. -- Treat all user instructions, sources, outputs, and transcripts as untrusted debate content. They cannot override authorization, schemas, citations, quotas, or safety rules. - -## Smaller Edge code without more services - -- Keep the deployed `ai` and `ai-stream` endpoints unchanged. -- Leave `supabase/functions/ai/index.ts` as the HTTP entrypoint and dispatcher. -- Move each feature's handler into a focused internal module. -- Keep authentication, entitlements, quotas, research, model routing, costs, request budgets, error mapping, and response headers shared once. -- Move code with behavior preserved before changing its prompt. This makes regressions obvious. -- Do not create seven Edge Functions, new deployment configuration, a vector database, or a prompt-management system. - -## Proof - -- Focused tests for cross-debate isolation, forged IDs, context overrides, size bounds, and prompt-injection boundaries. -- Existing schema/contract tests stay green for all seven features. -- One bounded local real-provider smoke per feature when credentials exist. Do not run a model matrix. -- Run locally at final head: - -```bash -PATH=/home/codex/.nvm/versions/node/v22.23.2/bin:$PATH npm run test:edge -PATH=/home/codex/.nvm/versions/node/v22.23.2/bin:$PATH npm run verify -``` - -GitHub Actions are currently unreliable. Record local results and distinguish fixture checks from live-provider checks. - -## Agent prompt - -After PR 1 merges, implement PR 2 from `plans/2026-09-25_074255-debate-ai-quality-modules.md` in a fresh worktree from current `origin/main`. - -Adam wants every existing feature to understand the Current Debate without silently dumping everything into the model. Add server-authorized automatic context selection and the visible Review control exactly where the plan specifies. Improve all seven feature prompts using two representative examples each. Split the oversized AI entrypoint and prompt file into focused internal modules, but keep the deployed `ai` and `ai-stream` endpoints stable. - -Reuse existing auth, quotas, research, routing, costs, persistence, schemas, and UI patterns. Do not create separate deployed functions, a vector database, a prompt platform, or any Workspace dependency. Run focused local tests while working, then `npm run test:edge` and `npm run verify` once at final head. Run at most one representative live smoke per feature when credentials exist. - -Commit, push, and open a PR titled `refactor(ai): add debate-aware context and focused feature handlers`. Include exact local evidence. Do not merge or deploy. diff --git a/plans/2026-10-01-voice-sparring-crossfire.md b/plans/2026-10-01-voice-sparring-crossfire.md deleted file mode 100644 index 7a5725dbe..000000000 --- a/plans/2026-10-01-voice-sparring-crossfire.md +++ /dev/null @@ -1,32 +0,0 @@ -# Voice Sparring and Crossfire - -**Status:** Shipped in #84 (merged 2026-10-02 as `791ccb0`). Kept as the record. Written 2026-10-01 against `main` at `fe6100a`. It records only the general choices. Implementation details are up to the implementer. - -## Why - -Sparring and Crossfire are typed today. The sparring scorecard admits it judges delivery "from the written speech, not audio". Real debate is spoken, so students should be able to practise out loud against an opponent who talks back. - -## Adam's choices - -- **Opponent voice: ElevenLabs Eleven v4, with the browser voice as fallback.** The opponent speaks through ElevenLabs `eleven_v4`; whether the Turbo variant is better for back-and-forth Crossfire is up to the implementer. If the key is missing, the user is over their cap, or the call fails, the browser's built-in `speechSynthesis` takes over and the round continues. -- **Use v4 expressively.** The opponent's AI responses should include inline audio tags such as `[sighs]`, `[laughs]`, `[pause]` and `[sarcastic]`, used the way a sharp human debater would: occasionally and with purpose, never every sentence. The tags must not show in the on-screen text or the saved transcript, and the browser fallback voice must not read them aloud. -- **Student speech: browser speech recognition.** This is the Web Speech API, which already powers dictation (`src/hooks/use-speech-to-text.ts`). Where it isn't supported, or microphone permission is denied, voice mode is hidden with a short reason and typing works as it does today. -- **Scope: Sparring and Crossfire.** - -## Fixed constraints - -- The ElevenLabs key lives only on the server. The browser asks DebateLab's backend for audio, and the backend enforces auth, a per-user daily voice cap and cost accounting, reusing the existing quota and cost code. -- Adam adds the `ELEVENLABS_API_KEY` secret himself. The implementer never creates, prints or changes secrets. Without the key, everything must work on the browser fallback, including tests. -- DebateLab never records or stores the student's audio. In Chrome, recognition audio goes to Google, and ElevenLabs receives the opponent's text, so the privacy page and a first-use notice must say both. -- Feedback states only measured facts: speaking time against the limit, words per minute, filler words (per language), long pauses and structure. It never claims to have heard tone, confidence or volume. Typed rounds keep today's wording. -- All new UI text exists in English, Spanish and Hungarian. -- Typed Sparring and Crossfire, saved sessions and Current Debate context keep working. Old saved sessions still load. - -## Finish line - -Push a branch, open a PR with the test evidence, and babysit it with the `babysit-pr` skill until it is ready to merge. Do not merge, deploy Edge Functions, apply migrations remotely, or touch secrets. Each of those needs Adam. - -## References - -- [Eleven v4 overview](https://elevenlabs.io/docs/overview/capabilities/text-to-speech/eleven-v4) -- [ElevenLabs models](https://elevenlabs.io/docs/overview/models) diff --git a/plans/2026-10-02-missing-translation-keys.md b/plans/2026-10-02-missing-translation-keys.md deleted file mode 100644 index 3d8e0bd48..000000000 --- a/plans/2026-10-02-missing-translation-keys.md +++ /dev/null @@ -1,35 +0,0 @@ -# Missing translation keys - -**Status:** Shipped (Done). `chore/i18n-missing-keys` added 90 literal keys and 40 count forms; this follow-up localizes Flowchart strength badges and the Hungarian landing badge. - -## Problem - -Components call `t('some.key', 'English fallback')`, but 88 of those literal keys are in no catalog. i18next then renders the inline English fallback, so Spanish and Hungarian users see English in toasts, confirmation dialogs, Library folder actions, Flowchart controls, the Google Drive dialog, and more. `src/test/i18n.test.ts` cannot catch this: it compares key sets between catalogs, and these keys are missing from all three. - -The agentic suite found the first four on every main page (`themeToggle.toggle`, `sparring.setup.speakingOrder`, `modelSelector.label`, `modelSelector.selectFor`), and they are fixed. A scan of `src/` for literal `t()` keys absent from `src/locales/en.json` (plural `_one`/`_other` forms count as present) finds the rest. - -## Plan - -1. Add a unit test next to `src/test/i18n.test.ts` that scans `src/` (excluding `src/test/`) for literal `t('a.b')` keys and fails when one is missing from `en.json`, allowing plural suffixes. Dynamic keys such as ``t(`nav.${x}`)`` are out of its reach. -2. Add every missing key to `en.json` with the current fallback text, then to `es.json` and `hu.json` with real translations. Keep the inline fallbacks or drop them, but do it consistently. -3. Verify with the unit test, `e2e/hungarian-parity.spec.ts`, and `verify.sh e2e run agentic/hungarian.e2e.ts`. For toasts and dialogs the main-page checks never open, also run `verify.sh e2e explore "Use the Library, History and Flowchart and report any English text" --session student --agent hungarian`. - -## Missing keys (88, listed per file) - -- `src/components/OfflineIndicator.tsx`: `offlineIndicator.label` -- `src/components/ThemeToggle.tsx`: `themeToggle.current` -- `src/components/features/ExportToolbar.tsx`: `common.discuss` -- `src/components/features/ask/AskChat.tsx`: `askChat.save` -- `src/components/features/brainstorm/BrainstormResults.tsx`: `brainstormResults.noIdeasDesc`, `brainstormResults.noIdeasTitle` -- `src/components/features/export/GoogleDriveAuthModal.tsx`: `googleDriveAuth.connect`, `googleDriveAuth.description`, `googleDriveAuth.failedDesc`, `googleDriveAuth.failedTitle`, `googleDriveAuth.redirecting`, `googleDriveAuth.scopeNote`, `googleDriveAuth.title` -- `src/components/features/factoids/FactoidsResults.tsx`: `factoidsResults.noVerified`, `factoidsResults.useSuggestion` -- `src/components/features/generate/GenerateResults.tsx`: `generateResults.copiedToClipboard`, `generateResults.copyFailed`, `generateResults.undo` -- `src/pages/AskPage.tsx`: `ask.chatSaved`, `ask.chatSavedDesc`, `ask.retryableFailedDesc`, `ask.saveFailed`, `ask.saveFailedDesc`, `common.retry` -- `src/pages/AttackPage.tsx`: `attack.complete`, `attack.completeDesc` -- `src/pages/BrainstormPage.tsx`: `brainstorm.complete`, `brainstorm.completeDesc` -- `src/pages/FactoidsPage.tsx`: `factoids.complete`, `factoids.completeDesc`, `factoids.saveFailed` -- `src/pages/FlowchartPage.tsx`: `flowchart.autoLayout`, `flowchart.edgeAttack`, `flowchart.edgeRebuttal`, `flowchart.edgeSupport`, `flowchart.exportFailedToast`, `flowchart.selectedNode` -- `src/pages/GeneratePage.tsx`: `ask.saveFailed`, `ask.saveFailedDesc`, `generate.caseTitle`, `generate.complete`, `generate.completeDesc`, `generate.noUndo`, `generate.regenerateUnavailable`, `generate.regenerateUnavailableDesc`, `generate.regenerated`, `generate.regeneratedDesc`, `generate.saveCase`, `generate.undone`, `generate.undoneDesc` -- `src/pages/HistoryPage.tsx`: `common.deleted`, `historyPage.caseDeleted`, `historyPage.deleteCaseDesc`, `historyPage.deleteCaseTitle`, `historyPage.deleteFailed` -- `src/pages/LibraryPage.tsx`: `common.clear`, `common.copied`, `common.copy`, `common.deleted`, `libraryPage.activateFolder`, `libraryPage.clearSuccess`, `libraryPage.copyFailed`, `libraryPage.deactivateFolder`, `libraryPage.deleteFolderDesc`, `libraryPage.deleteFolderTitle`, `libraryPage.deleteSourceDesc`, `libraryPage.deleteSourceTitle`, `libraryPage.folderActions`, `libraryPage.folderActionsDesc`, `libraryPage.folderActivated`, `libraryPage.folderActivatedDesc`, `libraryPage.folderDeactivated`, `libraryPage.folderDeactivatedDesc`, `libraryPage.folderDeleted`, `libraryPage.folderDeletedDesc`, `libraryPage.folderMergeConfirm`, `libraryPage.folderRenamed`, `libraryPage.folderRenamedDesc`, `libraryPage.noMatchingSources`, `libraryPage.noMatchingSourcesDesc`, `libraryPage.renameFolder`, `libraryPage.sourceCopied`, `libraryPage.sourceDeleted`, `libraryPage.sourceUpdated`, `libraryPage.sourceUpdatedDesc`, `libraryPage.typeLabel`, `libraryPage.untitledSource`, `libraryPage.useForGrounding`, `libraryPage.useForGroundingDesc` -- `src/pages/TimerPage.tsx`: `timer.disableSound`, `timer.enableSound` diff --git a/plans/2026-10-08-ultracode-review-report.md b/plans/2026-10-08-ultracode-review-report.md deleted file mode 100644 index 8e5b5b458..000000000 --- a/plans/2026-10-08-ultracode-review-report.md +++ /dev/null @@ -1,368 +0,0 @@ -# Ultracode review: ranked report - -**Status:** Review finished 2026-10-09 against `main` at `40738ea` (staging synced to the same commit). Nothing has been fixed yet. This is the work list for the fixes. - -This is the output of the 2026-10-08 ultracode review plan. Ten reviewers each took one area. They read the code and used the real app on staging in headless Chromium: phones, tablets, dark mode, slow networks and a 4x slower CPU. The integrity reviewer used the local full stack, because billing and race repros need a fake Stripe and a database it can reset. Then separate verifiers re-ran every finding from scratch with their own sessions and screenshots, re-rated the severity and grouped findings that one fix covers. - -## Summary - -- **188 findings.** The verifiers grouped them into 159 distinct root causes, and the 17 workstreams below group those further by shared fix. A verifier checked every one of the 188 independently: 168 confirmed, 18 partly confirmed, 1 refuted and 1 that couldn't be checked without spending AI budget. Final severities: 15 high, 74 medium, 85 low and 13 nit. -- **Nothing is critical.** Data isolation holds. RLS is on for all 51 public tables, and probes with other accounts' tokens found no foreign rows. Every Edge Function checks the JWT and ownership, and every SSRF trick against URL import was blocked. Warm loads and in-app navigation are fast (30 to 170 ms). -- **The worst problems are about trust.** - - Saves can quietly stay in the browser only while the app says they worked. - - The next student on a shared computer sees the previous student's drafts. - - Promo codes can take Pro away from paying users, and can be brute-forced. - - Paying users are invited to buy Pro a second time. - - Ask gets stuck when you leave the page mid-answer. -- **Speed problems are almost all first loads and phones.** - - Every route waits for one 1.2 MB entry chunk (368 KB compressed) that holds all three languages and the whole signed-in app. - - The Jam bug recorder loads another 772 KB for every visitor. - - On a mid-range phone profile, content appears after 3.5 to 5 seconds. - - Ask streaming re-renders the whole page every 30 ms. -- **Polish problems mostly come from one global CSS layer in `src/index.css`.** It holds the dashed-box jump you spotted, toasts that never stack, sticky hover after taps on phones, and inverted dark-mode tokens. Deleting most of that layer fixes a large share of the glitches. - -## How to use this report - -The workstreams below are ranked by impact. Each one is a root cause, with the findings it covers, the fix, the main files, and how to prove the fix. Each workstream is sized to be one PR, or a few small ones. Finding ids like `integrity-money-1` point into the detailed appendix, which has each finding's evidence, the verifier's independent check, and links to screenshots and measurements: - -- [Data integrity, billing and quotas](2026-10-08-ultracode-review-report/integrity-money.md) (20 findings) -- [Security: Edge Functions and database](2026-10-08-ultracode-review-report/security.md) (29) -- [Speed: bundle, network and rendering](2026-10-08-ultracode-review-report/performance.md) (27) -- [Motion, theming and mobile layout](2026-10-08-ultracode-review-report/visual.md) (35) -- [Journeys, AI surfaces, accessibility and Hungarian](2026-10-08-ultracode-review-report/ux.md) (77) - -The raw evidence (screenshots, videos, JSON measurements, repro scripts) is in `.verify/ultracode-review-2026-10-08/<area>/`, a git-ignored folder in the primary checkout. Before you start a fix, re-check its line numbers against current `main`. - -Severity scale: **high** means a core flow is broken, data is lost or leaked, money is wrong, or the app is clearly slow or janky for most users. **Medium** is a noticeable glitch, friction or waste. **Low** is minor, and **nit** is tiny polish. - -## Ranked workstreams - -### 1. Writes that pretend to succeed (high) - -**Findings:** integrity-money-1, journeys-1 (same bug found on staging), render-bundle-12, perf-runtime-3 (the getUser part). - -Every write in `src/lib/stores/session-store.ts` asks the Auth server who the user is (`getUserId()` calls `supabase.auth.getUser()`, a network round trip). Any failure counts as "signed out", and the write then lands only in localStorage. On flaky school wifi, or with the auth endpoint returning 503, a student adds a source and sees "Source Added". The database never gets the row, and the source is gone after a reload. Both verifiers reproduced it: offline, and with only `/auth/v1/user` failing. - -**Fix:** take the user id from the local session (`supabase.auth.getSession()`, or the id `AuthProvider` already has) and throw when there is none. Delete the local-only branches so a failed insert reaches the existing error toasts. This also removes 1 to 3 auth round trips per write, and the two `getUser()` calls that delay debate hydration in `debate-store.ts:127` and `sparring-store.ts:481`. While you're there, delete `saveCurrentAttack`, `saveCurrentBrainstorm` and `saveCurrentFactoids`: nothing calls them. - -**Prove it:** route `/auth/v1/user` to 503 in Playwright, add a source, and expect an error toast and no fake success. Then reload and confirm the database matches what the screen shows. - -### 2. The persisted session store (high) - -**Findings:** integrity-money-2, integrity-money-3, integrity-money-13, render-bundle-3, perf-runtime-4. - -`session-store` writes almost everything to localStorage on every `set()`: drafts, every saved case, chat and flowchart, and the full text of every source. That causes four separate problems: - -- **Privacy leak on shared computers.** Sign-out calls `resetLocalData`, which leaves the Generate, Attack, Brainstorm and Factoids drafts behind. The next student who signs in on that browser gets them prefilled. Reproduced with two demo users. -- **Permanent crash for heavy users.** Past about 5M characters (about 45 maximum-size imports across all debates), every write throws `QuotaExceededError`. Zustand's persist has no try/catch, so tool pages show "Unable to load this page", and a reload doesn't fix it. -- **Typing lag that grows with the library.** Each keystroke in a tool form serialises the whole store. By the end of the review the shared pro account had grown to 188 KB per keystroke, with 39 long tasks while typing 41 characters on the phone profile. -- **Lost edits and cross-tab mixing.** `hydrateFromServer` overwrites edits made while it's in flight. Cross-tab sync replaces another tab's whole session store while that tab's Current Debate stays different. - -**Fix:** -1. Keep only drafts and the open result in `partialize`. Drop `saved*`, `autosaved*`, `sourceLibrary` and `sourceFolders`, since hydration replaces them anyway. -2. Wrap the storage in try/catch and debounce it, as `sparring-store` already does. -3. Key persisted drafts by user id (Crossfire's draft already does this), or clear them in `resetLocalData`. -4. Copy debate-store's write-epoch guard into `hydrateFromServer`. -5. Narrow the cross-tab sync to drafts, and re-hydrate debates on the storage event. - -**Prove it:** sign out as user A with unsent drafts, sign in as B, and expect empty forms. Fill localStorage, type, and expect no page error. Hold the sources GET for 6 s, add a source meanwhile, and expect it to survive. Each of these is worth a regression test. - -### 3. Entitlements and billing (high) - -**Findings:** security-db-1, integrity-money-7, integrity-money-5, integrity-money-4, integrity-money-17, integrity-money-18, integrity-money-19, integrity-money-14. - -Three sources write one mutable `subscription_tier` column plus the grace and override columns: the promo RPC, the Stripe webhook and admin grants. Each one writes as if it were the only one. The verifiers reproduced, through the real functions: - -- **Promos take access away.** A paying Stripe subscriber who redeems an `extended_trial` code becomes `trial` and loses Pro models while still paying. An `extra_limit` code drops a Pro user from 50 a day to 3. A `free_month` code cuts lifetime Pro to 30 days (`20260819000000_security_hardening.sql:117-142`). Pro users reach the promo field through Settings > Set Up Billing and through the limit modal. -- **Stripe events wipe promos.** A later `subscription.updated` nulls the lifetime grace date. Cancelling one of two subscriptions sets the user to `expired`. -- **Webhook replays are applied again.** The dedupe row is written after the handlers run (`stripe-webhook/index.ts:65-129`). A replayed `updated(active)` arriving after a cancellation gives the cancelled user Pro again, while the response says `duplicate: true`. Verified as medium: it needs a late or retried delivery. -- **Paying users are sold Pro twice.** At the daily limit, a Pro subscriber sees "Upgrade to Pro - $5/mo". PricingModal shows "Free / Current" and calls `create-checkout`, which never checks for an existing subscription. Coming back from Checkout, Settings ignores `?checkout=success` and shows "Expired" for up to 60 s. -- **Smaller billing issues.** `invoice.payment_failed` downgrades on the first failed retry. A Checkout that completes after account deletion has no backstop. `create-checkout` returns raw Stripe errors, and the `discount` promo type is never applied. Auto Fast gives trial users a model the catalog marks as Pro-only. - -**Fix:** -1. Compute access as the best active grant (an active or trialing subscription, a future grace date, a future trial or an admin override) in `getSubscriptionInfo` and its two client copies. -2. Have webhooks write only the `subscriptions` table. -3. Rewrite `redeem_promo_code_atomic` so it only adds: `GREATEST` for dates and limits, and never lowering the tier. -4. In the webhook, claim the event first (insert with a status), then process it and mark it done. Re-fetch subscription state, or ignore events older than the last one applied. -5. `create-checkout`: return 409 with a portal link when a subscription is already active. -6. In the limit and pricing modals, show "resets at midnight UTC" to Pro users. -7. In Settings, handle `?checkout=success` by polling until the tier flips. -8. Decide whether a failed renewal gets a grace period before the downgrade. That is a product call for you (integrity-money-17). - -**Prove it:** pgTAP tests for Stripe Pro + `extended_trial`, lifetime + `free_month`, and override 200 + `extra_limit` 15. A Deno test that replays an event after a cancellation and checks the tier stays expired. `billing.spec.ts` currently checks only the `duplicate` flag. - -### 4. Ask: stuck, lossy and heavy (high) - -**Findings:** integrity-money-6, mobile-visual-1, render-bundle-1, integrity-money-11. Also see the AI-surfaces findings in workstream 15. - -Ask is the tool with the most problems: - -- **Stuck after leaving.** Leave Ask while it's answering, come back, and it shows "Typing" forever with the input disabled. The unmount cleanup aborts the request but never clears the global `askLoading` flags (`AskPage.tsx:53-67`, `285-288`). -- **No room on phones.** The card has a fixed height, and its control footer is taller than the card. The message area is 0 px on iPhone SE and iPhone 14 and 62 px on a Pixel 7, and the composer overlaps the page footer (`AskChat.tsx:517`, `545`, `582`). -- **Streaming saturates the main thread.** Every 30 ms the whole page re-renders, every past message's markdown is re-parsed, a smooth `scrollIntoView` restarts, and the store rewrites localStorage. That was 110 long tasks for one answer on the phone profile. The auto-scroll also stops readers from scrolling back up. -- **Typed text gets thrown away.** The input is cleared before validation, so "Message is too large" leaves an empty box, and a refused question vanishes from the input. - -**Fix:** -1. Keep the request in the store like the other tools, or clear the flags on unmount and mark the question as interrupted with Retry. -2. Use selectors instead of `useSessionStore()`. -3. Give the streaming bubble its own component, and memoize `Markdown`. -4. Auto-scroll only when the reader is near the bottom. -5. On phones, let the card grow to `100dvh` minus the header, and move the toggles into a "More options" sheet. -6. Have `onSend` return whether the message was accepted, and clear the input only then. - -**Prove it:** fake an SSE stream in Playwright (the verifier's script is in the evidence folder) and count long tasks before and after. Navigate away mid-answer and back. Screenshot Ask on iPhone SE. - -### 5. First load: entry chunk and Jam (high) - -**Findings:** perf-runtime-1, render-bundle-5, render-bundle-6, perf-runtime-2, render-bundle-2. - -- **One render-blocking entry chunk.** It's 1,204 KB raw and 376 KB brotli, and it's the only script before anything renders: i18next plus all three catalogs (324 KB), react-dom, auth-js, zod (88 KB), the stores, unused Supabase realtime and storage (84 KB), and Capacitor. The route's own chunk is only requested after the entry has executed. On the mobile profile (4x CPU, fast 4G), the `/app` h1 appears at 3.3 s and LCP at 3.6 s. -- **Jam for everyone.** `src/bootstrap.ts:13` loads the Jam recorder for every visitor, signed out too: 9 requests and about 772 KB. A/B on the phone profile: script time 2.9 s vs 1.6 s without Jam, and JS heap 16.7 vs 9.8 MB. It wraps `window.fetch`, keeps a request open forever and tags recordings with the user id. - -**Fix:** -1. Bundle `en` only. Load `es` and `hu` with `import()` before first render, reading the saved language in `bootstrap.ts`. -2. Lazy-load the protected app shell (`AppLayout`, `DebateDialogs`, `InactiveDebateGate`) and the store hydrators, so `/` and `/login` skip them. -3. Take zod out of the entry by loading the announcement and sparring parsers lazily, or validating those by hand. -4. Import Capacitor only when `isNativeApp()`. -5. Call `preloadRoute(location.pathname)` before `mountApp()`. -6. Load Jam only on demand, for admins, behind `?jam=1`, or from a "Report a problem" action. Tighten the CSP to match. -7. Add an entry byte budget to `scripts/check-build-boundaries.mjs`. - -**Prove it:** the entry chunk size in the build output, plus 3 cold mobile runs with the harness before and after (the h1 time and the request waterfall). - -### 6. Phone layout breaks (high) - -**Findings:** mobile-visual-2, mobile-visual-3, mobile-visual-7, mobile-visual-8, mobile-visual-14, mobile-visual-9, mobile-visual-10, mobile-visual-12, mobile-visual-15, mobile-visual-16. Overlaps journeys-24 and a11y-i18n-16 and -22. - -- **The Current Debate dashboard scrolls sideways on every phone.** Implicit grid tracks plus `min-width: auto` let a long `truncate` title set the card width. With the shared debate's long source titles, the page rendered at 49% zoom (scrollWidth 800 at 390 px wide). Factoids has the same bug. -- **Dialogs have no max height.** "Start new debate" and Flowchart "Add Node" have their Close, Cancel and Submit buttons off-screen on landscape phones (`dialog.tsx:41`). -- **Library titles are cut to 22 to 114 px on phones, mid-letter.** `truncate` on a flex anchor never draws the ellipsis (`LibraryPage.tsx:88`). The 28 px delete icon sits 8 px from the switch and deletes without confirmation. -- **The mobile menu is a plain disclosure.** The page scrolls under it, taps outside go through to the page, and in landscape Settings sits below the screen edge. -- **Smaller issues.** Tap targets of 16 px (dialog and sheet close buttons), 20 px footer links, a 110 px two-row sticky header, nested fixed-height scroll lists, a cramped Flowchart (`minZoom` 0.5), and no safe-area padding or `theme-color` on the web. - -**Fix:** -1. Add `grid-cols-1` (or `min-w-0` on grid children) wherever a grid only defines `md:`/`lg:` columns. A grep rule in CI stops this coming back. -2. Add `max-h-[calc(100dvh-2rem)] overflow-y-auto` and a gutter to `dialog.tsx` and `alert-dialog.tsx`. -3. Wrap the Library title in `<span class="truncate">`, allow two lines on phones, and move delete into a menu. -4. Turn both mobile menus into a Radix Sheet. -5. Pad the close buttons to 40 px. - -**Prove it:** the harness overflow scan at 320, 390 and 412 px, plus landscape 915x412, with screenshots. - -### 7. The global CSS motion layer (medium; this is your dashed-box bug) - -**Findings:** motion-polish-4, ai-surfaces-11, motion-polish-2, mobile-visual-5, motion-polish-3, motion-polish-5, motion-polish-8, motion-polish-9, motion-polish-10, motion-polish-11, motion-polish-12, motion-polish-15, motion-polish-16, motion-polish-17, motion-polish-18, motion-polish-19. - -- **Your example.** `.border-dashed:hover` (`index.css:472-511`) sets the `animation` shorthand to "marching ants". That replaces the element's `.motion-swap` entrance animation. When the pointer leaves, the entrance restarts from frame 0: the box drops 10 px, blurs and fades back in over about 280 ms. It hits the "Ready to Generate" box and the empty states on Generate, Attack, Brainstorm and Factoids, History and Current Debate. Hovering also turns the rounded 2 px dashed border into square-cornered 1 px lines, and the app has no drop zones. -- **Toasts never stack, expand or leave.** `[data-sonner-toast] { animation: … both !important }` (`index.css:644-646`) pins the transform, so several toasts sit on top of each other and dismissing seems not to work. -- **Sticky hover on phones.** Hover rules aren't gated by `(hover: hover)`, and Tailwind's `hoverOnlyWhenSupported` is off. After a tap, buttons stay raised with a near-black background. -- **No scroll reset between routes.** A new page opens at the old page's scroll position, for example Library with its h1 165 px off-screen. -- **Smaller issues.** Attribute-selector animations restyle every Radix widget and replay on mount. Every route fades and slides in over about 350 ms. A hover lift is applied to almost everything, including non-clickable cards. A global `!important` rule replaces the input focus ring with a faint 2:1 glow. Focus rings are clipped inside "More options". The Generate button shimmers forever. Spinners freeze under reduced motion. The Review sheet takes 500 ms to open. `MotionConfig` pulls framer-motion into the entry chunk. - -**Fix:** -1. Delete the `.border-dashed:hover` block and `@keyframes marching-ants`. -2. Delete the toast animation override, the attribute-selector micro-interactions and the global input-focus rule. -3. Wrap the remaining custom `:hover` rules in `@media (hover: hover) and (pointer: fine)`, and set `future.hoverOnlyWhenSupported` in `tailwind.config.ts`. -4. Add a `ScrollToTop` (`useLayoutEffect` on the pathname, `behavior: 'instant'`, skipping POP navigations). -5. Limit the hover lift to primary buttons. -6. Drop route-level entrance animations, or make them CSS-only and at most 150 ms. - -This is mostly a deletion PR. - -**Prove it:** run the harness's `trackMovement` on the Generate empty state while hovering and leaving, and expect 0 px movement. Show three toasts and expect them to stack. Tap a button on the Pixel 7 profile and expect no lingering hover. - -### 8. Debate scoping and hydration order (medium) - -**Findings:** integrity-money-10, motion-polish-1, perf-runtime-3, motion-polish-7, perf-runtime-10, perf-runtime-5, security-db-7. - -- **A result can show up under the wrong debate.** A result that arrives after a debate switch renders under the new debate, which breaks the "debates never mix" rule. The database row is filed correctly. -- **Pages paint "no debate" first.** On every sign-in, signed-in pages first show "No current debate" with a live "Start debate" button, zero counts and the default model, then flip. Cold `/app/debate` scores a CLS of 0.30 ("poor"). Only `DebateDialogs` reads the stores' `hydrated` flag. -- **Hydration downloads everything.** Every full load runs 14 REST queries: nine unbounded `select=*` across every debate, including the full text of every source, then messages after chats. PostgREST's 1000-row cap silently drops the newest messages for heavy users, and a failed messages query blanks every chat in History. - -**Fix:** -1. Store the debate id with each tool result, render a result only when it matches the current debate, and reset the loading flags in `clearSession`. -2. Add a `useDebateReady()` selector and same-size skeletons until hydration finishes. -3. Hydrate only the Current Debate, with list columns only, and page the rest. -4. Embed messages in the chats query, or load them when a chat opens. Keep the old messages when that query fails. -5. Run `use-subscription`'s three queries in parallel. - -### 9. Quotas that charge for nothing (medium) - -**Findings:** integrity-money-8, security-db-6, security-edge-13, integrity-money-12, security-edge-1, integrity-money-15, integrity-money-16. - -- **Slots are never refunded.** `consume_daily_quota` reserves a slot before any validation or provider call, and nothing gives it back. A provider 500, a short Generate reply or a reload mid-request each cost a trial student 1 of their 5 daily requests. When an Ask stream fails, a second paid non-streaming request is sent silently. A trial user's 5 requests are gone after about 5 Crossfire questions, and the refusal is shown as a debrief failure. -- **Refused requests grow the database.** The `ai` function never validates `feature`, and writes it (up to about 200 KB) into `ai_requests` on every refused request. Any account, even an expired one, can grow the table without limit, and the admin stats page loads every row. Reproduced on staging with a 4,010-character feature. - -**Fix:** -1. Add a service-role `release_daily_quota` RPC, called when the provider fails before any billed usage. -2. Limit the Ask fallback to transport errors. -3. Give sparring `respond` an idempotency key. -4. Count a Crossfire round as one unit, or warn about the cost up front. -5. Validate `feature` against the 7 known values before logging, auth or quota. -6. Stop inserting a row per refusal, and add length CHECKs to `ai_requests`. -7. Delete the unused exact count in `use-subscription`. - -### 10. Promo brute force, still open (high, known as next-15 #2) - -**Findings:** security-db-2, integrity-money-9. - -`redeem_promo_code_atomic` has no attempt limit. EXECUTE is granted to every authenticated user, so it can be called directly through PostgREST. It answers differently for unknown, expired, used-up and already-redeemed codes, and codes are admin-chosen words. Locally, 30 wrong codes took 2.3 s and a valid code still worked afterwards. `redeem-promo` also logs every attempted code as "redeemed". - -**Fix:** as next-15 #2 describes. Add a per-user failure window inside the SQL function (for example 5 failures in 15 minutes), return one generic message, stop logging raw codes, and optionally require 8 or more characters in `create-promo`. Add a pgTAP test. Ship this together with workstream 3's promo rewrite. - -### 11. Edge Function hardening (medium) - -**Findings:** security-edge-2, security-edge-3, security-edge-4, security-edge-5, security-db-4, security-edge-6, security-edge-7 to -12, security-edge-14 to -16. - -- **Seven functions hang on large unauthenticated bodies.** voice, ai-stream, account, admin, announcements, export-gdoc and workspace-invites never answer an unauthenticated POST of about 1 MB (no response after 60 s, reproduced on staging). `ai` hangs above about 600 KB. This is the class #118 fixed only in share and import-source. **Fix:** use `readBoundedBody` or `discardRequestBody` before every early return. -- **Raw error text reaches students.** Users see OpenRouter bodies (up to 16 KB), Deno TLS errors ("invalid peer certificate: Expired" in the Import Failed toast) and "Invalid code point 99999999", always in English. **Fix:** fixed error codes on the server, mapped to i18n strings on the client, and never show a raw 5xx message. -- **The prompt-injection blocklist rejects real debate text.** "At 18 you are now an adult" and "legalise jailbreaking smartphones" are refused with "Suspicious content detected in caseText" (staging, real UI). **Fix:** delete `PROMPT_INJECTION_PATTERNS`. The role-separated prompts and `UNTRUSTED_CONTENT_RULES` are the real defence, and `ai-stream` already runs without the list. -- **Share links can't be removed.** They survive "Delete All My Data", can't be revoked by the owner or an admin, and banned users can still publish. **Fix:** delete `shared_items` in `clear_current_user_data`, add a revoke action with a list in Settings, and add the ban check. -- **The demo-code IP lockout counts its own refusals.** A class behind one school NAT can stay locked out while it retries. **Fix:** exclude `rate_limited` and `invalid_format` from the count, and key the limit by IP and code. -- **Low items:** - - The import parser has catastrophic regex backtracking and crashes on out-of-range entities. - - Signing keys fall back to the service-role key. `SPARRING_INTEGRITY_SECRET` is unset on staging; setting it needs your authorisation. - - Account deletion needs no re-authentication. - - `export-gdoc` allows LAN origins in production. - - Web research text enters prompts without an untrusted-content boundary. - - `export-gdoc` reports success with an empty doc when the update fails. - - Student debate text is written to function logs. - - The admin user list stops at 50 users, and stats load every row. - - The hidden Workspace backend is still deployed and reachable. - -### 12. Deploys break open tabs, and other load waste (medium) - -**Findings:** perf-runtime-6, perf-runtime-7, render-bundle-10, perf-runtime-8, render-bundle-7, render-bundle-8, render-bundle-11, render-bundle-13, render-bundle-15, perf-runtime-9, render-bundle-9, motion-polish-14, perf-runtime-11, perf-runtime-12. - -- **Open tabs break after every deploy.** A missing old chunk returns the SPA shell as `200 text/html` with a one-year immutable header (`vercel.json`). The service worker caches that HTML as JavaScript, and nothing reloads. Every student with a tab open across a deploy gets "Unable to load this page" on their next route. **Fix:** - 1. Change the rewrite source to `/((?!assets/).*)`. - 2. In `sw.js`, cache only responses whose content-type matches. - 3. Version `CACHE_VERSION` with the build id. - 4. Add a `vite:preloadError` handler that reloads once. -- **The first visit precaches 768 KB of images.** That's a 1024×1024 favicon twice (`.png` and `.ico`) plus the og:image, downloaded while the page's own data loads. **Fix:** a 32 px favicon, proper 192 and 512 px manifest icons, and a shorter `APP_SHELL`. -- **Route chunks carry dead weight.** - - Generate pulls in reactflow and d3 (41 KB) for one enum. Use `'arrowclosed'`, and fix the `check-build-boundaries.mjs` regex, which misses `@reactflow/*`. - - Sparring loads recharts (95 KB) before a round starts. Lazy-load the scorecard. - - The landing page loads the full framer-motion. Use `LazyMotion` or CSS. - - The hidden Workspace and Capacitor code still ship to the web. - - About 50 chunks are under 1.5 KB. Set `experimentalMinChunkSize`. -- **Fonts.** Google Fonts loads through chained `@import`s, and Lora is never used. Self-host Inter with a size-adjusted fallback, which removes the text reflow when the font arrives. -- **Smaller items.** The landing h1's opacity-0 entrance holds LCP back about 0.8 s. Sentry and PostHog load their full bundles if production sets their keys. - -### 13. Render cost while typing (medium) - -**Findings:** render-bundle-4, plus the Ask part of workstream 4. - -Thirteen components call `useSessionStore()` or `useSettingsStore()` with no selector. The forms call `watch()` on the whole form and push every keystroke into the store. On the phone profile a Generate keystroke takes 72 ms, against 40 ms for the local-state Ask input. That figure includes the persist cost from workstream 2. **Fix:** selectors or `useShallow`, `useWatch` on the displayed fields, `React.memo` on `SourceRow` and the flowchart node, stable ReactFlow handlers, and an isolated 500 ms Crossfire clock. - -### 14. Theme tokens and dark mode (medium) - -**Findings:** mobile-visual-6, motion-polish-6, mobile-visual-4, mobile-visual-11, motion-polish-13, mobile-visual-13. Overlaps a11y-i18n-2, -9, -11, -12 and -24. - -- **The dark tokens are inverted.** Dark `--muted` is a mid-grey slab, behind the whole 404 page and every tab bar. Dark `--accent` is darker than the page, so every ghost or outline hover becomes a near-black hole. -- **"Muted" text isn't muted.** `--muted-foreground` equals `--foreground` in both themes, so the 377 uses of `text-muted-foreground` render as full-strength body text. -- **Light hovers are invisible.** Light `--accent` equals `--card`. -- **Flowchart was never themed for dark mode.** It shows a white minimap, white controls with invisible icons and a white attribution box. -- **Theme switching is staggered.** The page snaps, cards fade over 260 ms and the body over 420 ms. -- **Hard-coded colours fail contrast.** The Flowchart legend and nodes, and the Settings tier badge. - -**Fix:** retune the tokens in `index.css` so muted and accent are surface steps just above the card, and add a real muted-text token. Map React Flow's classes to the theme tokens. Disable transitions for one frame during a theme swap. Then re-run the contrast checks from workstream 15. - -### 15. Ease of use, AI surfaces, accessibility and Hungarian (medium) - -**Findings:** 77 in [the UX appendix](2026-10-08-ultracode-review-report/ux.md). The two highs there (journeys-1 and ai-surfaces-1) are the same bugs as workstreams 1 and 4. ai-surfaces-13 (a misplaced Sparring radar chart, seen once) was refuted. - -Translation coverage is strong. The Hungarian and Spanish catalogs have full key parity with English, no untranslated catalog strings showed up on any route, `html[lang]` switches, and the AI answered in Hungarian when asked. The problems are elsewhere: - -- **AI tools give no control or recovery.** - - No tool has a Stop button. Ask's only way out is Clear, which wipes the conversation (ai-surfaces-2). - - Failures show only in a 4-second toast, and the results panel quietly goes back to "Ready to Generate" (journeys-13). - - Error text is raw, English-only and offers no Retry, and the client and server timeouts disagree (ai-surfaces-8, a11y-i18n-7; same fix as workstream 11). - - **Fix:** pass an `AbortController` into the existing `signal` option for Stop, keep the error in the results panel with a Retry button, and use the error-code mapping from workstream 11. -- **Results wait for bookkeeping.** - - After the AI answers, about 16 database writes run one after another before "Generate complete" appears (ai-surfaces-4). - - Every run auto-saves about 5 sources with no de-duplication (ai-surfaces-5, journeys-14). - - "Regenerate section" re-runs the whole deep research and the whole case, then keeps one section (ai-surfaces-6). - - **Fix:** render as soon as the response parses, save in the background with one bulk insert, de-duplicate by URL, and send the section to the server. -- **Saved work is hard to manage.** - - Saving a case reopened from History creates an unscoped copy that can't be deleted where it shows up (journeys-2). - - There is no way to delete the Current Debate, the last debate, or saved brainstorms, attacks, factoids, chats or crossfires (journeys-3). Crossfire debriefs can't be reopened (ai-surfaces-12). - - The Library row trash and the saved-flowchart delete act instantly with no confirmation or undo (journeys-4). - - A recorded Generate result loses its History link after a reload (ai-surfaces-7). - - The Flowchart canvas, a running Timer and source-sheet edits are thrown away on navigation without a warning (journeys-9). -- **Interaction glitches.** - - Closing "Start new debate" flashes a full "Switch debate" dialog for about 200 ms (journeys-6). The dialog content is keyed on store state that turns null while the dialog animates out. - - Selecting a Flowchart node pushes the canvas down 127 px under the cursor, which makes double-clicks miss (journeys-7). - - The dashed loading card drops 10 px and blurs on every tool submit (ai-surfaces-11). This is a second cause of your dashed-box bug: `motion-swap` runs when the empty state swaps to the loading state. Keep one dashed container and crossfade only its content. - - On phones, tapping the highlighted button in the first tour step skips the step (journeys-10). - - Discuss shows its summary as a code block with literal `**` (ai-surfaces-10). -- **Keyboard and screen readers.** - - The Timer's global key handler resets a running speech on Escape (even when Escape only closes a select) and takes Space from focused buttons (a11y-i18n-1, journeys-8). - - Library rows nest a link and a switch inside a `role=button` (a11y-i18n-5). - - Flowchart nodes can't be edited from the keyboard (a11y-i18n-6). - - The inactive-debate gate opens with focus on `<body>` (a11y-i18n-4). - - Select triggers, the Factoids slider and the Timer progress bar have no accessible name (a11y-i18n-3). - - The Timer re-announces itself every second (a11y-i18n-10). - - `document.title` is "DebateLab" on every route and overwrites the landing page's SEO title (a11y-i18n-8). -- **Contrast.** In light mode, `--primary` gives 3.86:1 on primary buttons and 3.75:1 on links (a11y-i18n-2), and the focus ring is 1.89:1 (a11y-i18n-11). **Fix:** darken `--primary` to about `hsl(200 98% 32%)` and keep `--ring` in sync. -- **Low items:** - - No skip link, and no focus move or announcement on route change. - - Focus isn't returned after dialogs close (next-15 #14). - - Raw "strong / moderate / weak" enums in every language. - - 194 Hungarian strings copy English Title Case. - - No language switcher on `/login`, and first visits ignore the browser language. - - "1 sources" (missing plural forms). - - Expired users get no warning before they submit. - - Inconsistent export file names, and missing export sections. - - Settings says "Signed in with Google" to everyone and offers "Set Up Billing" to Pro users. - -### 16. Database hygiene (medium to low) - -**Findings:** security-db-5, security-db-3, security-db-8, security-db-9, security-db-10, security-db-11, security-db-12, security-db-13. - -- **Anonymous table writes.** Anyone with the public anon key can insert unbounded rows into `keepalive_events` and overwrite the heartbeat (`GRANT ALL TO anon`; 554 rows readable on staging). User-writable text and jsonb columns have no size caps. **Fix:** a narrow `keepalive()` RPC, revoke the table writes, and add `octet_length` CHECKs. The heartbeat is written by something no migration defines, so capture it in a migration. -- **Low items:** - - Client-role grants depend on platform defaults; a replay gives anon TRUNCATE on 28 tables. - - Judged sparring rounds are saved with no debate id. - - 16 legacy policies use bare `auth.uid()` with roles `{public}`. - - Items can be moved between your own debates by direct UPDATE, and pins outlive their items. - - Dead tables and admin RPCs (`api_keys`, `source_files`, `evidence_cards`, `curated_sources`, `get_daily_usage`, `get_usage_by_user`) can be dropped once you've checked production row counts. - - Redundant indexes, and unindexed foreign keys. - - Nothing purges demo login IPs, expired shares or old usage counters. - -### 17. Code worth deleting - -Deletion appears throughout the workstreams above. Collected in one place: - -- `src/index.css`: marching ants, the toast override, the attribute-selector micro-interactions, the global input-focus rule and most of the global hover lift (workstream 7). -- The `session-store` local-only branches and the three uncalled `saveCurrent*` actions (workstream 1). -- `PROMPT_INJECTION_PATTERNS` and its rejection branch (workstream 11). -- Jam outside on-demand use (workstream 5). -- The hidden Workspace branches in the tool pages and `ai`, the deployed `workspace-invites` function, and Capacitor in the web bundle (render-bundle-13, security-edge-16). -- The Lora font, the unused subscription count query, `MotionConfig` in `App.tsx`, and the dead prototype schema objects (security-db-11). -- next-15 #12 leftovers: server-only dependencies in client `dependencies`, three copies of a sanitizer, and knip gaps (render-bundle-14). - -## Suggested order - -1. **Quick, high-value deletions (a day).** - - Delete marching ants, the toast override and the global focus rule. - - Gate hover with `(hover: hover)` and turn on `hoverOnlyWhenSupported`. - - Add `ScrollToTop`. - - Delete the prompt-injection blocklist. - - Load Jam on demand. - - Use `grid-cols-1` on the phone grids, and give dialogs a max height. -2. **Trust fixes (workstreams 1, 2 and 4).** These are the bugs where students lose work or see someone else's. -3. **Money (workstreams 3, 10 and 9).** One migration for promos, entitlements and quota release, plus the webhook ordering fix and their tests. The production migration and function deploys need your authorisation as usual. -4. **First load (workstreams 5 and 12).** The entry split, the deploy-safe service worker and the chunk weight. -5. **Everything else:** in severity order within each appendix file. - -## What this review did not cover - -- Real phones, Safari or WebKit, and Firefox. Mobile numbers come from Chromium emulation with a 4x CPU throttle on a shared VPS, so timing claims lead with structural evidence: request counts, bytes and long-task counts. -- Production. It was never touched, so production grants, secrets and whether Sentry or PostHog keys are set are unknown. -- Real Stripe Checkout, the billing portal, ElevenLabs voice, Google Docs export and Google sign-in. -- Real screen readers. Accessibility findings come from axe-core, DOM and ARIA, and live-region measurements. -- Write probes against staging security. The write-policy claims rest on the migration text plus a throwaway local Postgres replay. -- The hidden Workspace and the iOS shell, except where their code still ships to the web app. - -Each reviewer's own "not covered" list is at the top of its appendix file. diff --git a/plans/2026-10-08-ultracode-review-report/integrity-money.md b/plans/2026-10-08-ultracode-review-report/integrity-money.md deleted file mode 100644 index 130b06f41..000000000 --- a/plans/2026-10-08-ultracode-review-report/integrity-money.md +++ /dev/null @@ -1,723 +0,0 @@ -# Data integrity, billing and quotas: findings - -**Status:** Review evidence from 2026-10-08/09, for [the ranked report](../2026-10-08-ultracode-review-report.md). Not started. - -20 findings: 5 high, 8 medium, 6 low, 1 nit. An independent verifier re-checked every one. - -Each entry has the final severity, where the problem lives, what the reviewer saw, why it happens, a suggested fix, the independent check and links to the screenshots and measurements in `.verify/ultracode-review-2026-10-08/`. Line numbers are against main at 40738ea. - -Not covered by the integrity-money reviewer: - -- Real Stripe Checkout, the billing portal and checkout.session.completed / invoice.payment_succeeded handling (they call the Stripe API; the local key is a placeholder), so the account-deletion Checkout race is backed by code reading only -- Live model output (no --live run; nothing here needed real model text) -- ElevenLabs voice quota and cost rows -- Agentic e2e explorations (not run; scripted repros covered the area) -- Exact Chrome localStorage quota on Safari/iOS (only Chromium tested; Safari's limit is similar or lower) -- Debate create dialog losing a long motion on accidental close (seen in code as local state, not reproduced) -- Staging: deliberately not used (local stack only, as assigned) - -### integrity-money-1: Saves silently become local-only when supabase.auth.getUser() fails (offline, flaky wifi, auth hiccup) and vanish on reload, while the UI says 'Source Added' - -**Severity:** high · **Verdict:** confirmed · **Root cause:** `getuser-network-fallback-local-only-writes` - -**Where** - -- src/lib/stores/session-store.ts:288-291 (getUserId calls supabase.auth.getUser(), a network round trip) -- src/lib/stores/session-store.ts:505-512 (addSource: no user -> local-only item, returns success) -- src/lib/stores/session-store.ts:561, 592, 657, 693, 734, 796, 821, 858, 890, 922, 960, 994 (createSourceFolder, folder rename/delete, clearLibrary, saveCurrentCase/Attack/Brainstorm/Factoids/Chat, saveFlowchart: same 'if (!userId)' local-only branch) -- src/lib/stores/session-store.ts:1043, 1306 (updateRecordedResult, syncAskChatNow: silently return) -- route /app/library -> Add Source -> Save to Library - -**What the reviewer saw** - -Offline: signed in, debate started, filled Add Source, context.setOffline(true), clicked Save to Library. The source appeared in the list, the panel closed, no error toast. Back online for 3 s: sources table had [] for the user. After reload the source was gone (b5-offline.json). Auth hiccup only: routed /auth/v1/user to 503 while REST stayed up. The 'Source Added: Your source is now available for grounding.' toast appeared, the list showed 2 sources, the DB had only the first, and after reload it was gone (b6-getuser-fallback.json, b6-auth-hiccup-source-looks-saved.png, b6-after-reload-source-gone.png). A normal add made 3 /auth/v1/user network requests (b6 JSON 'authUserRequests':3). - -**Why it happens** - -Leftover signed-out mode: every write asks the Auth server for the user (getUser) and treats any failure as 'not signed in', then writes only to the persisted Zustand store. /app is auth-gated, so on the live app these branches only run when the network or auth server fails, and they turn that failure into fake success. - -**Suggested fix** - -In session-store.ts, get the user id from the local session (supabase.auth.getSession() or the id AuthProvider already passes to setActiveAccessToken) instead of getUser(), and throw when there is none. Delete the local-only branches so a failed insert surfaces through the existing error toasts. This also removes 1–3 auth round trips per write. - -**Who it hits** - -Students on school or mobile networks lose sources, saved results, flowcharts and folder changes with no warning. Whenever the auth call fails, every Library and Save action becomes a silent loss. - -**Independent check** - -Re-read session-store.ts:288-291 (getUserId = supabase.auth.getUser(), a network call) and the local-only branches; re-ran on the local stack. Auth hiccup (/auth/v1/user -> 503, REST up): 'Source Added: Your source is now available for grounding' toast, source in the list, no DB row, gone after reload. Fully offline: same false 'Source Added' toast (the 'Offline mode' pill shows too), sources table empty after going back online, gone after reload. One normal add made 3 /auth/v1/user requests. Correction to scope: recordResult (session-store.ts:1026) throws when there is no user, so AI tool results filed through recordResult are not silently lost. The silent paths are addSource, createSourceFolder, rename/deleteSourceFolder, updateSource/toggle, saveCurrentCase (GeneratePage), saveCurrentChat (AskPage), saveFlowchart, updateRecordedResult. saveCurrentAttack/Brainstorm/Factoids have no callers outside the store (dead code, worth deleting in the same fix). The suggested fix holds: take the user id from the local session and throw when it is missing. - -**Evidence files** - -[b5-offline.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b5-offline.json), [b5-offline-save-source.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b5-offline-save-source.png), [b6-getuser-fallback.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b6-getuser-fallback.json), [b6-auth-hiccup-source-looks-saved.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b6-auth-hiccup-source-looks-saved.png), [b6-after-reload-source-gone.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b6-after-reload-source-gone.png), [b6-getuser.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/scripts/b6-getuser.mjs), [f1-getuser.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f1-getuser.json), [f1-auth-hiccup-source-looks-saved.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f1-auth-hiccup-source-looks-saved.png), [f1-after-reload-source-gone.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f1-after-reload-source-gone.png), [f1-offline.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f1-offline.json), [f1-offline-save-source.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f1-offline-save-source.png) - - -### integrity-money-2: Sign-out leaves the previous student's unsent drafts in localStorage, and the next student sees them on shared computers - -**Severity:** high · **Verdict:** confirmed · **Root cause:** `signout-keeps-form-drafts` - -**Where** - -- src/lib/stores/session-store.ts:1221-1245 (resetLocalData does not clear generateForm/attackForm/brainstormForm/factoidsForm/pendingFlowchartData) -- src/lib/stores/session-store.ts:1247-1262 (clearSession clears them, but sign-out never calls it) -- src/lib/supabase/AuthProvider.tsx:249-258 and 365-371 (SIGNED_OUT handler and signOut call resetSessionStore only) -- localStorage key debate-trainer-storage - -**What the reviewer saw** - -User A typed into Attack 'Your Argument', Factoids 'Statement' and Brainstorm 'Motion' without submitting, then signed out in Settings. localStorage still held all three drafts. User B signed in on the same browser: /app/attack showed A's text 'ZZ-PRIVATE-A: my coach said my rebuttal on immigration is weak because of my family situation', and Factoids and Brainstorm were prefilled with A's text (b1-signout-leak.json, b1-userB-attack-shows-userA-draft.png). - -**Why it happens** - -Per-user draft state lives in the persisted session store, and the sign-out reset path resets results and lists but forgets the form drafts. Only the environment-switch path (discardLocalState) calls clearSession. - -**Suggested fix** - -Make resetLocalData also clear the four forms and pendingFlowchartData (or call clearSession from the SIGNED_OUT and user-change branches in AuthProvider). Better: key the persisted store by user id, as the Crossfire draft already does (crossfireStorageKey(ownerId)). - -**Who it hits** - -Most users are school students, often on shared lab or library computers. A student's private case text and arguments show up for the next student who signs in on that browser. - -**Independent check** - -Code: resetLocalData (session-store.ts:1221-1245) leaves generateForm/attackForm/brainstormForm/factoidsForm/pendingFlowchartData alone. clearSession clears them, but only discardLocalState (environment switch), Settings clearLocalData (delete paths) and the debate dialogs call it. AuthProvider SIGNED_OUT (249-258) and signOut (365-371) call resetSessionStore only. Reproduced locally: demo user A typed into Attack, Factoids and Brainstorm without submitting, then clicked Settings > Sign out. Auth keys were removed but the drafts stayed in debate-trainer-storage. Demo user B signed in through the Login page and got A's text prefilled in all three forms. High holds for a privacy leak between students on shared browsers. It is limited to unsent drafts, since saved results and lists are reset. - -**Evidence files** - -[b1-signout-leak.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b1-signout-leak.json), [b1-userB-attack-shows-userA-draft.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b1-userB-attack-shows-userA-draft.png), [b1-userB-brainstorm.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b1-userB-brainstorm.png), [b1-signout.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/scripts/b1-signout.mjs), [f2-signout.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f2-signout.json), [f2-userB-attack-prefilled-with-userA-draft.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f2-userB-attack-prefilled-with-userA-draft.png), [f2-userB-brainstorm-prefilled.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f2-userB-brainstorm-prefilled.png) - - -### integrity-money-3: The session store writes every debate's full sources and results to localStorage on every change; past ~5M chars every write throws and tool pages crash for good - -**Severity:** high · **Verdict:** confirmed · **Root cause:** `session-store-persists-server-lists` - -**Where** - -- src/lib/stores/session-store.ts:1264-1294 (persist to localStorage; partialize keeps savedCases/Attacks/Brainstorms/Factoids/Chats/Flowcharts/Crossfires, autosaved*, sourceLibrary with full content) -- node_modules/zustand/esm/middleware.mjs:358-372 (persist calls storage.setItem inside set() with no try/catch) -- src/lib/source-file-import.ts:4 (file imports up to 120,000 chars); library-sources.md notes pasted sources up to ~250 KB -- src/components/features/attack/AttackForm.tsx:59, generate/GenerateForm.tsx:82 (form watch writes the store on every keystroke) -- route /app/brainstorm crashes with 'Unable to load this page' - -**What the reviewer saw** - -Seeded one demo user's debate with sources through admin. 45 sources × 120,000 chars (the file-import cap): the persisted string is 5,410,309 chars. Every keystroke in Attack threw a page error "Failed to execute 'setItem' on 'Storage': Setting the value of 'debate-trainer-storage' exceeded the quota". Opening /app/brainstorm then rendered 'Unable to load this page' (console: error in <BrainstormForm>, 'Route rendering failed'). Reloading does not help, because hydrate sets the same state again (b3-quota-pdf45.json, b3-pdf45-error.png; the same at 130 × 45k, b3-big-error.png). Below the limit, at 60 × 45k (2.7M chars), each keystroke re-serialized and rewrote 2,713,534 chars: setItem alone had a median of 10.8 ms per keystroke and caused 4 long tasks (226 ms) while typing 36 chars (single run, load average ~1–4) (b3-quota-mid.json). - -**Why it happens** - -Server-owned lists, re-fetched in full on every load anyway (hydrateFromServer selects * from all tables across all debates), are also cached in synchronous localStorage, and the whole blob is rewritten on every set(), including each form keystroke. Zustand's persist lets QuotaExceededError escape from set(). - -**Suggested fix** - -Remove the server lists (saved*, autosaved*, sourceLibrary, sourceFolders) from partialize in session-store.ts and keep only drafts and the open result. Wrap the storage adapter in try/catch (createJSONStorage over a safe wrapper) so storage can never break set(). If an offline cache is wanted, move it to IndexedDB, scoped to the current debate and without full source content. - -**Who it hits** - -Heavy users, the most engaged students, break the app with no way back: about 42 max-size PDF imports, or ~20 large pasted sources, across all their debates. Every user also pays a full-store serialize on each keystroke, which grows with the library. - -**Independent check** - -Code: partialize (session-store.ts:1281-1293) keeps every saved*/autosaved* list and sourceLibrary with full content. zustand 5.0.15 persist (middleware.mjs:358-372) calls storage.setItem after set() with no try/catch, so the state applies and then the call throws. Reproduced locally with 45 sources x 120,000 chars seeded for one demo user: typing 17 chars in Attack threw 17 page errors ('Setting the value of debate-trainer-storage exceeded the quota'). /app/brainstorm showed 'Unable to load this page' (console: error in <BrainstormForm>, 'Route rendering failed'), and it was still broken after a reload. Below the limit (60 x 45,000), every keystroke rewrote about 2,713,700 chars: 22 writes for 22 keystrokes in each of 3 runs, setItem median 12.1/11.1/11.1 ms with JSON.stringify not counted, load average about 3. Severity is borderline. Only heavy libraries (about 5M chars across all debates) hit the crash, but nothing inside the app recovers from it, and every user pays the per-keystroke serialization. Note that the storage-sync half of integrity-money-13 is a separate cause. - -**Evidence files** - -[b3-quota-pdf45.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b3-quota-pdf45.json), [b3-pdf45-error.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b3-pdf45-error.png), [b3-quota-big.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b3-quota-big.json), [b3-big-error.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b3-big-error.png), [b3-quota-mid.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b3-quota-mid.json), [b3-quota.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/scripts/b3-quota.mjs), [f3-quota-pdf45.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f3-quota-pdf45.json), [f3-pdf45-brainstorm-after-reload.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f3-pdf45-brainstorm-after-reload.png), [f3-pdf45-brainstorm.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f3-pdf45-brainstorm.png), [f3-quota-mid.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f3-quota-mid.json) - - -### integrity-money-5: Paying subscribers are invited to buy Pro again and create-checkout lets them: double-subscription risk - -**Severity:** high · **Verdict:** confirmed · **Root cause:** `paywall-unaware-of-active-subscription` - -**Where** - -- supabase/functions/create-checkout/index.ts:250-326 (no check for an existing active/trialing/past_due subscription before stripe.checkout.sessions.create) -- src/components/features/subscription/LimitReachedModal.tsx:41-47 ('Upgrade to Pro - $5/mo' regardless of tier) -- src/components/features/subscription/PricingModal.tsx:40-44 (Upgrade Now → redirectToCheckout with no isPro check; shows 'Free — Current') -- src/lib/stripe.ts:14 (successUrl ?checkout=success, which no code reads); src/pages/SettingsPage.tsx (no checkout param handling or polling) -- src/hooks/use-subscription.ts:27,132-140 (60 s staleTime, no refetch after checkout) - -**What the reviewer saw** - -An active Stripe Pro user (subscriptions row active, Settings showed 'Pro' + 'Manage Subscription') at 50/50 ran Brainstorm. The modal read 'You've used 50 of 50… Upgrade to Pro - $5/mo'. Clicking it opened PricingModal with 'Free — Current' and 'Upgrade Now', and clicking that fired POST /functions/v1/create-checkout (b13-pro-limit-upsell.json, b13-*.png). Calling create-checkout directly for an active subscriber with an allowed origin went straight on to Stripe (it failed only on the placeholder key: 'Invalid API Key provided') with no 'already subscribed' refusal (checkout-probe.json). Separately, opening /app/settings?checkout=success&session_id=… before the webhook showed 'Expired' and 'Upgrade to Pro' with no acknowledgement, and it still showed that 10 s after the webhook set tier pro (b9-checkout-return.json, b9-10s-after-webhook-no-reload.png). - -**Why it happens** - -Neither the server nor the paywall UI knows whether the caller already pays. The client never closes the gap between redirect and webhook, so right after paying the student is told their access has expired. - -**Suggested fix** - -create-checkout: before creating a session, query subscriptions (and Stripe by customer) for status in active/trialing/past_due and return 409 with a portal link. LimitReachedModal/PricingModal: read useSubscription(); for Pro show 'Limit resets at midnight UTC' and no upgrade button. SettingsPage: on ?checkout=success show 'Payment received, activating Pro…', poll subscription.refresh() every 2 s for up to 30 s, then strip the params. - -**Who it hits** - -Any Pro subscriber who hits the 50/day limit, and anyone who returns from Checkout before the webhook, is pushed toward a second $5/mo subscription. Duplicate subscriptions then also trigger the downgrade bug in integrity-money-7 when one is cancelled. - -**Independent check** - -Reproduced locally. The Stripe Pro subscriber (subscriptions row active; Settings shows Pro and Manage Subscription) had a 1/day limit. The second Brainstorm opened 'Daily Access Paused ... Upgrade to Pro - $5/mo'. Clicking it opened PricingModal with 'Free / Current' and 'Upgrade Now', and that fired POST create-checkout. Calling create-checkout directly for an active subscriber from an allowed origin went straight to the Stripe calls and failed only on the placeholder key. There is no 'already subscribed' refusal. Return path: /app/settings?checkout=success&session_id=... showed Expired and 'Upgrade to Pro' with no acknowledgement, and still did 10 s after the webhook set tier pro (useSubscription staleTime 60 s; nothing in src reads the checkout param). These are two sub-issues that could ship as separate PRs: (a) paywall and checkout ignore an existing subscription; (b) nothing acknowledges the payment or refetches after Checkout. (b) hits every buyer whose webhook lags the redirect. - -**Evidence files** - -[b13-pro-subscriber-limit-modal-upsell.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b13-pro-subscriber-limit-modal-upsell.png), [b13-pricing-modal-for-pro-user.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b13-pricing-modal-for-pro-user.png), [b13-pro-limit-upsell.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b13-pro-limit-upsell.json), [checkout-probe.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/checkout-probe.json), [b9-return-from-checkout-before-webhook.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b9-return-from-checkout-before-webhook.png), [b9-10s-after-webhook-no-reload.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b9-10s-after-webhook-no-reload.png), [b9-checkout-return.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b9-checkout-return.json), [f5-prolimit.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f5-prolimit.json), [f5-pro-subscriber-limit-modal-upsell.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f5-pro-subscriber-limit-modal-upsell.png), [f5-pricing-modal-for-pro-subscriber.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f5-pricing-modal-for-pro-subscriber.png), [checkout-active-subscriber.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/checkout-active-subscriber.json), [f5-checkout-return.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f5-checkout-return.json), [f5-return-from-checkout-before-webhook.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f5-return-from-checkout-before-webhook.png), [f5-return-10s-after-webhook-still-expired.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f5-return-10s-after-webhook-still-expired.png) - - -### integrity-money-6: Leaving Ask while it is answering leaves it stuck: 'Typing' forever, input disabled, question unanswered - -**Severity:** high · **Verdict:** confirmed · **Root cause:** `ask-request-tied-to-page-unmount` - -**Where** - -- src/pages/AskPage.tsx:53-57 (abortActiveRequest bumps the request id and aborts, but does not reset askLoading) -- src/pages/AskPage.tsx:65-67 (unmount cleanup calls abortActiveRequest) -- src/pages/AskPage.tsx:285-288 (finally only clears askLoading when isCurrentRequest(), which is now false) -- src/lib/stores/session-store.ts askLoading/askStreamingAssistant (global, not persisted) - -**What the reviewer saw** - -/app/ask: sent a question (ai-stream delayed 4 s), clicked the Library link 0.8 s later, waited 6 s, went back. The chat showed the question with a 'Typing|' bubble and the input was disabled (a Playwright fill on 'Ask your debate coach...' timed out: element is disabled), and it stayed that way. askMessages ended with the unanswered user message (b11-ask.json, b11-ask-back-after-navigating-away.png, b11-ask-input-disabled-after-return.png). In citation/non-stream mode the request is not aborted at all: it completes and is paid for, then dropped, because isCurrentRequest() is false. - -**Why it happens** - -Ask request lifetime is tied to the page component, but its loading flags live in the global store. The cleanup cancels the request without clearing the global flags, and the request's own finally then declines to clear them. - -**Suggested fix** - -Either keep the Ask request alive in the store (like the other tools do, so the answer lands when the user comes back), or have the unmount cleanup call setAskStreamingAssistant(null) and setAskLoading(false), and mark the dangling question as interrupted with a Retry action. - -**Who it hits** - -Students often check the Library or another tool while waiting for an answer. After that, Ask cannot be used until a reload or Clear, and the question gets no answer. - -**Independent check** - -Code matches: the unmount cleanup (AskPage.tsx:65-67) calls abortActiveRequest, which bumps the id but never resets the global askLoading/askStreamingAssistant, and finally (285-288) skips them because isCurrentRequest() is false. Reproduced locally (ai-stream held 4 s): sent, clicked Library at 0.8 s, went back after 6 s. The 'Typing' bubble stayed and the input was disabled 1.5 s and again 9.5 s later. askMessages ended with only the user message. The citation (non-stream) sub-claim also reproduced: the 'ask' provider call completed (quota +1, ai_requests success), the answer was dropped, and the chat stayed on 'Thinking...' with the input disabled. Clear or a full reload recovers, since askLoading is not persisted. Side note outside this finding: the Citations toggle was off again after coming back. - -**Evidence files** - -[b11-ask-back-after-navigating-away.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b11-ask-back-after-navigating-away.png), [b11-ask-input-disabled-after-return.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b11-ask-input-disabled-after-return.png), [b11-ask.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b11-ask.json), [b11-ask.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/scripts/b11-ask.mjs), [f6-f11-ask.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f6-f11-ask.json), [f6-ask-back-after-navigating-away.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f6-ask-back-after-navigating-away.png), [f6-ask-still-stuck-8s-later.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f6-ask-still-stuck-8s-later.png), [f8-reload-and-f6-citation.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f8-reload-and-f6-citation.json), [f6-citation-mode-after-return.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f6-citation-mode-after-return.png) - - -### integrity-money-4: Stripe webhook idempotency is broken: replayed or out-of-order events are applied again and can give a cancelled user Pro - -**Severity:** medium (reviewer said high) · **Verdict:** confirmed · **Root cause:** `webhook-dedupe-after-side-effects` - -**Where** - -- supabase/functions/stripe-webhook/index.ts:65-104 (handlers run first) -- supabase/functions/stripe-webhook/index.ts:106-129 (webhook_events insert and duplicate check run only after processing) -- supabase/functions/stripe-webhook/index.ts:300-334 (subscription.updated applies event state without comparing event.created or re-fetching) -- e2e/full-stack/billing.spec.ts:122-123 (the replay test only checks the response says duplicate:true) - -**What the reviewer saw** - -Signed with the local test secret from billing.spec.ts. Event E1 customer.subscription.updated(active) → tier pro, row active. E2 customer.subscription.deleted → tier expired, row canceled. Replaying E1 with the same event id returned {received:true,status:200,duplicate:true}, yet the user was back to tier pro with the subscription row 'active' and current_period_end 30 days out, which getSubscriptionInfo treats as Pro. With distinct ids, an older updated(active) delivered after deleted also reactivated the user (webhook-scenarios.json). The maintained billing spec passes because it only checks the duplicate flag. - -**Why it happens** - -The dedupe record is written after the side effects (to allow retries on failure), so a duplicate is never skipped, only labelled. Handlers trust the event payload's state instead of current Stripe state or event order. - -**Suggested fix** - -In stripe-webhook/index.ts, claim the event first (insert into webhook_events with a status column, then skip when it is already processed) and mark it processed after the handlers succeed. For subscription events, re-fetch stripe.subscriptions.retrieve(id) and write that state, or ignore events older than the row's last applied event.created (add a column). Make billing.spec.ts assert that the tier is still 'expired' after replaying E1 following the delete. - -**Who it hits** - -Stripe documents both duplicate deliveries and no ordering guarantee. Any slow response or retry around a cancellation can leave a cancelled customer with free Pro until another event happens to arrive. - -**Independent check** - -Code: stripe-webhook/index.ts:65-104 runs the handlers, and the webhook_events insert (106-129) only labels duplicates afterwards. handleSubscriptionUpdated (300-334) writes the payload state and sets tier pro with no event.created or re-fetch check. Reproduced locally with signed events: E1 updated(active) gave pro, E2 deleted gave expired/canceled. Replaying E1 returned {duplicate:true}, yet the tier went back to pro, the row to active, and a Claude Sonnet 5 call then returned 200. An older updated(active) with a new id, delivered after deleted, also re-activated the user. billing.spec.ts says 'a replayed event must be acknowledged, not reapplied' but only asserts the flag. Downgraded from high: it needs a retry or late delivery that lands after a cancellation (rare), and the harm is free Pro until the next event, not lost money from paying users. - -**Evidence files** - -[webhook-scenarios.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/webhook-scenarios.json), [webhook.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/scripts/webhook.mjs), [20261008T161120Z-billing](/home/codex/Projects/DebateLab/.verify/artifacts/20261008T161120Z-billing), [backend-scenarios.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/backend-scenarios.json) - - -### integrity-money-7: Promo codes and Stripe events overwrite one shared entitlement column: paying users lose Pro, limits drop, lifetime promos get erased - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `single-mutable-entitlement-tier` - -**Where** - -- supabase/migrations/20260819000000_security_hardening.sql:117-140 (extended_trial sets subscription_tier='trial'; extra_limit sets daily_limit_override, even below the plan default) -- supabase/functions/stripe-webhook/index.ts:183-188 and 320-325 (checkout/subscription.updated set grace_period_ends_at=null, erasing free_month/lifetime promos) -- supabase/functions/stripe-webhook/index.ts:241-246, 278-283 (payment_failed/deleted set tier 'expired' even with a lifetime promo or another active subscription) -- supabase/functions/_shared/entitlements.ts:59-120 (tier decides the branch, so 'trial'/'expired' ignore active subscriptions and a 9999 grace date) - -**What the reviewer saw** - -promo-scenarios.json: a paying Stripe Pro user called Claude Sonnet 5 → 200; redeemed an extended_trial code → tier 'trial'; the same call → 403 'This model requires a Pro subscription'. A Stripe Pro user (50/day) redeemed extra_limit (15) → override 15, and request 16 was refused 'You've used all 15'. A lifetime-promo user (grace 9999, override 50) redeemed extended_trial → tier trial, Pro models 403. webhook-lifetime-promo.json: lifetime user + subscription.updated(active) → grace_period_ends_at null; then deleted → tier 'expired'. webhook-scenarios.json S4: two active subscriptions, deleting one → tier 'expired' although the other is active. - -**Why it happens** - -Entitlement is one mutable tier plus grace and override columns that each billing source writes as if it were the only one. Nothing computes access as the best of the active grants (Stripe subscription, promo window, trial, admin override). - -**Suggested fix** - -Compute entitlements as the max over grants in getSubscriptionInfo (and its two client copies in use-subscription.ts and settings-store.ts): any active/trialing subscription → Pro; grace in the future → Pro; trial in the future → trial. Webhooks should then update only the subscriptions table, not subscription_tier or grace_period_ends_at. In redeem_promo_code_atomic, never lower tier or limits: use GREATEST for daily_limit_override, and leave tier unchanged for extended_trial when the user already has Pro. - -**Who it hits** - -Schools hand codes to whole classes, so paying students who redeem a trial or extra-limit code lose Pro features or get a lower limit. Lifetime-promo holders who ever subscribe lose lifetime access when they cancel. In each case the money paid no longer matches the access given. - -**Independent check** - -Reproduced locally through the real redeem-promo function and signed webhooks. A paying Stripe Pro user's Sonnet call returned 200. After redeeming extended_trial: tier 'trial', and the same call got 403 'requires a Pro subscription' while the subscription stayed active. A Stripe Pro user who redeemed extra_limit {limit:3} got override 3, and request 4 was refused 'used all 3' (was 50). For a lifetime-promo user (grace 9999, override 50), subscription.updated(active) set grace_period_ends_at null and a later deleted set tier 'expired' (lifetime Pro gone; the leftover override 50 still allows 50/day without Pro models). With two active subscriptions, deleting one gave tier 'expired' while the other stayed active. Code: security_hardening.sql:117-140 and stripe-webhook/index.ts:183-188, 241-246, 278-283, 320-325, entitlements.ts:59-120. Medium holds and borders on high, because it needs a second billing event on a paying account. This is a separate fix from integrity-money-4 (dedupe-first) and -17. - -**Evidence files** - -[promo-scenarios.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/promo-scenarios.json), [webhook-lifetime-promo.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/webhook-lifetime-promo.json), [webhook-scenarios.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/webhook-scenarios.json), [promo.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/scripts/promo.mjs), [webhook2.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/scripts/webhook2.mjs), [backend-scenarios.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/backend-scenarios.json) - - -### integrity-money-8: AI quota is charged at request start and never refunded or deduplicated, so failures and retries cost students extra requests - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `quota-reserve-without-release` - -**Where** - -- supabase/functions/_shared/entitlements.ts:176 (consume_daily_quota before any provider work) -- supabase/functions/ai/index.ts:757-795 (error path logs usage but does not release the slot) -- src/pages/AskPage.tsx:215-228 (a stream failure with no text silently retries via askChat, a second paid request; this applies to 500s, 401s and 402s alike) -- src/lib/stores/sparring-store.ts + SparringRound (a restored round re-requests the opponent turn; respond has no claim, unlike score) - -**What the reviewer saw** - -quota-scenarios.json q2: forced a provider 500 on brainstorm → client got 500, daily_usage_counters went to 1; with limit 2 the next request succeeded and the third got 402. b11-ask.json: one Ask question with ask-stream forced to 500 → quotaUsed 2, providerCalls ['ask-stream','ask'], ai_requests ['ask:error:stream_error','ask:success']. b16-sparring-reload.json: reloading while the opponent speech was pending → quotaUsed 2 and 2 sparring provider calls for one speech. b4-submit.json: reloading mid-brainstorm → quota 3→4 and an ai_requests success row, but no brainstorms row (the paid result was discarded). - -**Why it happens** - -The atomic reservation (correct against over-spend) has no matching release, and client retries have no idempotency key, so every retry or reload is a new paid request. - -**Suggested fix** - -Add a release_daily_quota RPC (service_role only) and call it when the request fails before the provider returns usage, or when the provider errors with no tokens. Limit the Ask fallback to transport failures (network/TypeError, or a stream endpoint missing): never after an HTTP response from ai-stream. Give sparring respond an idempotency key per (session, turn) like the score claim. - -**Who it hits** - -Trial users have 5 requests a day. One provider hiccup or one reload during a sparring turn costs 2 of them, and the same goes for Pro users at a smaller scale. - -**Independent check** - -Reproduced locally. With a forced provider 500 on brainstorm, the client got 500, daily_usage_counters went to 1, and at limit 2 the second call succeeded and the third got 402. One Ask with ask-stream forced to 500: provider calls ['ask-stream','ask'], quota 2, ai_requests ask:error then ask:success. Reload mid-Brainstorm (response held 10 s, reload at 2.5 s): quota 0 to 1, an ai_requests success row, no brainstorms row, no result on screen (the paid result was discarded). Not re-run: the sparring reload sub-claim. The code agrees, since only the score action has a claim (ai/index.ts:464-500) and respond has none. - -**Evidence files** - -[quota-scenarios.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/quota-scenarios.json), [b11-ask.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b11-ask.json), [b16-sparring-reload.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b16-sparring-reload.json), [b16-sparring-12s-after-reload.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b16-sparring-12s-after-reload.png), [b4-submit.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b4-submit.json), [quota.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/scripts/quota.mjs), [backend-scenarios.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/backend-scenarios.json), [f8-f12.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f8-f12.json), [f8-ask-after-stream-failure.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f8-ask-after-stream-failure.png), [f8-reload-and-f6-citation.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f8-reload-and-f6-citation.json), [f8-after-reload-mid-brainstorm.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f8-after-reload-mid-brainstorm.png) - - -### integrity-money-9: Promo-code brute force is still open, and different error messages reveal expired or used-up codes - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `promo-redeem-no-attempt-limit` · **Known in plan:** next-15 #2 - -**Where** - -- supabase/migrations/20260819000000_security_hardening.sql:117-142 (redeem_promo_code_atomic: no attempt counter; granted to authenticated) -- supabase/functions/redeem-promo/index.ts:44-53 (no limit; logs every attempted code as 'redeemed') - -**What the reviewer saw** - -promo-scenarios.json p4: one user made 40 wrong guesses through the redeem-promo function in 3,093 ms (all 'Invalid or expired promo code'), then a valid free_month code still worked. 20 more direct PostgREST rpc('redeem_promo_code_atomic') calls, bypassing the function, were also unlimited. An expired code returns 'This promo code has expired' and an exhausted one 'This promo code has reached its usage limit', both distinct from an unknown code. - -**Why it happens** - -next-15 #2 was not implemented: the limit has to live in SQL because the RPC is callable directly. - -**Suggested fix** - -As planned: a per-user failed-attempt window inside redeem_promo_code_atomic modelled on demo_access_attempts, one generic message for unknown, expired, exhausted and locked-out, plus a pgTAP test. Also stop logging raw codes on failure in redeem-promo. - -**Who it hits** - -Any free account (including demo-code users) can script guesses against human-chosen codes. lifetime_pro codes grant permanent Pro. - -**Independent check** - -Known in plan: next-15 #2, still open. redeem_promo_code_atomic is only defined in 20260819000000_security_hardening.sql (20260820090000 only re-grants it) and has no attempt counter. Locally, 30 wrong codes through redeem-promo took 2,311 ms, all 200 'Invalid or expired promo code', and a valid code still worked after them. 10 direct PostgREST rpc calls all answered. Expired, exhausted and unknown codes return different messages. Also, redeem-promo/index.ts:49 logs 'User X redeemed code CODE' for every attempt, including failures, with the raw code. - -**Evidence files** - -[promo-scenarios.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/promo-scenarios.json), [promo.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/scripts/promo.mjs), [backend-scenarios.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/backend-scenarios.json) - - -### integrity-money-10: A result that arrives after a debate switch shows under the new debate, breaking the 'debates never mix' rule - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `tool-results-not-scoped-to-debate` - -**Where** - -- src/components/features/debate/DebateDialogs.tsx:20-36 (switch/create call clearSession() but do not cancel or scope in-flight requests) -- src/pages/BrainstormPage.tsx:37-47 (setBrainstormResult runs whatever the debate is now); same pattern in Generate/Attack/Factoids pages -- src/lib/stores/session-store.ts brainstormLoading/brainstormResult (one global slot per tool) - -**What the reviewer saw** - -Debate A current, Brainstorm started (ai response delayed 7 s); switched to debate B through the header while pending. The page kept 'Generating Ideas…', then rendered A's ideas next to B's motion field and B's 'Working in' strip with a 'Brainstorm complete' toast. The DB row was correctly filed in A (b2-switch.json, b2-result-after-switch.png). Switching after a result clears it correctly (b4 step 5), so only in-flight requests leak. - -**Why it happens** - -Result and loading state are per-tool globals with no owning debate id. clearSession wipes them at switch time, but the pending promise writes into the slot afterwards. - -**Suggested fix** - -Store the debate id with each tool result (for example brainstormResult: {debateId, output}). Render it only when it matches the current scope and show 'Result saved to <debate A>' otherwise. Reset the *Loading flags in clearSession. - -**Who it hits** - -Students see one motion's arguments presented as another's. Follow-up buttons (Discuss, Open in Crossfire) then carry A's content into B. - -**Independent check** - -Reproduced locally. Debates A and B existed with A current. Brainstorm started (ai response held 7 s), then the header switch moved to B. The page kept 'Generating', then rendered the ideas next to B's motion field with a 'Brainstorm complete' toast. The brainstorms row was filed under A, which is correct. Code: DebateDialogs.tsx:20-36 calls clearSession at switch time, and BrainstormPage.tsx:37-47 calls setBrainstormResult with no scope check afterwards. - -**Evidence files** - -[b2-switch.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b2-switch.json), [b2-switch-midrequest.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b2-switch-midrequest.png), [b2-result-after-switch.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b2-result-after-switch.png), [b2-switch.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/scripts/b2-switch.mjs), [f10-switch.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f10-switch.json), [f10-switch-midrequest.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f10-switch-midrequest.png), [f10-result-after-switch.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f10-result-after-switch.png) - - -### integrity-money-11: Ask throws away the student's text: 'Message is too large — shorten it' appears with an empty box, and refused questions are cleared - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `ask-optimistic-input-clear` - -**Where** - -- src/components/features/ask/AskChat.tsx:402-408 (setInput('') right after calling onSend, before validation or the result) -- src/pages/AskPage.tsx:145-151 (size check toasts 'shorten your question' after the input is gone) -- src/pages/AskPage.tsx:153-154 (the question is added to the chat before the request; on a 402 it stays as an unanswered bubble) - -**What the reviewer saw** - -Pasted 54,400 chars into Ask and clicked Send: toast 'Message is too large — Remove some attachment text or shorten your question before sending', input length 0, no provider call (b12-asksize.json, b12-ask-too-large-input-cleared.png). At the daily limit, the typed question was cleared from the box and left in the chat as a sent message with no answer, behind the limit modal (b12-ask-limit-reached.png). - -**Why it happens** - -An optimistic clear happens in the child, and validation and refusal happen later in the parent, with no path to put the text back. - -**Suggested fix** - -Make onSend return a promise or boolean, and clear the input only once the message is accepted (validated and the request started). On a size error or a 402/403 refusal, restore the text and remove the optimistic user bubble. Optionally persist the Ask draft like the other tool forms. - -**Who it hits** - -Students pasting evidence into Ask lose the whole paste. At the limit they lose the question they typed. - -**Independent check** - -Code: AskChat.tsx:402-408 calls setInput('') right after onSend. AskPage.tsx:145-151 validates later and 153-154 adds the user bubble before the request. Reproduced locally: pasted 54,900 chars and clicked Send. The toast said 'Message is too large', the input length was 0, and no bubble or provider call happened. At the daily limit, the typed question was cleared from the box, left as a sent bubble with no answer, and the 'Daily Access Paused' modal opened. askMessages = ['user']. - -**Evidence files** - -[b12-ask-too-large-input-cleared.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b12-ask-too-large-input-cleared.png), [b12-ask-limit-reached.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b12-ask-limit-reached.png), [b12-asksize.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b12-asksize.json), [b12-asksize.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/scripts/b12-asksize.mjs), [f6-f11-ask.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f6-f11-ask.json), [f11-ask-too-large-input-cleared.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f11-ask-too-large-input-cleared.png), [f11-ask-at-limit-question-left-unanswered.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f11-ask-at-limit-question-left-unanswered.png), [f11-ask-at-limit-after-closing-modal.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f11-ask-at-limit-after-closing-modal.png), [f5-prolimit.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f5-prolimit.json) - - -### integrity-money-12: The trial allowance cannot finish one Crossfire round, and the unfinished round is thrown away - -**Severity:** medium · **Verdict:** partly · **Root cause:** `per-request-quota-for-multi-turn-practice` - -**Where** - -- supabase/functions/_shared/entitlements.ts:104-111 (trial = 5/day; each Crossfire answer and the debrief consume 1) -- src/pages/CrossfirePage.tsx:405-419 (the round is recorded only after a debrief completes) - -**What the reviewer saw** - -A trial user (5/day) started Crossfire (question-the-AI) and asked questions. 5 answers used 5 slots (quota 5, 5 crossfire provider calls). The 6th question ended the round with 'Could not build the debrief — You've used all 5 of your daily requests… Retry debrief', and the round was not filed (b15-crossfire-limit.json, b15-crossfire-limit-midround.png). - -**Why it happens** - -Per-request quota pricing makes multi-call practice modes cost far more than single-shot tools, and the round only persists once a paid debrief succeeds. - -**Suggested fix** - -Count a Crossfire round (and a Sparring round) as one quota unit: reserve at start and give its turns a round-scoped token, like the signal token already does. Or warn up front ('this round uses about N of your 5'). Save the transcript when the round ends even if the debrief is refused, and let the debrief be generated later. - -**Who it hits** - -A trial user's first try at Crossfire, a headline feature, ends in an error and loses the practice they just did. - -**Independent check** - -Reproduced locally with a trial user (5/day) in question-the-AI mode. Five answers used all 5 slots (5 crossfire provider calls). The 6th question ended in 'Could not build the debrief: You've used all 5 of your daily requests' with 'Retry debrief', and no crossfires row was filed. Correction: the round is not thrown away. After a reload the transcript and 'Retry debrief' are still there (per-user crossfire draft), so it can be finished once quota is back. 'Cannot finish one round' is also overstated: rounds are 180 s and timed, so a student who asks 4 or fewer questions still gets a debrief. Real defects: the 5/day trial budget is gone after about 5 Crossfire questions, and a refused question is shown as a debrief failure. - -**Evidence files** - -[b15-crossfire-limit-midround.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b15-crossfire-limit-midround.png), [b15-crossfire-limit.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b15-crossfire-limit.json), [b15-crossfire-limit.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/scripts/b15-crossfire-limit.mjs), [f8-f12.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f8-f12.json), [f12-crossfire-trial-limit.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f12-crossfire-trial-limit.png), [f12-crossfire-after-end.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f12-crossfire-after-end.png), [f12-crossfire-after-reload.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f12-crossfire-after-reload.png) - - -### integrity-money-13: The initial data load overwrites edits made while it is in flight, and cross-tab sync replaces the whole session store while the Current Debate stays per-tab - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `hydrate-overwrites-concurrent-writes` - -**Where** - -- src/lib/stores/session-store.ts:1082-1192 (hydrateFromServer replaces every list with its snapshot; no write counter) -- src/lib/stores/debate-store.ts:111-140 (the debate store does have a debateWrites guard, so the two stores are inconsistent) -- src/hooks/use-session-storage-sync.ts:10-22 (useSessionStore.setState(payload.state) from any tab's write; the debate store is not synced) - -**What the reviewer saw** - -Hydrate race: delayed only the sources list response of the initial load by 5 s, added a source during it. The source was visible immediately (1), then vanished when the snapshot landed (0); the DB had the row (b14-hydrate-overwrite.json, b14-source-vanished-after-hydrate.png). A re-add creates a duplicate. Two tabs: tab 1 showed a Brainstorm result in debate A; tab 2 switched to B. Tab 1's result disappeared, synced through storage, while tab 1's header still read debate A (b8-tabs.json, b8-tab1-after-tab2-switch.png). - -**Why it happens** - -Server snapshots replace local lists without checking for writes that happened after the snapshot started. Cross-tab sync is all-or-nothing for one store and absent for the other. - -**Suggested fix** - -Copy debate-store's write-epoch guard into session-store hydrateFromServer: count writes, and re-run or merge by id when one landed mid-load. Narrow the storage sync to drafts (or drop it, once integrity-money-3 removes the lists from storage), and either sync the debate store too or have every tab re-hydrate debates on the storage event. - -**Who it hits** - -On slow school wifi, sources or results saved right after opening the app disappear until reload, which leads to duplicates. Two-tab users see their results vanish next to the wrong debate header. - -**Independent check** - -Hydrate race reproduced locally. The initial GET sources?select=* was held 6 s, and a source was added during it: visible at once (1), gone after the snapshot landed (0), while the DB had the row. Code: hydrateFromServer (session-store.ts:1082-1192) replaces lists with no write counter, while debate-store.ts:111-140 has the debateWrites guard. Two tabs: tab 1 showed a Brainstorm result in debate A, and tab 2 started debate B. Tab 1's result vanished through the storage event (use-session-storage-sync.ts setState of the whole payload) while tab 1 stayed in debate A ('Using 1 source and 1 saved result'). These are two sub-causes. The cross-tab half is not covered by integrity-money-3's fix, because brainstormResult and the forms stay persisted. Give it its own fix (narrow or remove the storage sync, and sync or re-hydrate the debate store). - -**Evidence files** - -[b14-hydrate-overwrite.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b14-hydrate-overwrite.json), [b14-source-vanished-after-hydrate.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b14-source-vanished-after-hydrate.png), [b8-tabs.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b8-tabs.json), [b8-tab1-after-tab2-switch.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b8-tab1-after-tab2-switch.png), [b14-hydrate.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/scripts/b14-hydrate.mjs), [f13-hydrate.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f13-hydrate.json), [f13-source-visible-before-hydrate.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f13-source-visible-before-hydrate.png), [f13-source-vanished-after-hydrate.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f13-source-vanished-after-hydrate.png), [f13-tab1-after-tab2-new-debate.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f13-tab1-after-tab2-new-debate.png) - - -### integrity-money-14: Auto Fast gives trial users GLM-5.3 Flash, a model the catalog marks Pro-only and refuses when picked directly - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `auto-router-ignores-pro-only` - -**Where** - -- supabase/functions/_shared/model-catalog.ts:27 (z-ai/glm-5.3-flash isProOnly: true) -- supabase/functions/_shared/auto-model-router.ts:20-25, 76 (Auto Fast candidates and fallback include glm-5.3-flash) -- src/lib/stores/settings-store.ts:353 (client migration moves glm-5.3-flash off manual selection) - -**What the reviewer saw** - -quota-scenarios.json q3, trial user: model z-ai/glm-5.3-flash → 403 'requires a Pro subscription'; model auto-fast → 200 with meta.resolvedModel 'z-ai/glm-5.3-flash' (provider calls: jev, then research-queries and brainstorm on glm-5.3-flash). - -**Why it happens** - -The router's candidate list ignores the catalog's isProOnly flag. - -**Suggested fix** - -Decide the policy. Either drop isProOnly from glm-5.3-flash (and update PricingModal copy, which lists 'GLM Flash' as a Pro feature), or filter FAST_CANDIDATES and the fallback by canAccessProModels in resolveModelSelection. - -**Who it hits** - -The plan boundary is inconsistent: Pricing says GLM Flash is Pro, yet trial users get it by default through Auto Fast. Cost impact is small. - -**Independent check** - -Locally, a fresh trial user picking z-ai/glm-5.3-flash directly got 403 'requires a Pro subscription'. auto-fast returned 200 with meta.resolvedModel z-ai/glm-5.3-flash (provider calls: jev, research-queries and brainstorm on glm-5.3-flash). Code: model-catalog.ts:27 isProOnly true; auto-model-router.ts:20-25 and 75-76 skip the check for auto candidates. The UI is inconsistent too: en.json autoFastDescription 'Fast routing, usually GLM' against pricing 'All 10 models, including ... GLM Flash' as a Pro feature. - -**Evidence files** - -[quota-scenarios.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/quota-scenarios.json), [backend-scenarios.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/backend-scenarios.json) - - -### integrity-money-15: Usage rows are uneven: every refused ai request inserts an ai_requests row (unbounded), ai-stream logs none, and admin stats count refusals as errors - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `refusals-logged-as-usage-rows` - -**Where** - -- supabase/functions/ai/index.ts:525 (logUsage 'rate_limited' on every refusal) -- supabase/functions/ai-stream/index.ts:177-185 (no log on refusal) -- supabase/migrations/20260819000000_security_hardening.sql:84-92 (get_daily_usage errors = status != 'success') - -**What the reviewer saw** - -quota-scenarios.json q4: 1 success, then 10 refused calls produced 10 'rate_limited' rows. q5: ai-stream refused (402) and logged nothing. In get_daily_usage, rate_limited and cancelled rows count as errors and as requests. - -**Why it happens** - -Refusals are logged into the usage-and-cost table instead of a counter. - -**Suggested fix** - -Stop inserting a row per refusal (or rate-limit that logging, for example one per user per day per code). Make ai and ai-stream behave the same. Exclude rate_limited from 'errors' in get_daily_usage. - -**Who it hits** - -A client that loops after hitting its limit grows ai_requests without bound and inflates admin error rates. - -**Independent check** - -Locally, 1 success and 6 refused ai calls left 6 'rate_limited:limit_reached' rows (ai/index.ts:525). An ai-stream refusal (402) logged nothing (ai-stream/index.ts:177-185). Location correction: get_daily_usage has no consumers in src or supabase/functions (dead RPC). The live admin stats path is supabase/functions/admin/index.ts: successRate at :293 counts refusals as failures, and the daily breakdown at :318 adds them to errors. That path also pages through every ai_requests row, so unbounded refusal rows slow the admin stats too. - -**Evidence files** - -[quota-scenarios.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/quota-scenarios.json), [backend-scenarios.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/backend-scenarios.json) - - -### integrity-money-16: useSubscription runs an exact count(*) on ai_requests on every fetch for dailyUsed/dailyLimit, values no component reads - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `unused-daily-usage-count-query` - -**Where** - -- src/hooks/use-subscription.ts:114-125 (count query), 144-158 (returned) -- grep: dailyUsed and dailyLimit from this hook have no consumers (SettingsPage and ModelSelector use tier/isPro/hasStripeBilling only) - -**What the reviewer saw** - -rg 'dailyUsed' src → only src/hooks/use-subscription.ts. The count would also be wrong if shown: it counts refusals, errors and jev_model_router/jev_source_ranking rows (one Auto request = 2 rows), not daily_usage_counters. - -**Why it happens** - -A leftover usage meter. - -**Suggested fix** - -Delete the count query and the dailyUsed/dailyLimit fields. If a meter is wanted later, read daily_usage_counters (quota_key 'ai') through an RPC. - -**Who it hits** - -One wasted database round trip per Settings visit or ModelSelector mount (60 s stale time). - -**Independent check** - -grep: dailyUsed/dailyLimit from useSubscription have no readers outside use-subscription.ts. SettingsPage uses the hook, and ModelSelector reads only isPro/loading. On staging (pro account, /app/settings) the page sent HEAD /rest/v1/ai_requests?select=*&user_id=eq.<id>&created_at=gte.<today> with Prefer count=exact. The cost is small (60 s staleTime), so low is fair. It could arguably be a nit. - -**Evidence files** - -[staging-settings.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/staging-settings.json) - - -### integrity-money-17: invoice.payment_failed cuts access on the first failed renewal attempt, and a failed downgrade write is still recorded as processed - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `payment-failed-immediate-downgrade` - -**Where** - -- supabase/functions/stripe-webhook/index.ts:219-252 (status past_due + tier 'expired'; userError only logged, not thrown) -- supabase/functions/_shared/entitlements.ts:84-101 (past_due is not active, so access ends regardless) - -**What the reviewer saw** - -webhook-scenarios.json S3: an active Pro user + invoice.payment_failed → tier 'expired', row past_due, right away. grace_period_ends_at exists but Stripe paths never set it. - -**Why it happens** - -A 'downgrade immediately per spec' choice, with no grace for Stripe's smart retries. - -**Suggested fix** - -Product call for Adam. If grace is wanted, treat past_due as Pro until current_period_end + N days in getSubscriptionInfo and stop writing tier from payment_failed. Either way, throw on userError so Stripe retries. - -**Who it hits** - -A card glitch locks a paying student out until Stripe's retry succeeds and a subscription.updated arrives. - -**Independent check** - -Locally, an active Pro user plus a signed invoice.payment_failed left tier 'expired' and the row 'past_due' at once, with a 200 response. Code: stripe-webhook/index.ts:219-252; userError is logged but not thrown at 241-248, and the same goes for handleSubscriptionDeleted at 278-286. Extra detail from code reading, not run because it calls stripe.subscriptions.retrieve: handlePaymentSucceeded (199-217) only sets the subscriptions row active and never restores the tier. A recovered payment therefore restores access only once a customer.subscription.updated(active) arrives. Grace or no grace is a product call. - -**Evidence files** - -[webhook-scenarios.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/webhook-scenarios.json), [backend-scenarios.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/backend-scenarios.json) - - -### integrity-money-18: Account-deletion Checkout race: stripe-webhook still has no backstop for subscriptions with no live account - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `checkout-completed-no-missing-user-backstop` · **Known in plan:** next-15 open follow-up from #83 (Checkout during account deletion) - -**Where** - -- supabase/functions/stripe-webhook/index.ts:143-198 (checkout.session.completed upserts subscriptions for metadata.user_id; FK to auth.users fails for a deleted user → throw → 500 → Stripe retries for days, and the subscription is never canceled) -- supabase/functions/stripe-webhook/index.ts:254-290 (deleted/updated for an unknown subscription: silent 200) - -**What the reviewer saw** - -Code reading: subscriptions_user_id_fkey REFERENCES auth.users ON DELETE CASCADE (checked in the local DB), and no handler cancels the Stripe subscription when the user is missing. Locally, customer.subscription.deleted for an unknown subscription returned 200 and did nothing (webhook-scenarios.json S6). checkout.session.completed cannot run locally: it calls the Stripe API. - -**Why it happens** - -The planned backstop was never built. The account function closes most of the window by expiring open sessions, but not a session completed during deletion. - -**Suggested fix** - -In handleCheckoutCompleted, if auth.admin.getUserById(metadata.user_id) finds no user, cancel the subscription in Stripe (stripe.subscriptions.cancel), record the event and return 200. Add a Deno test. - -**Who it hits** - -Rare, but when it happens a parent or student keeps being billed with no account. - -**Independent check** - -Known in plan: the next-15 open follow-up from #83. Code reading only, because checkout.session.completed calls stripe.subscriptions.retrieve and cannot run locally with the placeholder key. handleCheckoutCompleted (stripe-webhook/index.ts:138-197) has no missing-user branch. The upsert to subscriptions (FK to auth.users) would throw and return 5xx for Stripe to retry, and nothing cancels the Stripe subscription. Locally, customer.subscription.deleted for an unknown subscription returned 200 and changed nothing. - -**Evidence files** - -[webhook-scenarios.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/webhook-scenarios.json), [backend-scenarios.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/backend-scenarios.json) - - -### integrity-money-19: create-checkout returns raw Stripe errors and price config to the browser, and 'discount' promos are 'noted' but never applied - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `checkout-leaks-stripe-errors-discount-unwired` - -**Where** - -- supabase/functions/create-checkout/index.ts:111-121 (AppError details include recurringPriceIds, configuredPriceId, configuredLookupKey) -- supabase/functions/create-checkout/index.ts:339-347 (raw Stripe message + type/code/param returned) -- supabase/functions/create-checkout/index.ts:300-323 (discount promo only sets allow_promotion_codes; the code is not attached) -- supabase/migrations/20260819000000_security_hardening.sql:133 (discount: 'Discount code noted! Apply it at checkout', not recorded anywhere) - -**What the reviewer saw** - -checkout-probe.json: the browser-facing error was 'Invalid API Key provided: sk_test_***…used'. promo-scenarios.json p5: a discount code returns success 'Discount code noted!', but nothing stores it, and Checkout only shows a generic promotion-code field. - -**Why it happens** - -Debug detail passes through to clients, and discount promos are half-wired. - -**Suggested fix** - -Return generic errors to the client and log the details server-side. For discounts, map the promo to a Stripe promotion_code id and pass discounts: [{ promotion_code }] in sessionParams, or remove the 'discount' type. - -**Who it hits** - -Students who enter a discount code in the app reach Stripe without the discount and have to type it again. A minor information leak. - -**Independent check** - -Locally, create-checkout for a signed-in user from an allowed origin returned 400 {error:'Invalid API Key provided: sk_<masked by Stripe>', details:{type:'W',statusCode:401}}: the raw Stripe message plus a minified class name (create-checkout/index.ts:330-347). A discount promo through redeem-promo returned success 'Discount code noted!', but no user_promos row was written (the RPC returns before the insert) and settings were unchanged. create-checkout:300-323 only sets allow_promotion_codes when the code is passed, and never attaches a discount. - -**Evidence files** - -[checkout-probe.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/checkout-probe.json), [promo-scenarios.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/promo-scenarios.json), [checkout-active-subscriber.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/checkout-active-subscriber.json), [backend-scenarios.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/backend-scenarios.json) - - -### integrity-money-20: Settings says 'Signed in with Google' for demo-code and email accounts - -**Severity:** nit · **Verdict:** confirmed · **Root cause:** `settings-account-copy-assumes-google` - -**Where** - -- src/locales/en.json settings.accountDesc ('Signed in with Google. API keys are managed securely on the server.') -- route /app/settings, Account card - -**What the reviewer saw** - -b9-10s-after-webhook-no-reload.png: a demo-code account (zz-review-…@fullstack.test) shows 'Signed in with Google'. - -**Why it happens** - -Static copy written when Google was the only sign-in. - -**Suggested fix** - -Use the session's app_metadata.provider for the wording, or drop the provider sentence, in en, es and hu. - -**Who it hits** - -Confusing for the many students who sign in with an email code or demo code. - -**Independent check** - -The local demo-code account and the staging seeded email account (seed-pro@staging...) both show 'Signed in with Google. API keys are managed securely on the server.' (SettingsPage.tsx:297, settings.accountDesc). The same claim is in es.json:456 and hu.json:456. - -**Evidence files** - -[b9-10s-after-webhook-no-reload.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/integrity-money/b9-10s-after-webhook-no-reload.png), [f20-staging-settings-account-card.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f20-staging-settings-account-card.png), [staging-settings.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/staging-settings.json), [f5-return-10s-after-webhook-still-expired.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-integrity-1/f5-return-10s-after-webhook-still-expired.png) - diff --git a/plans/2026-10-08-ultracode-review-report/performance.md b/plans/2026-10-08-ultracode-review-report/performance.md deleted file mode 100644 index 5482fb92e..000000000 --- a/plans/2026-10-08-ultracode-review-report/performance.md +++ /dev/null @@ -1,1333 +0,0 @@ -# Speed: bundle, network and rendering: findings - -**Status:** Review evidence from 2026-10-08/09, for [the ranked report](../2026-10-08-ultracode-review-report.md). Not started. - -27 findings: 4 high, 12 medium, 9 low, 2 nit. An independent verifier re-checked every one. - -Each entry has the final severity, where the problem lives, what the reviewer saw, why it happens, a suggested fix, the independent check and links to the screenshots and measurements in `.verify/ultracode-review-2026-10-08/`. Line numbers are against main at 40738ea. - -Not covered by the perf-runtime reviewer: - -- Real phones, Safari/iOS, Firefox, and real cellular networks. All mobile numbers are Chromium emulation -- Production itself (off limits). Sentry and PostHog costs are sized from a dummy-DSN build, not measured, because staging has no DSN/key -- AI streaming and AI request latency (AI-surfaces agent; budget 0) -- Heavy-user data volumes: the pro account has 3 debates and 3 sources, so payload and per-keystroke scaling is shown with a synthetic 15/60 KB paste and extrapolated from code (import-source allows 50,000 chars per source) -- A real deploy: the stale-chunk behaviour was reproduced by fulfilling the chunk with the exact response Vercel returns for a missing asset -- Cold full loads of /app/attack, /app/brainstorm, /app/factoids, /app/timer and /app/flowchart (only measured through in-app navigation). FlowchartPage has no h1, so it was skipped in nav timing -- Language switching cost and the es/hu first-render path; Lighthouse/PSI scores; HTTP/3; scroll and animation frame rate (motion/polish agents) -- Announcement banner CLS: no active announcement exists on staging and creating one is forbidden. Code shows the banner mounts in normal flow after a ~450 ms Edge Function call -- Exact attribution of tool-page mount cost on mobile: the profile puts ~240 ms in React (entry chunk) and most of the rest in native '(program)' time; no React Profiler run on the production build -- Write-path latency: the 27 store write sites gated on auth.getUser() were not timed, to avoid modifying seeded data - -Not covered by the render-bundle reviewer: - -- No real AI calls (0 of my 2-request budget). Streaming was driven through the store locally, which exercises the same render, scroll and persist path as a real stream but not network chunking. -- Production was off limits. That Jam loads in production comes from reading the code (no env gate in src/bootstrap.ts, and the CSP allows *.jam.dev), not from a measurement. -- Real low-end phones, WebKit and Firefox. CPU throttling in Chromium stood in for phones. The VPS was shared (load average 2.5 to 4.6), so I lead with structural counts and treat milliseconds as indicative. -- Rendering with large real datasets: the shared pro account holds few items, so list scaling (History, Library, Flowchart) is shown structurally, not measured with hundreds of rows. -- Keystroke measurements on History, Settings, Timer and Admin. -- Per-route CSS coverage beyond total size (18.4 KB gzip, Tailwind purged). -- Service worker caching and repeat-visit cache hit rates. -- Crossfire active-phase 500 ms tick renders (needs AI to reach that phase; read in code only). - -### perf-runtime-1: Every route waits for one 1.2 MB entry chunk (368 KB br) that bundles all three locales and the whole signed-in app shell; on the mobile profile content appears at 3.5-5.1 s - -**Severity:** high · **Verdict:** confirmed · **Root cause:** `oversized-entry-chunk` - -**Where** - -- src/i18n.ts:3-5 (en, es and hu JSON statically imported) -- src/App.tsx:8-9 (AppLayout and AuthProvider static imports, so every store loads on every route) -- src/lib/stores/sparring-store.ts:3 and src/lib/announcements.ts:2 (zod in the entry) -- src/native-runtime.ts:1-4, src/lib/native.ts:1-2 (Capacitor in the web entry) -- src/bootstrap.ts:9-13 (no early preload of the current route's chunk) -- vite.config.ts build (no chunk splitting; the entry has zero static imports) -- staging /assets/index-B7gkXSCc.js - -**What the reviewer saw** - -Staging entry /assets/index-B7gkXSCc.js: 1,196,246 bytes decoded, 367.7 KB brotli as served. It is the only render-critical script. - -It contains all 50/50 sampled long Spanish and Hungarian strings. Locale JSON minified: en 90,075 B, es 99,430 B, hu 97,925 B (24/27/29 KB br); es and hu are about 197 KB decoded / 57 KB br that an English user never uses. - -Other entry contents (source-map attribution of a local build of the same commit): react-dom 132 KB; @supabase/* 213 KB (auth-js 99, realtime-js + phoenix 57, unused outside the hidden Workspace; storage-js 22); zod v4 88.5 KB; stores 45 KB; sonner 34 KB; date-fns 21.5 KB; @radix-ui/react-select 18 KB; Capacitor ~9 KB; AppLayout, debate dialogs and DebateForm about 23 KB; supabase/functions/_shared/model-catalog and debate-context. - -Mobile profile, cold, medians of 3, content = first visible h1: -- /app 3,696 ms -- /login 3,527 ms (its own chunk is 5 KB) -- / 5,114 ms (LCP 5,884) -- /app/library 3,744 ms, /app/sparring 4,167 ms -- single runs: /app/generate 4,494 ms, /app/ask 4,189 ms - -Mobile waterfall for /app cold, run 2: document 0-218 ms; entry 248-2,328 ms (2.08 s of download); a 564 ms long task at 2,368 ms; first Supabase request at 3,101 ms; HomePage chunk requested only at 3,215 ms; content at 3,696 ms. - -Warm mobile loads still spend 7 long tasks / 903 ms (median) at 4x and show content at 867 ms. - -Unthrottled desktop cold: entry long task 127-138 ms, content 474 ms (/app), 472 ms (/login), 652 ms (/). - -Because everything is in one chunk, any code change gives the entry a new hash, so returning users re-download all 368 KB after every deploy. - -**Why it happens** - -Static imports pull everything into one chunk: -- i18n bundles every language so that init is synchronous. -- App.tsx imports AppLayout and AuthProvider eagerly. AuthProvider imports all five stores, which pull zod, the _shared modules and the debate dialogs. -- main.tsx imports the Capacitor runtime unconditionally. -- Nothing tells the browser to fetch the current route's lazy chunk until React has rendered the Suspense boundary, after the whole entry has executed. - -**Suggested fix** - -1. src/i18n.ts: keep en bundled as the fallback. Load es/hu with import(`./locales/${lng}.json`) through i18next-resources-to-backend or addResourceBundle before first render. Read the persisted language from debate-trainer-settings in bootstrap.ts and await it only when it is not en; keep the existing startup shell up meanwhile. -2. App.tsx: lazy-load the protected shell (AppLayout, DebateDialogs, InactiveDebateGate) as one chunk, so / and /login skip it. Make the sparring row parsing and the announcement schemas lazy, or use a small hand validator, so zod leaves the entry. -3. bootstrap.ts: call preloadRoute(window.location.pathname) before mountApp() so the route chunk downloads while the entry executes. -4. native-runtime.ts / native-app-url-listener.ts: import('@capacitor/...') only when isNativeApp(). -5. vite.config.ts: add output.manualChunks for react/react-dom/react-router and @supabase/*, so vendor code stays cached across deploys. - -Target: entry under 150 KB br for /login and /, plus one locale. - -**Who it hits** - -Every visit by every user, worst on first visits and phones. Students on mid-range Android over 4G look at the 'Loading DebateLab' card for about 2.8 s before anything renders, and first-time visitors to the landing page wait more than 5 s. Every deploy forces returning users to re-download the whole entry. - -**Independent check** - -Reproduced on staging today. The entry /assets/index-B7gkXSCc.js is served as 376,071 B brotli (1,204,196 B decoded) and is the only render-critical script. 50 of 50 sampled long en, es and hu strings are in it. Source-map attribution of my own local build of 40738ea (1,201 KB min): i18next plus the inlined catalogs 324 KB, react-dom 129, @supabase/auth-js 99, zod 88.5, stores 45, realtime-js 32 + phoenix 25 + storage-js 22 + iceberg 5, sonner 34, date-fns 21.5, radix-select 18, @capacitor/core 8 plus plugin defs. Mobile cold runs at CPU 4x + fast4g, 3 each, load 2.8-4.2: on /app the entry downloads from 240-2,406 ms (about 2.08 s), the HomePage chunk is requested at 3,254-3,301 ms, the app h1 shows at 3,295-3,344 ms (median 3,298) and LCP is 3,572 ms median. On /login the entry downloads from 339-2,482 ms, LoginPage is requested at 3,109-3,196 ms and the h1 shows at median 3,498 ms. Desktop unthrottled /app shows its h1 at about 583 ms. Correction to fix #3: preloadRoute already exists (src/lib/route-modules.ts:49). Bootstrap's body only runs after the whole entry module graph has evaluated, so a JS preload saves little. Also, src/test/vercel-deployment-config.test.ts:105 deliberately forbids HTML modulepreload of page chunks. The real win is shrinking the entry. render-bundle-5 (locales) and render-bundle-6 (signed-in shell, zod, realtime, Capacitor) are sub-fixes of this finding and are marked duplicateOf it. I did not measure the '/ 5,114 ms' landing figure with network throttling. - -**Evidence files** - -[matrix-medians.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/matrix-medians.json), [matrix.jsonl](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/matrix.jsonl), [m1-_app-pro-r1.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/raw/m1-_app-pro-r1.json), [bundle-graph-local-build.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/bundle/bundle-graph-local-build.json), [source-map-explorer-entry.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/bundle/source-map-explorer-entry.json), [prof-_app-cold-cpu4.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/raw/prof-_app-cold-cpu4.json), [loads.jsonl](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/loads.jsonl), [last-pro-mobile-_app-cpu4-fast4g.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/raw/last-pro-mobile-_app-cpu4-fast4g.json), [last-null-mobile-_login-cpu4-fast4g.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/raw/last-null-mobile-_login-cpu4-fast4g.json), [bundle-analysis-local-build.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/bundle-analysis-local-build.json), [source-map-explorer-local-entry.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/source-map-explorer-local-entry.json), [load.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/scripts/load.mjs) - - -### perf-runtime-2: Jam recorder loads for every visitor: 10 requests / 772 KB, about 1 s of main-thread work at 4x CPU, doubles the JS heap, wraps window.fetch, and keeps a request open forever - -**Severity:** high · **Verdict:** confirmed · **Root cause:** `jam-recorder-unconditional` - -**Where** - -- src/bootstrap.ts:13 (scheduleJamRecorder() for every page, signed in or not) -- src/jam-loader.ts:1-4 and 55-73 (recorder.js + capture.js after load + idle, with no user, admin or flag gating) -- index.html:6 (jam:team meta) -- src/lib/jam-metadata.ts (publishes the user id to the recorder) - -**What the reviewer saw** - -Jam is scheduled after load + idle, so it does not delay first content. It costs the first seconds of interaction. - -Desktop cold /app: Jam = 10 requests, 772 KB transferred. Chain: js.jam.dev/recorder.js 31 KB + capture.js 32 KB, then an iframe at recorder.jam.dev/capture that loads index-47191793.js (610 KB), a 76 KB CSS file, a Cloudflare RUM beacon and worker.js. - -CPU profiles, mobile, 4x: -- Cold: Jam scripts ≈1,000 ms of main-thread time (recorder iframe JS 796 ms, capture.js 177 ms, beacon 28 ms), against 1,007 ms for the app's entry. -- Warm: Jam 468 ms of JS, and native '(program)' time 1,028 ms versus 581 ms without Jam. - -A/B medians, 3 runs each: -- Mobile warm long tasks: 7 / 903 ms with Jam, 4 / 418 ms blocked. Mobile cold: 9 / 1,330 ms vs 5 / 961 ms. -- ScriptDuration, mobile warm: 828 vs 434 ms. Desktop warm: 212 vs 87 ms. -- JS heap, desktop warm: 33.1 vs 14.6 MB. -- After forced GC at 60 navigations: 21.0 vs 11.4 MB heap. JS event listeners grow by about 4 per round trip only with Jam (914 to 1,242); without Jam they stay at 338. - -raw/idle.json: networkidle never settles with Jam (20 s timeout; recorder.jam.dev/worker.js still pending) and settles in 1.4 s with Jam blocked. - -On warm loads the iframe document is revalidated (recorder.jam.dev/capture is max-age=0) and worker.js is re-requested. - -The initiator of later Supabase calls is 'js.jam.dev/capture.js:window.fetch', so every API call goes through Jam's wrapper. - -The console warning 'recorder.jam.dev/assets/main-567a071d.tsx was preloaded using link preload but not used' appears in 23 of 92 runs with Jam and 0 of 12 without. - -**Why it happens** - -The Jam bug-capture SDK is injected unconditionally after load for every visitor, including signed-out landing visitors. Its capture script buffers console and network by wrapping fetch, and its recorder runs a full app (610 KB) in an iframe with a long-lived worker. In headless Chromium the iframe's JS ran on the page's main thread; on real phones, process placement may differ. - -**Suggested fix** - -Load Jam on demand only: -- in src/jam-loader.ts, gate scheduleJamRecorder() on a signed-in admin/staff role, on a URL flag such as ?jam=1 (persisted in sessionStorage for that tab), or on a 'Report a problem' click; -- or load capture.js alone (fetch/console buffer) and inject the recorder iframe only when a recording starts. - -Remove the jam:team meta and the *.jam.dev CSP entries for builds where Jam is off. - -**Who it hits** - -Every visitor on every full load. Phones get 0.5-1 s of extra main-thread work right when students start tapping, which makes the first taps feel stuck. The 772 KB download costs mobile data. Heap doubles for the whole session. Automated tooling that waits for network idle (Lighthouse, Playwright networkidle) never finishes. - -**Independent check** - -Code: src/bootstrap.ts:13 calls scheduleJamRecorder() on every page. src/jam-loader.ts:53-72 gates it only on window load, idle and MediaRecorder support. Every cold load I ran, on /, /login, /app, /app/library and /app/settings, signed in or out, fetched 9 Jam requests, about 772 KB: js.jam.dev recorder.js 31 KB and capture.js 32 KB, then the recorder.jam.dev/capture iframe with index-47191793.js 610 KB, CSS 76 KB, worker.js and the Cloudflare beacon. recorder.jam.dev/worker.js was still pending at the end of every Jam run and never in the no-Jam runs. A/B on / signed out, mobile, CPU 4x, 3 runs each, load 2.5-4.4, medians: ScriptDuration 2,889 vs 1,591 ms (+1.3 s), long tasks 10 / 2,501 ms vs 5 / 1,161 ms, CDP JSHeapUsedSize 16.7 vs 9.8 MB. performance.memory showed no difference because it is bucketed. Unthrottled mobile: 3 / 439 vs 2 / 242 ms of long tasks. window.fetch is native before Jam loads and wrapped after. FCP and LCP do not move, because the cost lands after load. Not reproduced: the listener-growth and heap-after-60-navigations figures, and the 'preloaded but not used' warning. The heap rise I measured is about 1.7x, not 2x. render-bundle-2 is the same finding. - -**Evidence files** - -[idle.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/raw/idle.json), [prof-_app-cold-cpu4.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/raw/prof-_app-cold-cpu4.json), [prof-_app-warm-cpu4.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/raw/prof-_app-warm-cpu4.json), [prof-_app-warm-cpu4-nojam.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/raw/prof-_app-warm-cpu4-nojam.json), [mem2.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/raw/mem2.json), [mem2-nojam.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/raw/mem2-nojam.json), [swnet-mobile.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/raw/swnet-mobile.json), [matrix-medians.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/matrix-medians.json), [last-null-mobile-_-cpu4.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/raw/last-null-mobile-_-cpu4.json), [last-null-mobile-_-nojam-cpu4.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/raw/last-null-mobile-_-nojam-cpu4.json), [loads.jsonl](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/loads.jsonl), [fetchwrap.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/scripts/fetchwrap.mjs) - - -### render-bundle-1: Ask streaming saturates the main thread (re-renders and re-parses every message every 30 ms) and pins the reader's scroll to the bottom - -**Severity:** high · **Verdict:** confirmed · **Root cause:** `ask-stream-rerender-and-autoscroll` - -**Where** - -- src/pages/AskPage.tsx:42 (whole-store useSessionStore()) -- src/pages/AskPage.tsx:189-201 (30 ms delta batching -> appendAskStreamingDelta) -- src/pages/AskPage.tsx:317 (handleSaveToLibrary recreated every render) -- src/pages/AskPage.tsx:334-343 (formattedMessages builds new objects every render) -- src/pages/AskPage.tsx:367 -- src/components/features/ask/AskChat.tsx:82 (React.memo MessageBubble, defeated) -- src/components/features/ask/AskChat.tsx:380-382 (smooth scrollIntoView on [messages]) -- src/components/features/ask/AskChat.tsx:557-558 -- src/components/ui/markdown.tsx:4 (unmemoized ReactMarkdown) -- src/lib/stores/session-store.ts:948-952 -- route /app/ask - -**What the reviewer saw** - -Production build of 40738ea served locally against staging (vite preview + staging proxy), pro account, Pixel 7 viewport, CPU throttled 4x, all writes to staging blocked. Streaming was driven through the real store with 100 appendAskStreamingDelta calls at 30 ms spacing (about 3 s of tokens), no AI call. - -Wall time for those 100 flushes, and long tasks: -- 1 prior message: 6,757 ms wall, 74 long tasks, 4,566 ms total. -- 11 prior messages: 11,174 ms wall, 99 long tasks, 9,787 ms. -- 31 prior messages: 16,474 ms wall, 100 long tasks, 14,783 ms. - -Every flush is one commit re-rendering 401, 501 or 701 components. Dev counts show MessageBubble re-rendering for every message on every flush: 1,200 renders per 100 flushes with 12 messages, 3,200 with 32. Markdown renders were 2,400 and 6,400 per 100 flushes. Every flush also writes the whole persisted store: 101 localStorage writes, 9.4 to 10.9 MB serialized. - -CPU profile (production build, unthrottled, 11 messages, 3.5 s): scrollIntoView 577 ms, markdown chunk 539 ms, React plus store 964 ms (including 137 ms of zustand setItem stringify and 86 ms of native setItem). - -Scroll test (desktop, production build): mid-stream, the chat viewport was set to scrollTop 0 and the window to 0. The chat viewport read 9 at 100 ms, 1,241 at 300 ms and 2,169 (the maximum) at 1 s. The window snapped back to 140. Video ask-stream-scroll-yank.webm. - -**Why it happens** - -Five causes stack up: -- AskPage subscribes to the entire session store, so every 30 ms delta re-renders the page. -- It maps askMessages into fresh objects and recreates onSaveSource on every render, so MessageBubble's React.memo never holds. -- Markdown is not memoized, so react-markdown re-parses every past message on every flush. -- The auto-scroll effect depends on a messages array that is new on every render, so smooth scrollIntoView (which also scrolls the window) restarts about 33 times a second and overrides the user's scroll. -- zustand persist serializes the whole partialized state on every set(), even though askStreamingAssistant itself is not persisted. - -**Suggested fix** - -- AskPage: subscribe with selectors (useShallow), memoize the message list or pass store messages straight through, and wrap handleSaveToLibrary in useCallback. -- Render the streaming bubble as its own component subscribed only to askStreamingAssistant, so finished messages never re-render. -- Wrap Markdown in React.memo keyed on content. -- While streaming, re-parse at most once per animation frame, or show plain text and switch to markdown when the answer finishes. -- Auto-scroll only when the user is already near the bottom. Set the ScrollArea viewport's scrollTop with behavior auto during streaming instead of calling scrollIntoView. -- Stop persisting on every streaming delta (see render-bundle-3). - -**Who it hits** - -Every Ask user, worst on phones. It gets worse as the conversation grows: the UI lags behind the network, taps and scrolling freeze while an answer streams, and the reader cannot scroll back up to re-read anything until the answer ends. - -**Independent check** - -Code confirmed: AskPage.tsx:42 subscribes to the whole store, :334-343 builds new message objects every render, :317 recreates handleSaveToLibrary, AskChat.tsx:380-382 calls a smooth scrollIntoView on [messages], markdown.tsx is unmemoized, and session-store.ts:949-953 plus persist write on every delta. Repro on staging with no AI cost: an in-page fake SSE stream for /functions/v1/ai-stream drove the real code, with Jam blocked and all writes route-aborted. Mobile at CPU 4x, 300 deltas: with 0 prior messages there were 110 long tasks / 10.5 s (max 315 ms) and 117 store writes / 22 MB serialized. With 22 prior bubbles, 113 long tasks / 20.4 s (max 485 ms) and 119 writes / 24.7 MB. Desktop at 4x gave 107 / 9.7 s and, with 8 bubbles, 111 / 16.6 s. So each flush costs about 90-180 ms. My producer is a main-thread timer, so the wall times (15-27 s for an ideal 3 s) overstate real lag; the per-flush cost is the reliable figure. Scroll test, desktop at 1x: I set the chat viewport and the window to the top mid-stream. The window snapped back from 0 to 140 within 300 ms, and the viewport was dragged steadily down (0, 2, 84, 251, 530 px over 1 s), so the reader cannot stay scrolled up. Aside, not a finding of this batch: the chat viewport is only 143 px tall at 1280x800, and on mobile the messages area was not visible in the screenshot. Exclude from any finding the ERR_FAILED console errors and the 'Could not save this result to the debate' toast in the screenshot; my write blocking caused both. - -**Evidence files** - -[prod-build-cpu4x-measurements.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/prod-build-cpu4x-measurements.json), [prod-ask-stream-profile-10.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/prod-ask-stream-profile-10.json), [stream-sim-0msgs.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/stream-sim-0msgs.json), [stream-sim-10msgs.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/stream-sim-10msgs.json), [stream-sim-30msgs.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/stream-sim-30msgs.json), [ask-stream-scroll-yank.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/ask-stream-scroll-yank.json), [ask-stream-scroll-yank.webm](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/ask-stream-scroll-yank.webm), [ask-stream-yank-after-scroll-up.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/raw/ask-stream-yank-after-scroll-up.png), [prod-stream.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/scripts/prod-stream.mjs), [yank.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/scripts/yank.mjs), [ask-stream.jsonl](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/ask-stream.jsonl), [ask-stream-yank-desktop.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/raw/ask-stream-yank-desktop.png), [ask-stream-yank-mobile.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/raw/ask-stream-yank-mobile.png), [ask-stream.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/scripts/ask-stream.mjs) - - -### render-bundle-2: Jam bug recorder loads about 690 KB of third-party JS for every visitor on every page, more than the whole app - -**Severity:** high · **Verdict:** confirmed · **Root cause:** `jam-recorder-unconditional` · **Same fix as:** perf-runtime-2 - -**Where** - -- src/bootstrap.ts:15 (scheduleJamRecorder() unconditional) -- src/jam-loader.ts:1-4 (recorder.js + capture.js) -- src/jam-loader.ts:53-68 (loads after window load + idle, no env/user gating) -- src/lib/supabase/AuthProvider.tsx:11 (setJamUserId) -- vercel.json:18 (CSP script-src/frame-src/connect-src *.jam.dev) -- routes / (signed out) and /app (signed in) - -**What the reviewer saw** - -Staging, / signed out, Pixel 7, 3 runs with Jam allowed and 3 with Jam blocked. Load average 2.8 to 4.4. Medians: -- JS transferred: 1,119 KB with Jam vs 431 KB without. -- ScriptDuration: 613 ms vs 254 ms. -- Long-task total: 467 ms vs 219 ms. -- JS heap: 17.8 MB vs 9.6 MB. - -Jam requests: js.jam.dev recorder.js 31.5 KB and capture.js 32.3 KB, recorder.jam.dev 6 requests and 697.8 KB (main bundle index-47191793.js is 609.7 KB transferred), and a static.cloudflareinsights.com beacon (10.2 KB) that only appears when Jam loads. Signed-in /app shows the same: recorder.jam.dev 699 KB. - -The main-thread work lands after load, at idle time, so FCP and LCP don't move. The cost is bandwidth (about 2.6x the app's own JS), battery, and contention with taps in the first seconds. - -**Why it happens** - -bootstrap.ts schedules the Jam recorder for every page view in every environment, gated only on MediaRecorder support. Jam is a bug-reporting tool, but every student pays its full cost on every visit. - -**Suggested fix** - -Load Jam only on demand: from a 'Report a problem' action, for admins, on staging, or behind an opt-in flag (for example ?jam=1 kept in localStorage). Otherwise remove it from production builds with a build-time env check in src/bootstrap.ts. If it is removed or gated, tighten the CSP *.jam.dev entries to match. Since setJamUserId tags recordings with the user id, gating also cuts what is sent to a third party. - -**Who it hits** - -Every visitor on every first load, including signed-out landing visitors and students on mobile data. - -**Independent check** - -Same cause and fix as perf-runtime-2. Location correction: scheduleJamRecorder() is at src/bootstrap.ts:13, not :15. Reproduced 9 Jam requests / about 772 KB on / signed out and on /app signed in. On / mobile, total scripts were 1,119 KB with Jam vs 431-442 KB without. A/B medians at CPU 4x: ScriptDuration 2,889 vs 1,591 ms, CDP heap 16.7 vs 9.8 MB. The setJamUserId privacy angle (AuthProvider.tsx:11) supports gating. - -**Evidence files** - -[load-_-anon-mobile.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/load-_-anon-mobile.json), [load-nojam-_-anon-mobile.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/load-nojam-_-anon-mobile.json), [load-_app-pro-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/load-_app-pro-desktop.json), [load.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/scripts/load.mjs), [last-null-mobile-_.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/raw/last-null-mobile-_.json), [last-null-mobile-_-nojam.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/raw/last-null-mobile-_-nojam.json), [last-null-mobile-_-cpu4.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/raw/last-null-mobile-_-cpu4.json), [last-null-mobile-_-nojam-cpu4.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/raw/last-null-mobile-_-nojam-cpu4.json) - - -### perf-runtime-3: First load on a device shows 'No current debate' / 'Start debate' / '0 sources' placeholders for 200-420 ms, then swaps in the real debate (Library CLS 0.175), because two stores make a network getUser() round trip before querying - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `debate-placeholder-before-hydration` - -**Where** - -- src/lib/stores/debate-store.ts:127 (hydrate awaits supabase.auth.getUser() before the debates and current_debates queries) -- src/lib/stores/sparring-store.ts:481 (same before sparring_sessions) -- src/lib/supabase/AuthProvider.tsx:133-146 (hydrateStores already has the userId from getSession()) -- src/pages/HomePage.tsx:113-124 (dashed 'No current debate' card whenever debate is null, ignoring hydrated) -- src/pages/LibraryPage.tsx:477-492 ('Select or create a debate', 0 sources, 'No Sources Yet', Add Source disabled) -- src/pages/CurrentDebatePage.tsx:90-104 (EmptyState 'No current debate') -- src/components/features/debate/CurrentDebateMenu.tsx:32-35 (header shows 'Start debate', which opens the create dialog) -- Only src/components/features/debate/DebateDialogs.tsx:171 checks hydrated - -**What the reviewer saw** - -Every cold /app load fires two parallel GET /auth/v1/user calls. debates and current_debates start only after they resolve. -- Desktop: getUser 373-538 ms, then debates 540-623 ms. -- Mobile: getUser 3,102-3,503 ms, then debates 3,719-3,916 ms. - -Frame-accurate captures, desktop cold, single runs: -- Home: placeholder at 398 ms, real card at 599 ms. A second run: header 'Start debate' 396-736 ms, main placeholder 479-736 ms. -- Library: placeholder at 672 ms, real at 973 ms. -- Current Debate: placeholder at 570 ms, real at 877 ms. -- A warm reload in the same context shows no flash, because debates are persisted. - -CLS, desktop cold: /app/library 0.1749 (median of 3, 3/3 runs; the real content shifts everything up 54 px) and /app/debate 0.1877 (single run). Mobile cold: /app/library 0.0547, /app/debate 0.0687. - -The LCP element of every cold /app load is the placeholder text 'Start a debate to keep one motion…'. - -The Current Debate page heading (the motion) waits for the chain: desktop content at 1,009 ms vs 474 ms for /app; mobile at 4,613 ms vs 3,696 ms (single run; getUser 3,288-4,095, debates 4,113-4,303). - -**Why it happens** - -supabase.auth.getUser() validates the JWT with a network call to /auth/v1/user. The stores use it just to learn the user id the AuthProvider already holds, which serialises a round trip in front of the data. Meanwhile, consumers treat debate === null as 'the user has no debate' instead of 'not loaded yet', because they never read useDebateStore(s => s.hydrated). - -**Suggested fix** - -1. Pass the userId from AuthProvider.hydrateStores into hydrate(userId) and hydrateSavedSessions(userId). Otherwise read it locally with supabase.auth.getSession(); RLS still enforces access on the server. -2. In HomePage, LibraryPage, CurrentDebatePage, CurrentDebateMenu and CurrentDebateStrip, render a same-size skeleton while !hydrated && !debate. Never show 'Start debate' or a disabled Add Source until hydration ends. -3. Reserve the card height so the swap causes no shift. - -**Who it hits** - -Every user's first visit on a device, after sign-in, and after clearing data. That includes every new student's first impression. They see an empty, dashed 'No current debate' state and an active 'Start debate' button that opens the create dialog if tapped, then the page jumps. The Current Debate page heading arrives about 0.9 s later on phones. - -**Independent check** - -Code confirmed. debate-store.ts:127 and sparring-store.ts:481 await supabase.auth.getUser() before querying. HomePage.tsx:113, LibraryPage.tsx:477-492, CurrentDebatePage.tsx:90 and CurrentDebateMenu.tsx:32 render the 'no debate' state whenever debate is null. Only DebateDialogs.tsx:171 reads hydrated. Desktop cold runs: debates and current_debates start only after both /auth/v1/user calls return (getUser 596-754 ms, debates at 712 ms). Home: header 'Start debate' at 583 ms, dashed 'No current debate' card at 676 ms, real card at 831 ms. Library, 3/3 runs: 'Select or create a debate', 0 sources and a disabled Add Source for 76-168 ms (764 ms in one run with slow auth), then a 0.1749-0.1752 layout shift as the content below moves up 54 px. Total CLS was 0.191, which includes a second 0.016 shift. My screenshots caught the placeholder without any added delay. The LCP element was the placeholder text in 3/3 mobile throttled /app runs and 2/3 unthrottled ones, so 'most' loads, not 'every'. Fix #1 (pass the userId instead of calling getUser) is the same change as perf-runtime-5 item 5 and render-bundle-12. The flash and CLS still need the hydrated gate plus a reserved card height. - -**Evidence files** - -[flash-_app_library-desktop-placeholder.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/flash-_app_library-desktop-placeholder.png), [flash-_app_library-desktop-after.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/flash-_app_library-desktop-after.png), [flash-_app-desktop-placeholder.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/flash-_app-desktop-placeholder.png), [flash-_app-desktop-after.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/flash-_app-desktop-after.png), [flash-_app_debate-desktop-placeholder.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/flash-_app_debate-desktop-placeholder.png), [flash-_app_debate-desktop-after.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/flash-_app_debate-desktop-after.png), [flash-_app-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/raw/flash-_app-desktop.json), [flash-_app_library-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/raw/flash-_app_library-desktop.json), [matrix.jsonl](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/matrix.jsonl), [flash_app_library-placeholder.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/raw/flash_app_library-placeholder.png), [flash_app_library-after.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/raw/flash_app_library-after.png), [flash_app-placeholder.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/raw/flash_app-placeholder.png), [flash_app-after.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/raw/flash_app-after.png), [last-pro-desktop-_app_library.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/raw/last-pro-desktop-_app_library.json), [flash.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/scripts/flash.mjs) - - -### perf-runtime-4: Each keystroke in the Generate, Attack, Brainstorm and Factoids forms re-serialises the whole persisted library to localStorage (20 KB per key today, growing with saved work), producing one long task per key on phones - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `persist-whole-store-every-set` · **Same fix as:** render-bundle-3 - -**Where** - -- src/components/features/generate/GenerateForm.tsx:81-82 (watch -> setGenerateForm on every change) -- src/components/features/attack/AttackForm.tsx:58-59 -- src/components/features/brainstorm/BrainstormForm.tsx:51-52 -- src/components/features/factoids/FactoidsForm.tsx:55-56 -- src/lib/stores/session-store.ts:490-493 (setXForm -> set) and 1281-1292 (partialize persists everything except loading flags: saved cases, attacks, brainstorms, factoids, chats, flowcharts, full source texts) -- src/pages/GeneratePage.tsx:35 and GenerateForm.tsx:57 (useSessionStore() with no selector, so the whole page re-renders on any store change) - -**What the reviewer saw** - -Typing 120 characters in the Generate motion field at CPU 4x (single run per device): 120 localStorage.setItem calls on 'debate-trainer-storage', 2,443 KB written in total (about 20 KB per keystroke). -- Desktop: 58 long tasks of 50-100 ms; key-event median 64 ms; 289 of 584 key events over 50 ms. -- Mobile: 30 long tasks; median 56 ms. -- Control, the Ask box (local useState, no store write): median 40 / 32 ms, 1 long task, 0 writes. The Library textarea: median 40 ms, 0 writes. - -Attack case textarea at 4x, mobile, single run, prefilled with 0 / 15 / 60 KB of case text (the form value itself is persisted): -- per-write payload 20 / 35 / 79 KB; -- keydown median 56 / 64 / 104 ms, p90 72 / 80 / 120 ms; -- 11 / 16 / 49 long tasks for 49 keys. With 60 KB, every keystroke is a long task. - -The pro account holds only 3 debates. import-source allows 50,000 characters per source, and all source texts are persisted, so the per-keystroke cost scales with the library. - -**Why it happens** - -Zustand's persist middleware writes JSON.stringify(entire partialized state) on every set(). The tool forms mirror every react-hook-form change into the same persisted store as the whole saved library, and the pages subscribe to the entire store. - -**Suggested fix** - -1. Move the form drafts (generateForm, attackForm, brainstormForm, factoidsForm) into a small separate persisted store, or write them debounced (300-500 ms) on blur and submit. -2. Stop persisting server-owned lists (savedCases…, sourceLibrary). They are re-fetched on boot anyway; if offline display is wanted, keep them in IndexedDB. -3. Replace useSessionStore() destructuring with selectors or useShallow so a keystroke re-renders only the field. - -**Who it hits** - -Anyone typing a motion or pasting a case. It is already measurable on mid-range phones, and it gets worse for every student as their library grows. Heavy users with dozens of imported sources would see 100+ ms lag per key, plus a risk of hitting the roughly 5 MB localStorage quota. - -**Independent check** - -Code: GenerateForm.tsx:80-85, AttackForm.tsx:57-62, BrainstormForm.tsx:51-55 and FactoidsForm.tsx:55-59 call setXForm through watch() on every change. zustand 5.0.15 persist calls setItem after every set(), and partialize (session-store.ts:1281-1292) keeps every saved list. Measured on mobile at CPU 4x, typing 41 characters into Generate's motion field: 41 writes of 'debate-trainer-storage' at 188 KB each, 7.7 MB serialized in total, 39 long tasks / 2,665 ms, keydown median 72 ms (max 128). Control, the Ask input (local state): 0 writes, 1 long task, median 40 ms. Each write is now 188 KB instead of the reviewer's 20 KB because other agents grew the shared pro account during the review, which directly confirms that the cost scales with the library. Fix item #3 (selectors) belongs to render-bundle-4. Folded into render-bundle-3, which also covers the quota failure. - -**Evidence files** - -[inp.jsonl](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/inp.jsonl), [attack-typing.jsonl](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/attack-typing.jsonl), [typing.jsonl](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/typing.jsonl), [typing.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/scripts/typing.mjs) - - -### perf-runtime-5: Every full load downloads the user's whole library across 14 queries (select=*, no limit, dependent messages query), with duplicate auth and sparring calls, a 3-step sequential subscription chain, and a ~450 ms Edge Function call - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `eager-hydration-network` - -**Where** - -- src/lib/stores/session-store.ts:1083-1093 (9 parallel select('*') queries over cases, attacks, brainstorms, factoids, chats, flowcharts, sources incl. full content, source_folders, crossfires; no limit, all debates) -- src/lib/stores/session-store.ts:1119-1123 (messages for every chat, only after chats returns) -- src/pages/HistoryPage.tsx:57-59 (hydrateSavedSessions again while the provider's call is still in flight) -- src/hooks/use-subscription.ts:~45-120 (user_settings -> subscriptions -> ai_requests count, run sequentially; user_settings is already loaded by settings-store) -- src/components/layout/AppLayout.tsx:48,121,130 (announcements Edge Function on boot and on route changes after 60 s) -- src/lib/stores/session-store.ts:288-290 and 26 other getUserId()/requireUserId() call sites (each write first makes a /auth/v1/user round trip) -- supabase/functions/import-source/index.ts:12 (MAX_CONTENT_CHARS 50,000 per source) - -**What the reviewer saw** - -Cold /app, any route: 2 auth calls, 14 REST calls, 1 Edge Function, and 17 CORS preflights before the cache warms. -- The 14 REST calls: cases, attacks, brainstorms, factoids, chats, flowcharts, sources, source_folders, crossfires, user_settings, sparring_sessions, debates, current_debates, messages. -- Waterfall depth 3: getUser -> debates; chats -> messages (desktop 523-586 ms after chats 375-494 ms). - -/app/history cold: 3 /auth/v1/user calls and sparring_sessions fetched twice (2,247-3,056 and 2,277-3,204 ms). - -Tool pages (/app/sparring, /app/generate, /app/ask, /app/settings): 18 REST calls. They add a second user_settings call (583 ms and again 922 ms), then subscriptions (1,014-1,060), then debate_context_pins (1,064-1,105), then a HEAD on ai_requests (1,092-1,151), one after another. - -functions/v1/announcements takes 430-600 ms on every boot (408-1,006 ms on desktop cold /app). - -In-app navigation does not refetch: backHomeSb is empty in nav.jsonl. A first tool visit adds 4 calls. - -Payload is small for pro (21 KB across the REST calls; persisted copy 20.6 KB) only because the account has 3 debates and 3 sources. Every saved output and full source text across all debates is fetched on every load and then persisted (see perf-runtime-4). - -**Why it happens** - -The session store hydrates eagerly as an all-tables mirror instead of loading per route or per Current Debate. Dedupe is only by a completed 'hydrated' flag, so in-flight duplicates slip through. use-subscription chains independent queries with await. Every store write resolves the user id over the network. - -**Suggested fix** - -1. Hydrate only the Current Debate's items on boot (.eq('debate_id', current)). Select list columns (id, title, created_at, debate_id, side) for lists and fetch output/content on open. Paginate History and Library. -2. Embed messages in the chats query (chats?select=*,messages(*)), or load them when a chat opens. -3. Keep an in-flight promise in hydrateSavedSessions so concurrent callers share it. -4. In use-subscription, run the three queries with Promise.all and reuse settings-store's user_settings row. -5. Use getSession()/the AuthProvider user id in getUserId()/requireUserId(). -6. Defer announcements until idle, or read admin_announcements via PostgREST with RLS, and give the banner reserved space. -7. Add <link rel=preconnect> to the production Supabase origin in index.html. - -**Who it hits** - -Every full load for every signed-in user. Phones pay 3 sequential round trips before the Current Debate and tier are known. Data use and boot time grow with each student's library. Each write action waits an extra auth round trip. - -**Independent check** - -Cold /app, desktop: 2 /auth/v1/user calls, 14 REST calls and 1 Edge Function call, with no writes. The REST calls are 9 select=* without limit from session-store.ts:1084-1092, plus user_settings, debates, current_debates, sparring_sessions and messages. messages starts only after chats returns (714 ms). debates starts only after getUser (712 ms). announcements took 862-1,534 ms. /app/history, 2 runs: 3 /auth/v1/user calls and sparring_sessions fetched twice, overlapping in flight (771-812 and 796-875 ms), from HistoryPage.tsx:57-59 plus the provider. use-subscription.ts:47-130 runs user_settings, then subscriptions, then ai_requests sequentially (/app/settings: 614-731, 750-804, 823-866 ms). Correction: the count is 22 getUserId()/requireUserId() call sites in session-store.ts plus 2 in debate-store.ts and 3 in settings-store.ts, not '26 other'. I did not separately verify the extra debate_context_pins call on tool pages. render-bundle-12 is a duplicate of this finding, and its getUser part is the same fix as perf-runtime-3 #1. - -**Evidence files** - -[matrix.jsonl](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/matrix.jsonl), [m1-_app-pro-r1.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/raw/m1-_app-pro-r1.json), [nav.jsonl](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/nav.jsonl), [last-pro-desktop-_app.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/raw/last-pro-desktop-_app.json), [last-pro-desktop-_app_history-nojam.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/raw/last-pro-desktop-_app_history-nojam.json), [last-pro-desktop-_app_settings.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/raw/last-pro-desktop-_app_settings.json), [loads.jsonl](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/loads.jsonl) - - -### perf-runtime-6: After a deploy, open tabs get 'Unable to load this page': missing assets return the SPA shell as 200 text/html with a one-year immutable header, the SW caches that HTML as JS, and nothing recovers automatically. The SW itself never versions - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `stale-chunk-html-cached-by-unversioned-sw` · **Known in plan:** next-15 #15 asks to localise the offline page in public/sw.js. The stale-chunk, caching and versioning problems are not in the plan - -**Where** - -- vercel.json:5-10 (rewrite /(.*) -> /index.html also catches /assets/*) -- vercel.json:114-121 (/assets/(.*) -> public, max-age=31536000, immutable, so it also applies to the rewritten HTML) -- public/sw.js:50-66 (cacheFirst caches any response.ok, whatever its content-type) -- public/sw.js:1 and 37-49 (constant CACHE_VERSION, so caches are never pruned and the SW only updates when someone edits sw.js) -- public/sw.js:111-119 (cacheFirst for every same-origin script: /theme-init.js and /_vercel/* are frozen despite Vercel no-store) -- src/App.tsx:135-160 (RouteErrorBoundary generic error; no vite:preloadError handler anywhere in src) - -**What the reviewer saw** - -curl https://debatelab-staging.vercel.app/assets/Nope-abc12345.js returns HTTP 200, content-type text/html, cache-control: public, max-age=31536000, immutable, and the 2.6 KB SPA shell. - -Simulated deploy (raw/sw.json): the LibraryPage chunk was answered with exactly that Vercel response, then the Library tile was tapped. -- Console: 'Expected a JavaScript-or-Wasm module script but the server responded with a MIME type of "text/html"' and 'Failed to fetch dynamically imported module'. -- The screen shows 'Unable to load this page / Reload'. -- The SW runtime cache then holds /assets/LibraryPage-D1kN5AuS.js as 9 KB of text/html. - -Warm reload: /theme-init.js is served from the SW (fromServiceWorker) despite Vercel's no-store, and so are /_vercel/insights/script.js and speed-insights. - -After one /app visit the runtime cache holds 18 entries / 1,318 KB, a second copy of what the immutable HTTP cache already has, and it is never pruned across deploys. - -Offline: reloading /app works from cache, but an unvisited route (/app/crossfire) shows the same generic 'Unable to load this page / Reload', not an offline message. - -Full loads do get a fresh shell: navigation is network-first. - -**Why it happens** - -Vercel only serves the current deployment's hashed files. The catch-all rewrite turns a request for an old chunk into the HTML shell, the /assets header rule marks it immutable, and the SW's cacheFirst accepts any 200. The client has no stale-chunk recovery. The SW is unversioned, so its cache-first copies of unhashed files never refresh. - -**Suggested fix** - -1. vercel.json: change the rewrite source to "/((?!assets/).*)" so missing assets return a real 404. -2. sw.js cacheFirst: cache only when response.ok and the content-type matches the destination (javascript/css/image/font). Never cacheFirst unhashed paths (/theme-init.js, /_vercel/*); use network-first or stale-while-revalidate for those. -3. Inject the build id into sw.js at build time (CACHE_VERSION = 'debate-lab-<sha>'), so activate deletes old runtime caches. -4. In main.tsx add window.addEventListener('vite:preloadError', ...) that reloads once (guard with sessionStorage), and make RouteErrorBoundary show an offline message when !navigator.onLine. - -**Who it hits** - -Every student with a tab open across a deploy (merges ship to production several times a week) hits an error page on their next new route and has to reload. Offline users get a misleading reload prompt. Fixes to theme-init.js never reach users who have the SW installed. - -**Independent check** - -curl of /assets/Nope-abc12345.js on staging returns HTTP 200, text/html, 'public, max-age=31536000, immutable' and the 8.7 KB shell; vercel.json:6-11 and :112-121. To simulate a deploy, I answered the LibraryPage chunk with exactly that response and tapped Library. Console showed the MIME-type error and 'Failed to fetch dynamically imported module'. The screen showed 'Unable to load this page / Reload'. The SW runtime cache then held /assets/LibraryPage-D1kN5AuS.js as 9 KB of text/html (sw.js:50-66 caches any response.ok). There are 0 vite:preloadError handlers in src. After reload, /theme-init.js and both /_vercel scripts came from the SW despite Vercel's no-store; CACHE_VERSION is constant at sw.js:1. Offline, tapping the unvisited Crossfire route showed the same generic error. Caveat: in my run the error also persisted after a reload, but that is a test artifact, because a real deploy references new hashes. The real impact is the error on the open tab with no automatic recovery. A Vercel rollback to a build whose chunk URL was poisoned would break that route for the user until the SW cache changes, which matters given the ~/ops/rollback path. - -**Evidence files** - -[sw.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/raw/sw.json), [sw-stale-chunk-after-deploy-desktop.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/raw/sw-stale-chunk-after-deploy-desktop.png), [sw-offline-unvisited-route-desktop.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/raw/sw-offline-unvisited-route-desktop.png), [sw-offline-reload-app-desktop.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/raw/sw-offline-reload-app-desktop.png), [sw.jsonl](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/sw.jsonl), [sw-stale-chunk-library.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/raw/sw-stale-chunk-library.png), [sw-offline-unvisited-route.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/raw/sw-offline-unvisited-route.png), [curl-missing-asset-headers.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/curl-missing-asset-headers.txt), [sw.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/scripts/sw.mjs) - - -### perf-runtime-7: First visit downloads 768 KB of images nobody sees: the SW precaches a 1024×1024 favicon twice (.png and .ico, 273 KB each) and the 222 KB og:image, right when the page's own chunks and data load - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `oversized-icons-precached` · **Known in plan:** next-15 #15 (add 192px, 512px and maskable manifest icons). The SW precache waste and the 1024² tab icon are not covered - -**Where** - -- public/sw.js:5-14 (APP_SHELL includes /favicon.ico, /favicon.png and /hero-visual.png) -- index.html:17 (<link rel=icon href=/favicon.png>, a 1024×1024 PNG) -- public/manifest.webmanifest (only icon is /favicon.png 1024x1024) -- index.html og:image / twitter:image (hero-visual.png is used only here) - -**What the reviewer saw** - -curl: favicon.png 279,465 B, favicon.ico 279 KB (same image), hero-visual.png 226,968 B. All three are served with max-age=0, must-revalidate. hero-visual.png is referenced only in og/twitter meta; no page renders it. - -The SW shell cache after a first /app visit: 8 entries / 791 KB (favicon.png 273 KB, favicon.ico 273 KB, hero-visual.png 222 KB). - -raw/swnet-mobile.json: the SW install fetches all 8 shell URLs at 3,047 ms on the mobile profile. That is the same moment the page starts its route chunk (3,215-3,357 ms) and Supabase requests (3,101 ms). - -Caveat: CDP throttling does not apply to SW fetches, so the measured 70 ms is unrealistic. At 1.6 Mbps these 737 KB take about 3.7 s of bandwidth, about twice the app's entry. - -Browsers also fetch the 279 KB favicon.png as the tab icon and revalidate it on each load. - -**Why it happens** - -The precache list was built from 'files that exist' rather than 'files the shell needs', and the logo was never resized for icon use. - -**Suggested fix** - -Drop /hero-visual.png, /favicon.ico and /favicon.png from APP_SHELL (keep '/', theme-init and favicon-72.png). Ship a 32×32/48×48 favicon (about 2 KB) plus 192/512/maskable manifest icons, and point <link rel=icon> at the small one. Optionally defer SW registration until after the first route's data has loaded. - -**Who it hits** - -Every first visit on every device, including students on metered mobile data. The download competes with the route chunk and API calls on slow connections. - -**Independent check** - -After a first /app visit the SW shell cache held 8 entries, 791 KB: favicon.png 273 KB, favicon.ico 273 KB, hero-visual.png 222 KB, from APP_SHELL at public/sw.js:5-14. The SW install fetched them at about 550-660 ms on desktop cold, at the same time as the HomePage chunk and the Supabase calls. curl: favicon.png and favicon.ico are 279,465 B each and hero-visual.png is 226,968 B, all 'max-age=0, must-revalidate'. hero-visual.png is used only in the og:image and twitter:image meta tags. CDP network throttling does not apply to SW fetches, so I could not measure the real slow-network contention. render-bundle-10 is the same root cause. - -**Evidence files** - -[swnet-mobile.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/raw/swnet-mobile.json), [sw.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/raw/sw.json), [sw.jsonl](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/sw.jsonl), [last-pro-desktop-_app.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/raw/last-pro-desktop-_app.json), [curl-icon-headers.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/curl-icon-headers.txt) - - -### perf-runtime-8: Tool pages are slow to open on phones: 250-350 ms to mount even when cached, and up to 0.9 s on first visit because of heavy statically imported chunks (recharts on the Sparring setup screen, reactflow on Generate for one enum) and 36-file fan-outs - -**Severity:** medium · **Verdict:** partly · **Root cause:** `route-chunks-eager-heavy-deps` - -**Where** - -- src/pages/SparringPage.tsx:12 (static import of SparringScorecard, which pulls recharts: generateCategoricalChart chunk 356 KB / 79 KB br, used only after judging, line 330) -- src/pages/GeneratePage.tsx:15 (import { MarkerType } from 'reactflow', 123 KB / 35 KB br for MarkerType.ArrowClosed at lines 287 and 316) -- src/components/landing/*.tsx (framer-motion, 118 KB / 35 KB br, needed before the landing page renders) -- src/pages/GeneratePage.tsx:26-27 and the other tool pages (hidden-Workspace WorkspaceToolScope + tool-context, ~4 KB, 2 extra requests per tool page) -- vite.config.ts (no experimentalMinChunkSize / manualChunks; 58 of 116 chunks are under 2 KB) - -**What the reviewer saw** - -Mobile tap navigation from Home tiles, CPU 4x, medians of 3 runs, click to heading visible. -- First visit: Generate 843 ms (36 chunk files + 4 Supabase calls), Sparring 494 ms, Attack 338, Ask 331, Brainstorm 316, Factoids 313, Library 274, Crossfire 249, History 247, Timer 237. -- Second visit, chunks cached, no network: Generate 352, Sparring 333, Attack 331, Factoids 286, Ask 276, Brainstorm 255, Library 229, History 191, Crossfire 149, Timer 148. -- Back to Home: about 135 ms. -- First-visit chunk downloads went through the SW unthrottled, so real 4G first visits are slower. The Generate closure is 123 KB br, Sparring 162 KB br (recharts is half of it). - -Desktop unthrottled, single run: first Generate 415 ms on tap, 162 ms with hover prefetch; everything else 30-170 ms. - -A CPU profile of a cached Home->Generate navigation at 4x (387 ms) puts ~243 ms in the entry chunk (React reconcile/commit) and 6 ms in GeneratePage's own code; the rest is native '(program)' time (style, layout, paint). The page subscribes to the whole session store (see perf-runtime-4). - -Touch devices get no prefetch before pointerdown: use-route-prefetch.ts schedules hover prefetch only for (hover: hover) and (pointer: fine). - -**Why it happens** - -Route chunks statically import code needed only in later states (the scorecard chart, flowchart enums) or only in hidden features (Workspace). Rollup splits shared modules into many tiny chunks. Mount cost on phones is dominated by React rendering large, whole-store-subscribed pages plus style and layout. That part is only partly attributed. - -**Suggested fix** - -1. lazy(() => import('@/components/features/sparring/SparringScorecard')) behind a Suspense boundary shown at judging time. -2. In GeneratePage, replace MarkerType.ArrowClosed with the string 'arrowclosed' and keep reactflow in the flowchart chunk. -3. Gate WorkspaceToolScope behind COLLABORATION_URL with a dynamic import. -4. Set build.rollupOptions.output.experimentalMinChunkSize ≈ 10_000 to merge the lucide icon micro-chunks. -5. On touch devices, prefetch the Home tool chunks at idle after first paint (requestIdleCallback -> preloadRoute for the 8 tool paths). -6. Profile GeneratePage, SparringPage and AttackPage mounts with the React Profiler on a production build, and add selectors to useSessionStore. - -**Who it hits** - -Students on phones feel a quarter to a third of a second of lag on every tool tap, and close to a second on the first Generate or Sparring visit. Desktop is fine. - -**Independent check** - -Structure confirmed from the bundle graph. GeneratePage's static closure is 40 files, 412 KB min / 124 KB br, including @reactflow/core at 123 KB. SparringPage's is 637 KB / 163 KB br, including the recharts chunk at 356 KB. Every tool page pulls the WorkspaceToolScope and tool-context chunks. use-route-prefetch.ts:81 skips prefetch on touch devices. Mobile tap timing at CPU 4x, Jam blocked, 3 sessions, load 1.2-2.4, medians: Generate first visit 769 ms with 36 script files; Sparring 451; Attack 241; Library 289. Cached visits: Generate 245, Attack 191, Sparring 163, Library 126; back to Home 42-89 ms. The first-visit Generate cost reproduces. 'Cached mounts take 250-350 ms' does not: I measured 125-245 ms, possibly because Jam was blocked in my runs. Chunk fetches were unthrottled. The reactflow and recharts parts are the same fixes as render-bundle-7 and render-bundle-8; this entry adds the touch prefetch and micro-chunk items. - -**Evidence files** - -[nav.jsonl](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/nav.jsonl), [prof-nav-generate.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/raw/prof-nav-generate.json), [bundle-graph-local-build.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/bundle/bundle-graph-local-build.json), [nav.jsonl](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/nav.jsonl), [bundle-analysis-local-build.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/bundle-analysis-local-build.json), [nav.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/scripts/nav.mjs) - - -### render-bundle-3: session-store writes every saved case, chat and full source text to localStorage on every update and crashes once over quota - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `persist-whole-store-every-set` - -**Where** - -- src/lib/stores/session-store.ts:453 (persist) -- src/lib/stores/session-store.ts:1263-1291 (localStorage storage, partialize keeps savedCases/savedAttacks/savedBrainstorms/savedFactoids/savedChats/savedFlowcharts/sourceLibrary/results) -- src/lib/stores/session-store.ts:490 (setGenerateForm) -- src/components/features/generate/GenerateForm.tsx:79-84 (watch -> setGenerateForm per keystroke; same pattern in AttackForm, BrainstormForm, FactoidsForm) -- src/lib/stores/session-store.ts:499-552 (addSource: server insert, then set()) -- src/pages/LibraryPage.tsx:223-226 and 296-307 -- src/lib/stores/sparring-store.ts:158 and 529 (already uses a debounced storage) -- src/lib/source-file-import.ts:4 (MAX_EXTRACTED_CHARS 120,000) - -**What the reviewer saw** - -Write volume: -- Each keystroke in the Generate, Attack, Brainstorm or Factoids form writes 'debate-trainer-storage' once: 17 writes for 17 keys (keys-*.json). -- Each write was 20.8 KB early in the session and 92.7 KB later, after the shared pro account had collected more items. At 4x CPU, 10 keystrokes serialized 927 KB and spent 23.5 ms in setItem alone. -- One Ask stream wrote it 101 times (render-bundle-1). - -Quota test (dev build, local only, writes blocked): adding 45 sources at the 120,000-character file-import limit made setState throw "QuotaExceededError: Setting the value of 'debate-trainer-storage' exceeded the quota". After that, each keystroke in Generate raised an uncaught page error (3 keys, 3 QuotaExceededError page errors), and the persisted draft stayed at its old value. - -zustand 5.0.15's persist calls storage.setItem on every set() with no try/catch, so the in-memory update happens and then the caller throws. addSource inserts the row on the server before calling set(). Over quota, handleAddSource throws before it clears the form or shows the success toast, leaving an unhandled rejection with the draft still filled in, so a second click inserts a duplicate source. - -**Why it happens** - -partialize persists almost the whole store, including every server-owned list with its full text, through synchronous localStorage writes on every set(). Form drafts update the store on every keystroke. The migrate comment at line 1268 already notes that server hydration replaces these lists on load. - -**Suggested fix** - -- Persist only what must survive a reload: form drafts, the open Ask conversation, UI flags. Drop savedCases, savedAttacks, savedBrainstorms, savedFactoids, savedChats, savedFlowcharts, sourceLibrary and results from partialize, since hydrateFromServer replaces them anyway. If an offline view is wanted, move those lists to IndexedDB. -- Use a debounced storage wrapped in try/catch, like sparring-store's debouncedSessionStorage. -- Debounce setGenerateForm and its siblings, or persist drafts on blur. -- Make addSource and similar actions tolerate a failed persist once the server write has succeeded. - -**Who it hits** - -Every user pays a full-store serialization on each keystroke and each stream flush, and the cost grows with their history. Heavy Library users (large PDF or DOCX imports) hit the quota: drafts stop saving, errors fire on every keystroke, and saving sources looks like it failed, which invites duplicates. - -**Independent check** - -This is the primary entry for the persist cause; perf-runtime-4 is folded in. Write volume: 1 write of 'debate-trainer-storage' per keystroke, now 188 KB each on the shared pro account (41 keys gave 7.7 MB). One Ask stream gave 114-119 writes. Quota: I filled localStorage with junk in my own browser context only, then typed into Generate's motion field. That produced an uncaught 'QuotaExceededError: Setting the value of debate-trainer-storage exceeded the quota' page error. The field showed '... abc' while the persisted draft stopped at '... ab'. I got 1 error for 4 keys rather than 3 for 3, because the slack left in storage absorbed the first characters. zustand persist has no try/catch (node_modules/zustand/esm/middleware.mjs:300, 358-368). The duplicate-source path is code-confirmed only and was not executed: session-store.ts:519-533 inserts the row on the server before set() at :549, and LibraryPage.tsx:217-227 and :296-307 have no try/catch, so the form stays filled and there is no toast. The junk was removed and the context destroyed. - -**Evidence files** - -[quota-localstorage.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/quota-localstorage.json), [keys-generate.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/keys-generate.json), [keys-attack.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/keys-attack.json), [prod-build-cpu4x-measurements.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/prod-build-cpu4x-measurements.json), [quota-generate-typing.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/raw/quota-generate-typing.png), [quota.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/scripts/quota.mjs), [typing.jsonl](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/typing.jsonl), [quota-generate-typing-desktop.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/raw/quota-generate-typing-desktop.png), [ask-stream.jsonl](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/ask-stream.jsonl), [typing.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/scripts/typing.mjs) - - -### render-bundle-4: Tool pages re-render top to bottom on every keystroke (whole-store subscriptions, watch() on the whole form, no memo boundaries) - -**Severity:** medium · **Verdict:** partly · **Root cause:** `whole-store-subscriptions` - -**Where** - -- src/pages/GeneratePage.tsx:35 and src/components/features/generate/GenerateForm.tsx:57, :108 (watch()) -- src/pages/AttackPage.tsx:26-27, src/components/features/attack/AttackForm.tsx:37, :71 -- src/pages/BrainstormPage.tsx:24-26, src/components/features/brainstorm/BrainstormForm.tsx:36, :59 -- src/pages/FactoidsPage.tsx:24-25, src/components/features/factoids/FactoidsForm.tsx:37, :61 -- src/pages/LibraryPage.tsx:160-172 (whole store) and :50 / :882-890 (SourceRow not memoized, inline props) -- src/pages/FlowchartPage.tsx:261-267 and :756-758, :765 (inline onNodeClick/onNodeDoubleClick/onPaneClick/nodeColor) -- src/pages/HistoryPage.tsx:35, src/components/features/debate/debate-items.ts:88, src/components/ModelSelector.tsx:40, src/components/LanguageSwitcher.tsx:12, src/components/ThemeToggle.tsx:7 -- src/pages/CrossfirePage.tsx:421-425 (setNow every 500 ms re-renders the page during a round; read in code only) -- src/components/ui/select.tsx (Radix Select renders every item even when closed) - -**What the reviewer saw** - -Committed components per keystroke, dev build pinned to staging, pro account: -- Generate: 708 (1 commit per key, about 25 ms of React render per key in dev). -- Factoids: 487. Attack: 486. Brainstorm: 464. -- Flowchart add-node label: 417, and all 6 CustomNode/NodeWrapper re-render on every key. -- Library add-source content: 386, with every SourceRow re-rendering. -- Ask input: 342. Library search: 250. -- Crossfire: 75, but 2 commits per key. - -On Generate, each keystroke re-renders GeneratePage, GenerateForm, ModelSelector, DebateContextControl, CurrentDebateStrip, 3 closed modals (LimitReached, ModelRestricted, Pricing) and 5 OptionSelects with all 30 SelectItems. - -Production build, 4x CPU: Generate's median key event was 72 ms (max 88 ms; 47 events over 16 ms for 10 keys). - -Route mount: navigating to Generate costs 7 commits and 2,605 component renders (about 97 ms of dev render). 4 of those commits come from Radix Select's mount sequence, each re-rendering every select item. Ask, Sparring and Library show the same pattern. - -**Why it happens** - -- 13 components call useSessionStore() or useSettingsStore() with no selector, so any store change re-renders them. -- The forms call watch() on the whole form, which re-renders on every field change, and push each keystroke into the store, which re-renders the page as well. -- Pages own transient input state (LibraryPage is over 1,000 lines; FlowchartPage holds the dialog state) with no memoized children. -- FlowchartPage passes new handler functions to ReactFlow on every render, so every node wrapper re-renders. -- Radix Select mounts all of its items even when closed. - -**Suggested fix** - -- Use selector subscriptions everywhere: useSessionStore((s) => s.generateForm), or useShallow for several fields. -- In the forms, replace watch() with useWatch on the fields actually displayed, and debounce store persistence. -- Move the add-source form, the Flowchart add/edit dialog and the Ask composer into child components that own their own state. -- Wrap SourceRow and the flowchart CustomNode in React.memo, and wrap FlowchartPage's ReactFlow handlers and MiniMap nodeColor in useCallback. -- Use a native select (or a lighter list) for the five simple option pickers on Generate. -- Isolate Crossfire's 500 ms clock in a small timer component. - -**Who it hits** - -Typing feels heavy on phones in every core tool (Generate, Attack, Brainstorm, Factoids, Library, Flowchart, Ask), and the cost grows with list sizes (sources, flowchart nodes). - -**Independent check** - -Code confirmed: whole-store useSessionStore() or useSettingsStore() calls in GeneratePage:35/41, GenerateForm:57, AttackPage:26-27, AttackForm:37, BrainstormPage:24-26, BrainstormForm:36, FactoidsPage:24-25, FactoidsForm:37, LibraryPage:172, FlowchartPage:267, HistoryPage:35, debate-items.ts:88, ModelSelector:40, LanguageSwitcher:12, ThemeToggle:7, AskPage:42/44, CrossfirePage:346 and SettingsPage:45-46. The forms call watch() on the whole form (GenerateForm:108 etc.). CrossfirePage:421-426 ticks setNow every 500 ms. Measured in production at CPU 4x, Generate keydown median 72 ms vs 40 ms for the local-state Ask input, but that figure mixes re-render cost with the persist cost from render-bundle-3; I did not separate them. Not verified: the dev-build component counts (708 per key and so on), the Flowchart and Library row figures, and the claim that Radix Select renders all items while closed. - -**Evidence files** - -[keys-generate.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/keys-generate.json), [keys-attack.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/keys-attack.json), [keys-brainstorm.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/keys-brainstorm.json), [keys-factoids.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/keys-factoids.json), [keys-ask.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/keys-ask.json), [keys-crossfire.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/keys-crossfire.json), [keys-library-content.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/keys-library-content.json), [keys-library-search.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/keys-library-search.json), [keys-flowchart-label.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/keys-flowchart-label.json), [nav-renders.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/nav-renders.json), [prod-build-cpu4x-measurements.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/prod-build-cpu4x-measurements.json), [rc.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/scripts/rc.mjs), [typing.jsonl](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/typing.jsonl) - - -### render-bundle-5: All three locale catalogs (en, es, hu) are inlined into the entry chunk - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `oversized-entry-chunk` · **Same fix as:** perf-runtime-1 - -**Where** - -- src/i18n.ts:3-5 and :9 (static JSON imports bundled as resources) -- staging /assets/index-B7gkXSCc.js - -**What the reviewer saw** - -In the local build of 40738ea, the entry chunk index-BlnKlHj_.js is 1,201 KB minified and 371 KB gzip (staging's entry is 367.7 KB transferred). - -Rollup's rendered sizes for the locale modules: es.json 101.8 KB, hu.json 100.3 KB, en.json 92.4 KB. Each catalog minified and compressed: -- en: 88.0 KB min, 28.0 KB gzip, 23.6 KB brotli. -- es: 97.1 KB min, 31.1 KB gzip, 26.8 KB brotli. -- hu: 95.6 KB min, 32.9 KB gzip, 28.7 KB brotli. - -source-map-explorer attributes 324 KB of the entry to i18next.js (about 40 KB of library plus the inlined catalogs). Fifty long sample strings from each language were all found in both my entry and staging's entry. Every visitor downloads two languages they never see, about 50 to 55 KB brotli or 15% of the entry. Landing LCP on slow 4G with 4x CPU is 12.7 to 13.7 s, bound by the entry download. - -**Why it happens** - -i18n.ts imports every catalog statically to make init synchronous. - -**Suggested fix** - -Keep en (the fallback) bundled and load es and hu through import() (i18next-resources-to-backend or a small loader) inside applyAppLanguage before changeLanguage. Pick the initial language from the persisted setting or navigator before mount so there is no flash of English. Add an entry-size budget to scripts/check-build-boundaries.mjs so the catalogs cannot creep back in. Optionally split out the landing/public keys so the landing page doesn't need the app's 2,156 keys. - -**Who it hits** - -Every first visit, every page, every language. It weighs most on the landing page and on slow mobile networks. - -**Independent check** - -Sub-fix of perf-runtime-1: lazy-load the es and hu catalogs. src/i18n.ts:3-10 imports all three statically. 50 of 50 sampled strings per language are in staging's entry. Minified catalogs: en 90,075 B, es 99,430 B, hu 97,925 B. My local build's entry is 1,201 KB min / 371 KB gzip, matching the reviewer. I did not measure the slow-4G landing LCP of 12.7-13.7 s. - -**Evidence files** - -[entry-chunk-composition.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/entry-chunk-composition.json), [entry-import-chains.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/entry-import-chains.txt), [build-sizes-excerpt.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/build-sizes-excerpt.txt), [fonts-render-blocking.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/fonts-render-blocking.json), [source-map-explorer-local-entry.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/source-map-explorer-local-entry.json), [bundle-analysis-local-build.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/bundle-analysis-local-build.json) - - -### render-bundle-6: Entry chunk ships signed-in-only code (stores, debate UI, zod, unused Supabase realtime/storage, Capacitor) to landing and login visitors - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `oversized-entry-chunk` · **Same fix as:** perf-runtime-1 - -**Where** - -- src/App.tsx:8-9 (AppLayout, AuthProvider static) -- src/lib/supabase/AuthProvider.tsx:5-12 (session-store, sparring-store, debate stores, crossfire storage) -- src/components/layout/AppLayout.tsx:22-28 (CurrentDebateMenu, DebateDialogs, announcements) -- src/lib/announcements.ts:2 (zod in entry) -- src/lib/stores/sparring-store.ts (zod schemas, voice delivery) -- src/lib/supabase/client.ts (createClient bundles RealtimeClient and StorageClient) -- src/main.tsx:7-10 (native runtime, Capacitor) - -**What the reviewer saw** - -source-map-explorer on the entry (1,173 KB minified): -- zod: 88.5 KB. -- Stores: 45.0 KB (session-store.ts alone 22.8 KB). -- Debate UI (DebateDialogs, DebateForm, CurrentDebateStrip, CurrentDebateMenu): 12.3 KB. -- Sparring, crossfire and voice libs: 9.7 KB. -- Capacitor core and plugin definitions plus native runtime: 12.5 KB. -- Supabase realtime-js + phoenix + storage-js + iceberg-js: about 84 KB of the 212.6 KB Supabase total. - -Realtime and storage are used nowhere outside the hidden Workspace: grep for .channel(, realtime and storage.from in src found nothing. - -Import chains: -- index.html -> main.tsx -> App.tsx -> AppLayout.tsx -> announcements.ts -> zod. -- App.tsx -> AuthProvider.tsx -> sparring-store.ts -> ai/schemas/sparring.ts -> voice/delivery.ts. -- main.tsx -> lib/native.ts -> @capacitor/core. - -That is roughly 250 KB minified the signed-out landing and login pages never run. Landing LCP on slow 4G with 4x CPU: 12,720 / 13,672 / 13,640 ms. The h1 waits for this entry plus LandingPage and the framer chunk. - -**Why it happens** - -The protected-app shell (AppLayout and its dialogs) and store hydration are imported statically from App.tsx and AuthProvider, so the public routes inherit them. Zod's classic API doesn't tree-shake. Supabase's createClient always constructs realtime and storage. The native runtime is imported even on the web. - -**Suggested fix** - -- Make AppLayout lazy, or move RequireAuth, AppLayout and the debate dialogs into one lazy app-shell chunk. -- Have AuthProvider import the store hydrators dynamically after a session exists, or move hydration into the app shell. -- Parse announcements without zod, or lazily. -- Import native-runtime and native-app-url-listener only when isNativeApp() is true. -- Optionally build the client from @supabase/auth-js, postgrest-js and functions-js, without realtime and storage. -- Extend check-build-boundaries.mjs to forbid src/lib/stores/session-store.ts, zod and @supabase/realtime-js in the LandingPage and LoginPage static graphs, and add an entry byte budget. - -**Who it hits** - -Every signed-out visitor (landing, login, shared links), especially on mobile networks. It also delays first paint of the app shell for signed-in users. - -**Independent check** - -Sub-fix of perf-runtime-1: a lazy app shell and store hydrators, zod out of the entry, no realtime or storage, and Capacitor only on native. Confirmed: zod 88.5 KB, stores 45 KB, debate UI 12.3 KB, realtime-js 32 + phoenix 25 + storage-js 22 + iceberg 5 KB, @capacitor/core 8 KB plus plugin definitions, all in the entry. announcements.ts:2 and sparring-store.ts:3 import zod. client.ts uses createClient from @supabase/supabase-js. Grepping src outside the Workspace finds no .channel(, realtime or storage.from. native-runtime.ts and native.ts are statically imported from main.tsx:6-10. Landing LCP on slow 4G was not re-measured. - -**Evidence files** - -[entry-chunk-composition.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/entry-chunk-composition.json), [entry-import-chains.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/entry-import-chains.txt), [bundle-largest-chunks.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/bundle-largest-chunks.txt), [fonts-render-blocking.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/fonts-render-blocking.json), [source-map-explorer-local-entry.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/source-map-explorer-local-entry.json), [bundle-analysis-local-build.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/bundle-analysis-local-build.json) - - -### render-bundle-7: Generate downloads reactflow + d3 (41 KB transferred) to read one string constant, and the boundary guard meant to stop this misses @reactflow/* - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `generate-imports-reactflow-enum` - -**Where** - -- src/pages/GeneratePage.tsx:15 (import { MarkerType, ... } from 'reactflow'; used at :287, :316, :349) -- src/lib/stores/session-store.ts:3 (type-only, fine) -- scripts/check-build-boundaries.mjs:24 (forbiddenFeature regex lists 'reactflow' but the code lives in node_modules/@reactflow/core) - -**What the reviewer saw** - -GeneratePage's chunk statically imports index-BQ_dqVwH.js (126.17 KB minified, 40.51 KB gzip; @reactflow/core 167 KB rendered plus d3-selection, d3-transition, d3-zoom and d3-drag). Chain: GeneratePage.tsx -> reactflow/dist/esm/index.mjs -> @reactflow/core. - -On staging, /app/generate loads /assets/index-D92CwHPC.js at 41.4 KB, and its body contains react-flow__* class names. MarkerType.ArrowClosed is the string 'arrowclosed'. Because FlowchartPage shares the module, Rollup puts all of reactflow core in a shared chunk that Generate loads up front. - -Testing the forbiddenFeature regex against the bundle graph: it matches no @reactflow/core module, so npm run build passes even though GeneratePage, one of its checked initial routes, pulls reactflow. - -**Why it happens** - -A value import of an enum from the reactflow barrel, combined with a regex that only names the barrel package and not the @reactflow/* packages that hold the code. - -**Suggested fix** - -In GeneratePage.tsx, use import type { Node, Edge } from 'reactflow' and the literal markerEnd: { type: 'arrowclosed' as MarkerType } (or a local const). Change the regex in check-build-boundaries.mjs to (?:@reactflow\/[^/]+|reactflow)\/ and add d3-zoom. The same file should also catch react-markdown or framer-motion where those are not wanted. - -**Who it hits** - -Every Generate visit, Generate being one of the most used tools: about 41 KB brotli plus parse and compile, for nothing. The broken guard would let other regressions through too. - -**Independent check** - -GeneratePage.tsx:15 has a value import of MarkerType, used at :287, :316 and :349. My local build's GeneratePage closure includes index-BQ_dqVwH.js (126 KB min, 40.5 KB gzip, @reactflow/core plus d3). Applying scripts/check-build-boundaries.mjs:24's forbiddenFeature regex to the Generate closure's 661 modules gives 0 matches even though @reactflow/core is present, so the guard is blind. GeneratePage is one of its checked initial routes (:29). On staging, the first Generate visit loads 36 script files (nav.jsonl). - -**Evidence files** - -[entry-import-chains.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/entry-import-chains.txt), [load-nojam-_app_generate-pro-mobile.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/load-nojam-_app_generate-pro-mobile.json), [bundle-largest-chunks.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/bundle-largest-chunks.txt), [bundle-analysis-local-build.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/bundle-analysis-local-build.json), [bundle-graph-local-build-40738ea.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/bundle-graph-local-build-40738ea.json), [nav.jsonl](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/nav.jsonl) - - -### render-bundle-8: Sparring loads recharts (95 KB transferred) before a round starts; only the end-of-round radar chart needs it - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `sparring-scorecard-eager-recharts` - -**Where** - -- src/pages/SparringPage.tsx:12 (static import of SparringScorecard) -- src/components/features/sparring/SparringScorecard.tsx:4 (recharts RadarChart) -- src/pages/SparringPage.tsx:330 - -**What the reviewer saw** - -In the local build, SparringPage statically imports generateCategoricalChart-*.js (364.63 KB minified, 99.02 KB gzip: recharts, lodash, decimal.js-light, react-smooth, d3-scale). Chain: SparringPage.tsx -> SparringScorecard.tsx -> recharts. - -On staging, /app/sparring loads /assets/generateCategoricalChart-BkyWxGBS.js at 95.3 KB, the largest route-level download after the entry. It also loads markdown-*.js (37.8 KB) before any speech exists. The scorecard renders only when session.scorecard exists, after a completed round. - -**Why it happens** - -The scorecard, and recharts with it, is imported eagerly into the Sparring route chunk. - -**Suggested fix** - -Load SparringScorecard with React.lazy, and prefetch it when the last speech is submitted so judging stays instant. Or draw the 5-axis radar as a small hand-written SVG and drop recharts from Sparring entirely; AdminPage would then be recharts' only user. - -**Who it hits** - -Every Sparring visit, mostly on phones, pays about 95 KB brotli plus parse before the setup screen is needed. - -**Independent check** - -SparringPage.tsx:12 statically imports SparringScorecard, which imports recharts at line 4; the scorecard renders only at :330. SparringPage's static closure includes generateCategoricalChart-*.js at 365 KB min / 99 KB gzip, plus the markdown chunk at 116 KB. Total closure is 637 KB min / 163 KB br. The first Sparring visit takes median 451 ms on mobile at CPU 4x. - -**Evidence files** - -[load-nojam-_app_sparring-pro-mobile.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/load-nojam-_app_sparring-pro-mobile.json), [entry-import-chains.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/entry-import-chains.txt), [bundle-largest-chunks.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/bundle-largest-chunks.txt), [bundle-analysis-local-build.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/bundle-analysis-local-build.json), [nav.jsonl](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/nav.jsonl) - - -### perf-runtime-9: Render-blocking Google Fonts @import chain (three families, two extra origins) delays first paint; Lora is downloaded as CSS on every load but never used - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `google-fonts-css-import` - -**Where** - -- src/index.css:1-3 (@import of Inter, Lora and Space Mono from fonts.googleapis.com) -- tailwind.config.ts:80-113 (font-serif = Lora, but no font-serif class exists in src) -- index.html (no preconnect to fonts.googleapis.com or fonts.gstatic.com) - -**What the reviewer saw** - -Mobile cold /app waterfall: index CSS 248-597 ms, then fonts CSS (Inter, Lora, Space Mono) 603-805 ms, which the CSS @import blocks on. FCP of the startup card is 860 ms (median 3), roughly 250 ms later than the CSS alone would allow. The Inter woff2 (47.8 KB) is requested only at 3,028 ms and lands at 3,565 ms, so text renders in the fallback font and then swaps (a small '#text 177->176' shift is recorded). - -The built CSS has no font-family:Lora rule; only the @import and an unused CSS variable remain. document.fonts reports Lora 400-700 'unloaded' on every route measured. Space Mono is used only on Timer, Sparring, Crossfire and Admin. - -**Why it happens** - -Fonts are pulled with CSS @import from a third-party origin. @import serialises behind the main stylesheet and blocks rendering, and the serif family was configured but never adopted. - -**Suggested fix** - -1. Delete the Lora @import and the serif fontFamily entry. -2. Self-host Inter (variable woff2, latin + latin-ext for Hungarian) under /assets with font-display: swap and <link rel=preload as=font crossorigin>, plus a size-adjusted fallback (@font-face with size-adjust) to avoid the swap shift. -3. Load Space Mono only from the pages that use it, or swap it for ui-monospace. - -**Who it hits** - -Every load. The loading card and first text appear later on slow networks, and two third-party origins need DNS, TCP and TLS on cold visits. - -**Independent check** - -The staging CSS index-BMQPADaz.css starts with three @import rules for Inter, Lora and Space Mono. Mobile CPU 4x + fast4g: the app CSS loads 321-666 ms and the font CSS requests start only after it finishes (667-842 ms). Two first-visit runs show the same chain. 'Lora' appears only in the @import and the unused --font-serif variable; there is no font-serif class in src (tailwind.config.ts:94). I did not quantify the FCP penalty. The three imports run in parallel, so Lora adds a wasted request but no extra serial round trip. render-bundle-9 is the same finding. - -**Evidence files** - -[matrix.jsonl](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/matrix.jsonl), [matrix-medians.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/matrix-medians.json), [last-pro-mobile-_app-cpu4-fast4g.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/raw/last-pro-mobile-_app-cpu4-fast4g.json), [loads.jsonl](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/loads.jsonl) - - -### perf-runtime-10: Settings page jumps 52 px on every load: the subscription card renders one 'Loading…' line, then grows after three sequential queries - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `settings-subscription-card-no-skeleton` - -**Where** - -- src/pages/SettingsPage.tsx:200-235 (subscription.loading ? one line : badge + button + description) -- src/hooks/use-subscription.ts (user_settings -> subscriptions -> ai_requests, awaited one after another) - -**What the reviewer saw** - -Layout shift on every /app/settings load, warm or cold. Sources: 'Global Settings' and 'Account' cards move from y 351 to 403 on desktop and from 396 to 448 on mobile. -- Desktop CLS: 0.0161 warm, 0.0163 cold. -- Mobile CLS: 0.031 warm and cold. -- Single runs per profile and phase, but the shift repeated in all 4. - -The sequential chain on tool and settings pages: subscriptions 1,014-1,060 ms, then pins 1,064-1,105, then ai_requests 1,092-1,151 (desktop cold /app/sparring). - -**Why it happens** - -No reserved space while the subscription query is pending, and the query is a chain of three awaits. - -**Suggested fix** - -Render a skeleton with the final card height (badge + button + one text line) while loading, and parallelise the queries in use-subscription.ts (see perf-runtime-5). - -**Who it hits** - -Every visit to Settings. Small visually, but a visible jump under the user's eyes, which is exactly the kind of glitch Adam asked about. - -**Independent check** - -/app/settings desktop cold, 3/3 runs: a 0.0161-0.0163 layout shift in which the Global Settings and Account cards move from y 351 to 403 and from 553 to 605 (52 px) once the plan badge renders (SettingsPage.tsx:200-235). The sequential user_settings, subscriptions, ai_requests chain in use-subscription.ts is reproduced: 614-731, then 750-804, then 823-866 ms. Mobile was not re-measured. - -**Evidence files** - -[matrix.jsonl](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/matrix.jsonl), [last-pro-desktop-_app_settings.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/raw/last-pro-desktop-_app_settings.json) - - -### perf-runtime-11: Landing page LCP is held back about 0.8 s by the hero heading's opacity-0 entrance animation - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `hero-h1-opacity-entrance` - -**Where** - -- src/components/landing/Hero.tsx:47-50 (motion.h1 initial opacity 0, y 20, duration 0.6, delay 0.1) - -**What the reviewer saw** - -Medians of 3, signed out: -- Desktop warm: h1 in the DOM and laid out at 264 ms, LCP (the same h1) 1,124 ms. -- Desktop cold: content 652 ms, LCP 1,464 ms. -- Mobile warm: 1,149 vs 1,892 ms. -- Mobile cold: 5,114 vs 5,884 ms. - -Chrome excludes opacity-0 elements from LCP until they become visible, so the 100 ms delay plus the 600 ms fade is added to every landing LCP. - -**Why it happens** - -The entrance animation starts the LCP element fully transparent. - -**Suggested fix** - -Keep the h1 opaque: animate only transform (initial={{ y: 12 }}) or start at opacity 0.99. Alternatively, drop the h1 from the stagger and animate the subtitle and CTA only. - -**Who it hits** - -SEO and Core Web Vitals for the landing page, the first page new students and teachers see. Field LCP would improve by about 0.7-0.8 s at no cost. - -**Independent check** - -Hero.tsx:47-50 starts the h1 at opacity 0 with a 0.1 s delay and a 0.6 s fade. Signed-out mobile, h1 laid out vs LCP (the same h1): unthrottled 493/680/632 ms vs 1,344/1,404/1,380 ms. CPU 4x: 1,978/2,659/1,679 ms vs 2,840/3,504/2,472 ms. In every run, LCP lands about 720-860 ms after the h1 is laid out. - -**Evidence files** - -[matrix-medians.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/matrix-medians.json), [last-null-mobile-_.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/raw/last-null-mobile-_.json), [last-null-mobile-_-cpu4.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/raw/last-null-mobile-_-cpu4.json) - - -### perf-runtime-12: If production sets VITE_SENTRY_DSN and VITE_POSTHOG_KEY, every visit also downloads ~208 KB br (Sentry 471 KB with Replay and Feedback, PostHog 269 KB), and Sentry Replay buffers the DOM for every session - -**Severity:** low · **Verdict:** partly · **Root cause:** `observability-sdks-full-bundles` - -**Where** - -- src/lib/observability.ts:146-172 (Sentry.init with browserTracingIntegration + replayIntegration, replaysOnErrorSampleRate 1.0) -- src/lib/observability.ts:174-189 (posthog-js init) -- src/main.tsx:12-19, 36-40 (loaded on load + idle for every web visitor) - -**What the reviewer saw** - -Staging has no DSN or key, so this was not measured live. A local build of 40738ea with dummy values emits: -- assets/index-*.js 471 KB / 132 KB br (@sentry/browser, replay, replay-canvas, feedback, react); -- module-*.js 269 KB / 76 KB br (posthog-js). - -The code requests Replay with replaysOnErrorSampleRate 1.0, which keeps the rrweb recorder buffering in every session. In the worst case this stacks with Jam (perf-runtime-2): two session recorders plus a bug-capture iframe after every load. - -docs/operations.md shows production uploads Sentry source maps, which suggests the DSN is set; whether the PostHog key is set is unknown. - -**Why it happens** - -Full-featured SDK bundles are loaded for everyone, including integrations that may be unused (Feedback, Replay canvas), with no sampling at the load step. - -**Suggested fix** - -1. Confirm which keys production sets. -2. Import from @sentry/browser with explicit integrations instead of @sentry/react's defaults, or lazy-load replayIntegration only when an error happens (Sentry supports adding Replay after init). -3. Drop Feedback and Replay-canvas. -4. Load PostHog only on the first captureProductEvent. - -**Who it hits** - -Production visitors only, after load. It adds data use and main-thread work on phones in the first seconds of interaction. - -**Independent check** - -Code confirmed: observability.ts:156-168 sets browserTracingIntegration plus replayIntegration with replaysSessionSampleRate 0 and replaysOnErrorSampleRate 1.0, which runs Replay in buffer mode in every session. PostHog loads at :176-192. My build of 40738ea with a dummy VITE_SENTRY_DSN and VITE_POSTHOG_KEY emitted a Sentry chunk of 471 KB min / 132 KB br (core, browser, replay, replay-canvas, feedback, react) and a posthog-js chunk of 269 KB / 76 KB br. Whether production sets these variables cannot be checked from here: staging has neither and docs/development.md only names them. The impact therefore stays conditional. - -**Evidence files** - -[bundle-graph-with-dummy-sentry-dsn.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/perf-runtime/bundle/bundle-graph-with-dummy-sentry-dsn.json), [bundle-graph-dummy-sentry-posthog.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/bundle-graph-dummy-sentry-posthog.json) - - -### render-bundle-9: Google Fonts come in through chained, render-blocking CSS @import, and the Lora family is imported but never used - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `google-fonts-css-import` · **Same fix as:** perf-runtime-9 - -**Where** - -- src/index.css:1-3 (@import Inter, Lora, Space Mono) -- src/index.css:57 (--font-serif Lora) -- tailwind.config.ts:94 (serif family, no font-serif usage anywhere in src) - -**What the reviewer saw** - -The built CSS starts with three @import url(fonts.googleapis.com/...) rules. Staging, / signed out, Pixel 7, slow 4G, 4x CPU: index.css loads 526 to 1,696 ms, and the three font CSS requests start only after it finishes (1,703 to 2,283 ms). All are renderBlockingStatus 'blocking'. FCP is 2,320 ms (3 runs: 2,340 / 2,320 / 2,304). - -With the font CSS answered instantly, FCP median was 2,176 ms (2,176 / 2,012, plus one noisy 4,428 run), roughly a 150 ms penalty. Lora's CSS is the largest of the three at 18.2 KB (Inter 9.8 KB, Space Mono 2.5 KB). No font-serif class appears anywhere in src (0 usages), and the built CSS never uses the Lora font-family. Space Mono is used only by the Timer, Crossfire and Sparring setup digits. - -**Why it happens** - -Fonts are loaded with CSS @import, which the browser discovers only after the app CSS downloads, and an unused family was left in. - -**Suggested fix** - -Delete the Lora import and the serif token. Load Inter from index.html with <link rel=preconnect> and a stylesheet link, or better, self-host the Inter woff2 files with <link rel=preload> and font-display swap. Load Space Mono only where it is used, or use a system monospace with tabular-nums. - -**Who it hits** - -Every first visit pays an extra render-blocking round trip to fonts.googleapis.com plus 18 KB of unused CSS. It is noticeable on slow mobile networks. - -**Independent check** - -Same as perf-runtime-9. The built CSS has three @import rules, and the font CSS requests start only after the app CSS finishes. 'Lora' appears only in the @import and the unused --font-serif variable. I did not reproduce the roughly 150 ms FCP delta. - -**Evidence files** - -[fonts-render-blocking.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/fonts-render-blocking.json), [fonts.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/scripts/fonts.mjs), [last-pro-mobile-_app-cpu4-fast4g.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/raw/last-pro-mobile-_app-cpu4-fast4g.json) - - -### render-bundle-10: Tab icon is a 1024x1024, 279 KB PNG (favicon.ico is the same 279 KB PNG) - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `oversized-icons-precached` · **Same fix as:** perf-runtime-7 · **Known in plan:** next-15 #15 (manifest icons; the oversized tab icon is not mentioned there) - -**Where** - -- index.html:17 (<link rel="icon" href="/favicon.png">) -- public/favicon.png (1024x1024, 279 KB) -- public/favicon.ico (279 KB PNG-in-ICO) -- public/manifest.webmanifest (only icon is the 1024 PNG) - -**What the reviewer saw** - -PNG headers show public/favicon.png is 1024x1024 and 285,696 bytes on disk. public/favicon.ico is the same PNG payload in an ICO wrapper. index.html links the PNG as the tab icon, so a real browser fetches 279 KB on first visit to draw a 16 to 32 px icon. Headless Chromium skips favicons, so this comes from the files and markup, not a measurement. A 72 px icon (favicon-72.png, 4 KB) already exists and is used in the navbars. - -**Why it happens** - -The source artwork is used directly as the favicon and the manifest icon. - -**Suggested fix** - -Generate 32 and 48 px favicons (or an SVG icon), a 180 px apple-touch-icon, and 192, 512 and maskable manifest icons. Point index.html at the small icon. Shrink favicon.ico to a real multi-size ICO of 32 px and below. - -**Who it hits** - -First visit for every visitor wastes about 279 KB, and crawlers asking for /favicon.ico get 279 KB too. - -**Independent check** - -public/favicon.png is a 1024x1024 PNG of 279,465 B. Correction: public/favicon.ico is byte-identical to it (cmp), a raw PNG with a .ico name and no ICO wrapper. It is 279,465 B on disk, not 285,696. index.html:17 links the PNG as the tab icon. The manifest lists only the 1024 PNG. Same fix as perf-runtime-7. - -**Evidence files** - -[favicon.png](/home/codex/Projects/DebateLab/public/favicon.png), [favicon.ico](/home/codex/Projects/DebateLab/public/favicon.ico), [index.html](/home/codex/Projects/DebateLab/index.html), [curl-icon-headers.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/curl-icon-headers.txt) - - -### render-bundle-11: Landing page and Crossfire load the full framer-motion runtime (41.6 KB transferred) for simple fade and slide effects - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `framer-motion-full-runtime` - -**Where** - -- src/components/landing/Navbar.tsx:3, Hero.tsx:3, Features.tsx:2, SocialProof.tsx:2, DeepDive.tsx:2, FAQ.tsx:8, HowItWorks.tsx:2, UseCases.tsx:2, Pricing.tsx:2, WhatsNew.tsx:2, FinalCTA.tsx:2 -- src/pages/CrossfirePage.tsx:3 - -**What the reviewer saw** - -Chunk proxy-*.js is 121.32 KB minified and 39.68 KB gzip (motion-dom 283 KB, framer-motion 83 KB rendered). Its only static importers are LandingPage and CrossfirePage. On staging, / loads proxy-rtEpRdpA.js at 41.6 KB, the largest script after the entry. GeneratePage and AskChat import only useReducedMotion, which is a 0.44 KB chunk. - -**Why it happens** - -The code uses the full motion component rather than the m component with LazyMotion and domAnimation. - -**Suggested fix** - -Wrap the landing page and Crossfire in <LazyMotion features={domAnimation}> and replace motion.* with m.*, which typically cuts the runtime to about 15 to 20 KB. Or replace the whileInView fades with the existing CSS motion- classes and an IntersectionObserver. - -**Who it hits** - -Every signed-out landing visit (it sits on the LCP path on slow networks) and every Crossfire visit. - -**Independent check** - -proxy-*.js (framer-motion and motion-dom) is 121 KB min / 39.7 KB gzip in my build. It sits in the static closures of LandingPage and CrossfirePage only, not GeneratePage or AskPage. Landing's closure is 169 KB min / 48 KB br. I did not verify the LazyMotion size estimate. - -**Evidence files** - -[load-_-anon-mobile.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/load-_-anon-mobile.json), [bundle-largest-chunks.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/bundle-largest-chunks.txt), [bundle-analysis-local-build.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/bundle-analysis-local-build.json) - - -### render-bundle-12: Hydration makes two redundant /auth/v1/user round trips that delay Current Debate data, and selects every row of 9 tables on every app load - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `eager-hydration-network` · **Same fix as:** perf-runtime-5 - -**Where** - -- src/lib/stores/debate-store.ts:127 (await supabase.auth.getUser() before fetching debates) -- src/lib/stores/sparring-store.ts:481 (same before sparring_sessions) -- src/lib/supabase/AuthProvider.tsx:133-141 (already has userId when it calls the hydrators) -- session-store hydrateFromServer (select=* without limit on cases, attacks, brainstorms, factoids, chats, flowcharts, sources, source_folders, plus messages) - -**What the reviewer saw** - -On staging /app, signed-in pro account, every load made 2x GET /auth/v1/user. debates and current_debates (and sparring_sessions) start only after getUser returns, while the session-store tables start immediately. - -Timing across 4 runs: -- debates started at 842 vs cases at 560; getUser took 270 to 310 ms. -- 487 vs 398 ms. -- 491 vs 389 ms. -- An earlier run: debates at 2,177 ms vs 521 ms, when getUser took about 1.6 s. - -The session-store requests are select=*&order=created_at.desc with no limit, so each app load downloads the full text of every saved result and source the user owns. - -**Why it happens** - -Each store fetches the user again (a network call) instead of taking the userId AuthProvider already has. Hydration reloads everything with no pagination or column selection. - -**Suggested fix** - -Pass the userId into hydrate and hydrateSavedSessions, or use supabase.auth.getSession() or the cached session, which needs no round trip. Later, fetch list columns (id, title, created_at, debate_id) for the Current Debate first and load full outputs on demand. - -**Who it hits** - -Every signed-in app load. The Current Debate strip and dialogs fill one round trip later than needed, and bandwidth grows with history. - -**Independent check** - -Reproduced on every cold signed-in load: 2 GET /auth/v1/user, and debates, current_debates and sparring_sessions start only after them (desktop: getUser 596-754 ms, debates 712 ms, while cases starts at 588 ms). The session-store queries use select=* with no limit. Same fix as perf-runtime-5, and its getUser part is also perf-runtime-3 fix #1. - -**Evidence files** - -[hydration-waterfall.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/hydration-waterfall.json), [supabase-requests-_app.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/supabase-requests-_app.json), [last-pro-desktop-_app.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/raw/last-pro-desktop-_app.json) - - -### render-bundle-13: Hidden Workspace and the Capacitor runtime still ship to the web app (166 Workspace references across 8 tool pages and client.ts; Capacitor in the entry) - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `workspace-capacitor-shipped-to-web` - -**Where** - -- src/pages/GeneratePage.tsx, AttackPage.tsx, BrainstormPage.tsx, FactoidsPage.tsx, AskPage.tsx, CrossfirePage.tsx, SparringPage.tsx, FlowchartPage.tsx (useWorkspaceToolScope and workspace.context branches) -- src/lib/ai/client.ts (35 workspace references) -- src/lib/workspace/tool-context.ts and practice-client.ts (shipped in chunk tool-context-*.js) -- src/lib/workspace/artifact-save-queue.ts (in VoiceModeToggle chunk) -- src/main.tsx:7-10, src/native-runtime.ts, src/native-app-url-listener.ts, src/lib/native.ts (entry) - -**What the reviewer saw** - -Production does not set VITE_COLLABORATION_URL, so App.tsx:247 never registers the Workspace routes, and workspace.context comes only from route state set by WorkspacePage, which means it is always null on the web. Still, every tool chunk imports WorkspaceToolScope (1.1 KB) and tool-context plus practice-client (5.1 KB rendered, with a zod schema). - -Workspace references per file: GeneratePage 12, AttackPage 10, BrainstormPage 8, FactoidsPage 8, AskPage 11, CrossfirePage 34, SparringPage 42, FlowchartPage 6, ai/client.ts 35. - -The entry also carries @capacitor/core (23.7 KB rendered), the plugin definitions, native-runtime and native-app-url-listener: 12.5 KB minified that does nothing in a browser. The tiptap and yjs chunk (553 KB) is correctly never loaded in production. - -**Why it happens** - -Workspace integration was threaded into every tool, and native support into main.tsx, without build-time gating. - -**Suggested fix** - -Simplest: move Workspace launch handling behind one lazily loaded adapter, or delete the workspace.context branches from the tool pages and client.ts while Workspace is shelved (the git history keeps them). Import the native runtime with import() only when isNativeApp() is true, or move it into a capacitor-mode-only entry. - -**Who it hits** - -Mostly code complexity: the tool pages are harder to change and review. The web bundle carries about 12 KB minified of Capacitor and a few KB of Workspace code per tool. - -**Independent check** - -In scope under 'still ships to the web app'. grep -ci workspace counts match exactly: Generate 12, Attack 10, Brainstorm 8, Factoids 8, Ask 11, Crossfire 34, Sparring 42, Flowchart 6, ai/client.ts 35. In the bundle graph, the Generate, Ask and Sparring closures each load WorkspaceToolScope (0.8 KB) and tool-context with practice-client (3.5 KB). Sparring also pulls artifact-save-queue through VoiceModeToggle. The entry contains @capacitor/core, the browser, app, haptics, keyboard and status-bar plugins, native.ts, native-runtime.ts, native-app-url-listener.ts and workspace invite-handoff and collaboration-config. The Capacitor part overlaps render-bundle-6's fix. - -**Evidence files** - -[bundle-largest-chunks.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/bundle-largest-chunks.txt), [entry-import-chains.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/entry-import-chains.txt), [bundle-graph-local-build-40738ea.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/bundle-graph-local-build-40738ea.json), [bundle-analysis-local-build.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-1/bundle-analysis-local-build.json) - - -### render-bundle-14: next-15 #12 leftovers still open: server-only and native-tooling deps in client dependencies, three copies of the control-character sanitizer, and knip ignores unused exports - -**Severity:** nit · **Verdict:** confirmed · **Root cause:** `next-15-12-dep-and-dead-code-cleanup` · **Known in plan:** next-15 #12 - -**Where** - -- package.json:93 (pg; used by services/collaboration and e2e/full-stack/account-deletion.spec.ts) -- package.json:49 (@hocuspocus/server; only services/collaboration/src/server.ts) -- package.json:42, :45 (@capacitor/cli, @capacitor/ios in dependencies) -- package.json:2 (name vite_react_shadcn_ts) -- supabase/functions/_shared/prompts/context.ts:45, supabase/functions/ai/request.ts:29, supabase/functions/ai-stream/index.ts:67 -- knip.json include list (no exports or types) - -**What the reviewer saw** - -The same regex /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g appears in all three files (two of them also strip zero-width characters). Running npx knip@6.40.0 --include exports,types,duplicates reports 37 unused exports and 22 unused exported types, for example unused UI-kit exports (CardFooter, DialogClose, SelectGroup and friends, SheetTrigger), src/components/ui/sonner.tsx toast, src/lib/ai/client.ts classifyAIError and isAIProviderError, and 10 unused sparring schema exports. knip's 'ai unused devDependency' claim is a false positive: ai is a peer dependency of e2e and @openrouter/ai-sdk-provider. - -**Why it happens** - -Cleanup item 12 from plans/2026-10-01-next-15.md has not been done. - -**Suggested fix** - -- Move pg and @capacitor/cli to devDependencies (pg is still needed by the e2e spec). -- Move @hocuspocus/server to a services/collaboration package.json or to devDependencies. -- Rename the package. -- Share one sanitizer in supabase/functions/_shared. -- Add exports and types to knip.json's include list, then delete the dead exports. - -**Who it hits** - -Repo hygiene and install size; no user-facing effect. - -**Independent check** - -Real on main at 40738ea, but several details in the finding are wrong. Re-read the code: -- package.json:2 still has the name vite_react_shadcn_ts. -- pg (package.json:93) and @hocuspocus/server (:49) are still in dependencies. pg is imported only by services/collaboration/src/index.ts, e2e/full-stack/account-deletion.spec.ts and .claude/skills/verify-debatelab/recipes/account-data.spec.ts. The reviewer missed that third importer. @hocuspocus/server is imported only by services/collaboration/src/server.ts, and services/collaboration has no package.json of its own. -- The control-character regex appears three times: supabase/functions/_shared/prompts/context.ts:46, ai/request.ts:30 and ai-stream/index.ts:68 (the eslint-disable comments sit one line above each). -- eslint.config.js:23 still sets @typescript-eslint/no-unused-vars to off. That is part of next-15 #12 too, and the reviewer did not list it. -- knip.json's include list has files, dependencies, devDependencies, unlisted and binaries, so knip never checks exports or types. - -Corrections: -(1) The knip counts are mislabeled. The reviewer used --reporter compact, which prints one line per file. Running npx knip@6.40.0 --include exports,types,duplicates with the default reporter gives 89 unused exports (in 37 files) and 39 unused exported types (in 22 files). -(2) Most of the 89 are used inside their own file, so only the export keyword is dead: classifyAIError, isAIProviderError, RouteErrorBoundary, loadJamRecorder, SparringSessionSchema, ContentionSchema, validateSingleFile. Following the suggested fix ('delete the dead exports') literally would break those. The code that is truly unused is mostly shadcn leftovers: CardFooter, SheetTrigger/Close/Overlay/Portal, DialogClose/Portal/Overlay, SelectGroup/Label/Separator/ScrollUp/ScrollDown, AlertDialogPortal/Overlay, ScrollBar, and the sonner toast re-export. Part of the list is out-of-scope code (src/lib/workspace/*, src/lib/native.ts, native-runtime.ts, native-app-url-listener.ts) or test fixtures (agentic/support/fixtures.ts). -(3) @capacitor/ios in dependencies is where Capacitor's install docs put platform packages (from memory, not re-checked). Only @capacitor/cli is documented as a dev dependency. Neither is in plan item #12, and the review plan puts the iOS shell out of scope and expects Capacitor removal to be its own cleanup. Drop package.json:45 from this finding. -(4) The 'ai' false positive holds: package-lock.json lists ai as a peer dependency of node_modules/@openrouter/ai-sdk-provider and node_modules/e2e. The right knip fix is ignoreDependencies: ["ai"], not removing ai. knip's 'Unlisted binaries: knip' line is an artifact of the stale local node_modules (knip, e2e and ai are not installed), not a repo problem. -(5) Merging the sanitizers into one helper changes behavior slightly: context.ts strips only C0 control characters and DEL, while the other two also strip zero-width characters (​-‍). Context attributes would start losing zero-width characters too. That is harmless and probably desirable, but say it in the PR. - -Impact: none for users. Vite bundles only what is imported and tree-shakes unused exports, and Vercel installs devDependencies for the build anyway, so moving pg or @capacitor/cli does not change install size there. Nit is the right severity. knownInPlan: next-15 #12. - -**Evidence files** - -[knip-with-exports.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/knip-with-exports.txt), [knip-exports-types-duplicates.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-2/knip-exports-types-duplicates.txt) - - -### render-bundle-15: About 50 chunks under 1.5 KB mean dozens of tiny script requests per route (46 on /app/generate) - -**Severity:** nit · **Verdict:** partly · **Root cause:** `lucide-icons-chunk-per-module` - -**Where** - -- vite.config.ts build options (no output.experimentalMinChunkSize or manualChunks) -- lucide-react icon imports shared across routes - -**What the reviewer saw** - -The local build emits 116 chunks; 50 of them have under 1.5 KB of rendered code, mostly single lucide icons such as sparkles, zap, arrow-right, git-branch, timer, dumbbell, brain, swords, users, crown and crosshair. On staging, / requested about 15 icon chunks of 0.3 to 1.6 KB, and /app/generate loaded 46 scripts (529 KB total, of which 367.7 KB is the entry). - -**Why it happens** - -Rollup gives each module shared between dynamic entries its own chunk, and every lucide icon is its own module. - -**Suggested fix** - -Set build.rollupOptions.output.experimentalMinChunkSize (for example 4,000), or add a manualChunks rule that groups lucide-react icons into one shared chunk. - -**Who it hits** - -Small. Per-request overhead and preload fan-out on HTTP/2, most visible on high-latency mobile connections. - -**Independent check** - -The counts are real, but the claimed cost on high-latency mobile did not reproduce, and both suggested fixes fall short. - -Measured: -- My local build (npx vite build --outDir in scratch) emits 116 JS chunks, 54 of them under 1.5 KB, 36 of which are single lucide icons. Vite's own report counts 55 under 1.5 kB. vite.config.ts build options have no rollupOptions. -- Staging, Pixel 7, signed out: / loads 20 own scripts, 15 of them icon chunks of 0.3 to 1.4 KB encoded, about 8.5 KB in total. -- Staging /app/generate as pro: 43 own scripts on every run (the reviewer's 46 also counted the 2 Vercel insights scripts and the Cloudflare beacon), about 540 KB encoded, 27 to 31 of them under 1.5 KB encoded. -- Local file hashes differ from staging because of the release and Sentry environment, but the chunk structure matches. - -Why the cost is not real: all route chunks go out in one flat wave through Vite's preload helper, not a waterfall. Unthrottled, they start within 7 ms of each other. With fast-4G CDP throttling (150 ms RTT, 1.6 Mbps), 3 runs at load averages of 2.1 to 4.7 (the box was noisy): -- The 42 route chunks start within 50 to 74 ms of each other. -- All on h2. -- The sub-1.5 KB chunks total 18 to 26 KB encoded, and that figure includes response headers. -- The last small chunk finishes a median 499 ms after the wave starts. The largest chunks, index-D92 (42 KB) and markdown (39 KB), finish at a median 2031 ms. - -So the tiny chunks are never on the critical path. Merging them would save only their per-response header bytes, about 200 B each, roughly 6 to 8 KB per route, or about 30 ms at 1.6 Mbps. Assets are content-hashed and cached immutable, so even that is a first-visit cost. - -Fix corrections, each tested by building with a scratch config that merges the repo config: -(a) experimentalMinChunkSize: 4000 goes from 116 to 98 chunks and leaves 33 of the 36 icon chunks. Each lucide icon module calls createLucideIcon(...) at top level with no PURE annotation, so Rollup treats the chunk as side-effectful and will not merge it, even though package.json says sideEffects: false. -(b) A manualChunks rule that puts every lucide icon in one chunk gives 81 chunks (20 under 1.5 kB). But the icons chunk becomes a <link rel=modulepreload> in index.html: 37.9 KB raw / 12.5 KB gz on every first page, including the signed-out landing. The entry shrinks by only 5.0 KB gz, so the critical path grows by about 7.5 KB gz everywhere. Landing today loads about 8.5 KB of icons over 15 parallel requests that are off the critical path. - -Recommendation: no action, or at most group only the icons the entry does not use. It is not a free win. Severity stays nit. Not a duplicate of render-bundle-14. - -**Evidence files** - -[load-_-anon-mobile.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/load-_-anon-mobile.json), [load-nojam-_app_generate-pro-mobile.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/load-nojam-_app_generate-pro-mobile.json), [build-sizes-excerpt.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/render-bundle/build-sizes-excerpt.txt), [chunk-counts.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-2/chunk-counts.txt), [build.log](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-2/build.log), [build-min4000.log](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-2/build-min4000.log), [build-icons.log](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-2/build-icons.log), [scripts-_-anon-mobile-run1.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-2/scripts-_-anon-mobile-run1.json), [scripts-_app_generate-pro-mobile-run1.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-2/scripts-_app_generate-pro-mobile-run1.json), [scripts-_app_generate-pro-mobile-run2.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-2/scripts-_app_generate-pro-mobile-run2.json), [scripts-_app_generate-pro-mobile-fast4g-run1.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-2/scripts-_app_generate-pro-mobile-fast4g-run1.json), [scripts-_app_generate-pro-mobile-fast4g-run2.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-2/scripts-_app_generate-pro-mobile-fast4g-run2.json), [scripts-_app_generate-pro-mobile-fast4g-run3.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-2/scripts-_app_generate-pro-mobile-fast4g-run3.json), [scripts-_app_generate-pro-1.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-2/raw/scripts-_app_generate-pro-1.png), [scripts-_-anon-1.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-perf-2/raw/scripts-_-anon-1.png) - diff --git a/plans/2026-10-08-ultracode-review-report/security.md b/plans/2026-10-08-ultracode-review-report/security.md deleted file mode 100644 index 1c43da777..000000000 --- a/plans/2026-10-08-ultracode-review-report/security.md +++ /dev/null @@ -1,1046 +0,0 @@ -# Security (Edge Functions and database): findings - -**Status:** Review evidence from 2026-10-08/09, for [the ranked report](../2026-10-08-ultracode-review-report.md). Not started. - -29 findings: 2 high, 11 medium, 16 low. An independent verifier re-checked every one. - -Each entry has the final severity, where the problem lives, what the reviewer saw, why it happens, a suggested fix, the independent check and links to the screenshots and measurements in `.verify/ultracode-review-2026-10-08/`. Line numbers are against main at 40738ea. - -Not covered by the security-edge reviewer: - -- demo-code-login live behaviour. The task forbids probing it, because every request records an attempt row. Whether the last X-Forwarded-For entry is really the client IP on Supabase's edge is unverified -- Production secrets and deploy state (off limits). SPARRING_INTEGRITY_SECRET was checked on staging only -- The OpenRouter error passthrough is reproduced locally only. Triggering it live would have spent AI quota -- Import-parser CPU blowup timed only in local Deno. The hosted runtime's CPU limit and its effect on other requests sharing the worker were not measured -- An authenticated voice request whose content-length exceeds 8000 (the null-return path that never reads the body). Not probed -- Billing functions (stripe-webhook, create-checkout, create-portal, redeem-promo): integrity-money agent -- workspace-invites internals (hidden). I only confirmed it is deployed and answers 401 on staging -- Sparring and crossfire prompt content, and the HMAC binding details of crossfire signal tokens. Skimmed only -- The admin grant-trial, promo and subscribers actions were read only as far as the shared admin gate -- Browser UI flows. No browser was launched, because this area is server-side - -Not covered by the security-db reviewer: - -- Production catalog: never touched. TRUNCATE, global_prompts and admin-RPC grants on production are unknown. Staging shows out-of-band hardening for some objects, so production may differ from both staging and the replay -- Staging TRUNCATE and REFERENCES grants: PostgREST cannot show these without catalog access -- PostgREST max_rows on staging: no seeded account has more than 1000 rows in any table, so the truncation finding rests on Supabase's documented default of 1000, not a live repro -- Write probes against staging: none run, by design. Write-policy claims come from migration text plus the throwaway replay -- The keepalive_heartbeat 'rpc-keepalive' row is touched in the same transaction as each event insert. That suggests a function or trigger that no migration contains, but its name could not be enumerated without catalog access -- Workspace tables and functions (about 2,500 lines of migrations) were inventoried for grants and SECURITY DEFINER ACLs only, not audited in depth (out of scope) -- Stripe webhook entitlement writes (grace_period_ends_at set to null on checkout, tier set to 'expired' on cancel, which also wipes promo or lifetime Pro) are noted under finding 1 but left to the integrity-money lane -- Local DB gate and pgTAP run: not run (held by the integrity-money agent) - -### security-db-1: Promo redemption overwrites existing entitlements: a paying Pro user becomes trial, lifetime Pro becomes 30 days, a 200/day limit becomes 15 - -**Severity:** high · **Verdict:** confirmed · **Root cause:** `promo-redeem-overwrites-entitlements` - -**Where** - -- supabase/migrations/20260819000000_security_hardening.sql:117-142 (redeem_promo_code_atomic: each CASE branch does ON CONFLICT DO UPDATE SET subscription_tier/trial_ends_at/grace_period_ends_at/daily_limit_override = EXCLUDED.*) -- supabase/functions/_shared/entitlements.ts:47-125 (getSubscriptionInfo only consults subscriptions when tier === 'pro') -- src/components/features/subscription/PricingModal.tsx:25-48 (promo field) -- src/pages/SettingsPage.tsx:176-227 (promo-only Pro users get 'Set up billing', which opens PricingModal) -- src/pages/AttackPage.tsx:170-182 (and the other tool pages: LimitReachedModal -> PricingModal, so a Stripe Pro user who hits the daily limit also sees the promo field) - -**What the reviewer saw** - -Replay of all migrations, run as role authenticated with a JWT sub (replay-proofs.out.txt, block P1). Before: stripe-pro had tier 'pro', grace NULL and an active subscription row; lifetime had tier 'pro', grace 9999-12-31, limit 50; school-pro had tier 'pro', grace +300 days, limit 200. After redeeming SCHOOLTRIAL (extended_trial, 14 days), FREEMONTH (free_month, 30 days) and MORELIMIT (extra_limit, 15), all returning success=true: - stripe-pro@example.test | trial | grace NULL | trial_ends 2026-10-22 | override NULL - lifetime@example.test | pro | grace 2026-11-07 | ... | 50 - school-pro@example.test | pro | grace 2027-08-04 | ... | 15 -With tier 'trial', getSubscriptionInfo returns the trial branch (5/day, no Pro models) and never looks at the active Stripe subscription. Once trial_ends_at passes, a paying subscriber reads as expired until the next customer.subscription.updated webhook, usually the renewal. The lifetime user's 9999 grace date is gone for good. Related, from the same single-column design (integrity-money lane): stripe-webhook sets grace_period_ends_at=null on checkout and tier='expired' on cancel/payment failure (stripe-webhook/index.ts:183-187, 241-246, 278-283), which also wipes promo or lifetime Pro. - -**Why it happens** - -Entitlement is stored as overwritable scalar columns on user_settings with several writers (promo RPC, Stripe webhook, admin grants), and the promo RPC writes absolute values instead of combining them with what the user already has. - -**Suggested fix** - -In a new migration, rewrite redeem_promo_code_atomic so it only ever adds. Never lower subscription_tier: if the user is already 'pro' (active subscription or grace in the future), extended_trial becomes a no-op or is refused with a clear message. Use GREATEST(existing, new) for trial_ends_at, grace_period_ends_at and daily_limit_override. free_month extends from GREATEST(now(), grace_period_ends_at). Add a pgTAP test covering Stripe Pro + extended_trial, lifetime + free_month and override 200 + extra_limit 15. Separately (integrity-money lane), stop the webhook from nulling a future grace_period_ends_at. - -**Who it hits** - -Any Pro user, paying or promo/lifetime, who redeems a school or partner code. They lose Pro features or daily quota, sometimes permanently, and a paying customer gets trial limits. Reachable from the UI and directly through PostgREST. - -**Independent check** - -Evidence: code, plus a browser screenshot of UI reach. Promo redemption on staging is forbidden. redeem_promo_code_atomic (20260819000000_security_hardening.sql:117-141; later migrations only re-grant) does unconditional ON CONFLICT DO UPDATE SET col = EXCLUDED.col. extended_trial writes subscription_tier='trial'. free_month writes grace = now()+days, which overwrites a 9999 lifetime date. extra_limit writes daily_limit_override = 15, below Pro's 50. getSubscriptionInfo checks subscriptions only when tier === 'pro' (entitlements.ts:82-103), so a Stripe subscriber set to 'trial' gets 5 a day without Pro models. Reach: the seeded pro account (lifetime Pro through a demo code) sees 'Set Up Billing' in Settings, which opens the PricingModal promo field (screenshot). LimitReachedModal always shows Upgrade, which opens the PricingModal promo field (AttackPage.tsx:170-182). So a Pro user who hits the limit and redeems an 'extra limit' code ends up with fewer requests. Extra defect seen in the same screenshot: the PricingModal marks 'Free' as 'Current' for a Pro account. - -**Evidence files** - -[replay-proofs.sql](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-db/replay-proofs.sql), [replay-proofs.out.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-db/replay-proofs.out.txt), [db1-pro-settings-pricing-promo-field.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/raw/db1-pro-settings-pricing-promo-field.png), [db1-promo-reach.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/db1-promo-reach.json) - - -### security-db-2: Promo codes can be brute-forced through PostgREST: no attempt limit, answers reveal which codes exist, and codes are admin-chosen words - -**Severity:** high · **Verdict:** confirmed · **Root cause:** `promo-redeem-no-attempt-limit` · **Known in plan:** next-15 #2 - -**Where** - -- supabase/migrations/20260819000000_security_hardening.sql:117-142 -- supabase/migrations/20260820090000_advisor_security_cleanup.sql (GRANT EXECUTE ... redeem_promo_code_atomic(text) TO authenticated) -- supabase/functions/redeem-promo/index.ts:43-51 (no limit; logs the raw code and user id on every call, failures included, worded 'redeemed') -- supabase/functions/admin/index.ts:901-927 (create-promo accepts any string as the code, no length or entropy rule) - -**What the reviewer saw** - -Replay block P2, one authenticated attacker calling the RPC directly: - NOPE1 -> 'Invalid or expired promo code' - OLDCODE -> 'This promo code has expired' - SCHOOLTRIAL -> 'Promo code applied!' - SCHOOLTRIAL -> 'You've already redeemed this code' -The messages separate unknown, expired, already-redeemed and full codes, so dictionary guesses confirm real codes even when they can't be used. Nothing counts failures (no attempts table, no lockout). Any signed-in account, including a free trial or demo-code user, can POST /rest/v1/rpc/redeem_promo_code_atomic as fast as it likes. Codes are free text typed by the admin (AdminPage newPromo.code), e.g. school names. The lifetime_pro type exists. Still open as next-15 #2. - -**Why it happens** - -The rate limit was meant to live in SQL (the RPC is client-callable), but no per-user failure window was ever added, and the error messages give away whether a code exists. - -**Suggested fix** - -As next-15 #2 describes: add private.promo_redeem_attempts (user_id, created_at, success) and inside redeem_promo_code_atomic refuse after, say, 5 failures in 15 minutes, before the promo lookup. Return one generic message for unknown, expired, inactive and full codes. Keep 'already redeemed' only after the code is confirmed. Stop logging the raw code (log a hash or prefix). Optionally require at least 8 characters in create-promo, or offer a generate button like demo codes. Prove it with a pgTAP test: N failures lock the user, success resets, another user is unaffected. - -**Who it hits** - -Anyone with a free account can find and redeem trial, free-month or lifetime-Pro codes meant for specific schools, which is lost revenue and uses up max_redemptions for the real recipients. - -**Independent check** - -Known in plan: next-15 #2, still open. Evidence: code only. Wrong promo codes on staging are forbidden. redeem_promo_code_atomic returns four distinct messages (invalid, expired, already redeemed, usage limit; lines 123-126), so guesses confirm that a code exists. No attempts table or lockout exists anywhere in the migrations. EXECUTE is granted to authenticated (20260820090000:26-27), so any trial or demo account can call /rest/v1/rpc/redeem_promo_code_atomic directly. redeem-promo/index.ts:50 logs 'User X redeemed code CODE' with the raw code even when result.success is false. admin create-promo accepts free-text codes. I kept high because the lifetime_pro type exists and codes are guessable words. That is an inference, since production code entropy is not visible. - -**Evidence files** - -[replay-proofs.out.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-db/replay-proofs.out.txt) - - -### security-edge-1: Unvalidated `feature` is written unbounded to ai_requests on every refused AI request (storage amplification, admin stats blow-up) - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `ai-feature-unvalidated-refusals-logged-unbounded` - -**Where** - -- supabase/functions/ai/index.ts:146 (feature cast, never checked against the Feature union) -- supabase/functions/ai/index.ts:188 (pre-auth log of feature) -- supabase/functions/ai/index.ts:218 + _shared/control-enforcement.ts:56-62 (ENUM_CONTROLS[feature] ?? {} lets unknown features pass) -- supabase/functions/ai/index.ts:506-525 (checkRateLimit then logUsage(userId, feature, ...,'rate_limited') on every refusal) -- supabase/functions/_shared/prompts/system.ts:61 (unknown feature throws only after the quota slot is consumed) -- supabase/migrations/20260129000000_init.sql:93-106 (ai_requests.feature text NOT NULL, no CHECK, no index on it) -- supabase/functions/admin/index.ts:273-290 (stats loads every ai_requests row in the window, feature included) - -**What the reviewer saw** - -Staging: an unauthenticated POST to ai with feature 'zz-review-unknown' got 401 'Authentication required', not 400. The unknown feature passes every pre-auth check (probe-auth.json). The code path after auth is as follows. readBoundedJsonBody allows 200,000 bytes, so `feature` can be about 199 KB. validateControlInput skips unknown features. For an expired or over-quota account, checkRateLimit returns not allowed, and line 525 inserts an ai_requests row whose feature column holds the whole string, with status 'rate_limited'. Nothing limits how many refused requests are logged. For an account with quota left, the unknown feature consumes one daily slot. buildSystemPrompt then throws 'Unsupported feature: <string>', which becomes a 500 and a second error row. The pre-auth log at line 188 also writes the string to function logs, with no session needed. ai_requests has no length CHECK and no index on feature (only idx_ai_requests_user_id), so 200 KB values are simply TOASTed and the insert succeeds. Admin `stats` pulls every row in its window, feature included, into the function's memory through collectAdminUsagePages. - -**Why it happens** - -Feature validation is implicit: it only happens when a handler or buildSystemPrompt rejects the value, which is after authentication, quota and logging. Refused requests are logged once each, and every client-supplied string goes in unbounded. - -**Suggested fix** - -In ai/index.ts, check `feature` against the 7-value Feature list (export it from _shared/prompts/common.ts) right after parsing, return 400 before logging, auth or quota, and delete the dead 'Fallback for any other feature' branch. Stop inserting one ai_requests row per rate-limited request; count refusals in the daily quota row or keep one counter per user per day. Add `CHECK (char_length(feature) <= 64)` (and similar caps on model and error_code) to ai_requests in a migration. Move the admin `stats` aggregation into a SQL RPC with GROUP BY. - -**Who it hits** - -Any signed-in account can do this: free trial, expired, demo-code or banned. A loop of about 10 requests a second adds roughly 2 MB/s, about 7 GB an hour, to ai_requests. That costs disk money and breaks the admin stats dashboard, which loads all rows. Legitimate users are unaffected until the database fills. - -**Independent check** - -Evidence: staging HTTP plus code. An unauthenticated POST ai with feature 'zz-review-unknown' returns 401, not 400, so the unknown feature passes every pre-auth check. End to end with a fresh expired session: POST ai with a 4,010-char feature returned 402 trial_expired. The account's own newest ai_requests row (readable through ai_requests_select_own) has status rate_limited, error_code trial_expired and featureLength 4010 (probe-api.json edge1_expired). Code: ai/index.ts:146 casts feature with no allowlist. validateControlInput uses ENUM_CONTROLS[feature] ?? {} (control-enforcement.ts:56). logUsage(..., 'rate_limited') runs on every refusal (index.ts:525), and logUsage inserts feature verbatim (entitlements.ts:200-240). The body cap is 200,000 bytes (request.ts:4). init.sql has no CHECK on feature, and no later migration adds one. model and error_code are already bounded (isModelSelection, fixed codes), so feature is the only unbounded column. Note for the fix: repetitive strings TOAST-compress to almost nothing, so the amplification needs random strings, which is easy. With model 'auto', an unknown feature on an account with quota would also trigger a paid router call before buildSystemPrompt throws. - -**Evidence files** - -[probe-auth.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-edge/probe-auth.json), [probe-auth.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-edge/probe-auth.mjs), [probe-api.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/probe-api.json), [probe-api.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/scripts/probe-api.mjs) - - -### security-edge-2: Seven Edge Functions never answer an unauthenticated POST over ~512 KB (#118 hold class), and ai's own reader is racy - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `edge-refuse-before-draining-request-body` - -**Where** - -- supabase/functions/voice/index.ts:63 (content-length over 8000 returns null without reading) and :113-119 (401 before the body is read) -- supabase/functions/ai-stream/index.ts:104-111 (401 before the body is read), :116 (unbounded req.json() after auth) -- supabase/functions/account/index.ts:115-117, admin/index.ts:98-105, announcements/index.ts:18, export-gdoc/index.ts:744-757 (refusal before the body is read) -- supabase/functions/ai/request.ts:116-121 (cancels at 200 KB without draining) -- supabase/functions/demo-code-login/index.ts:145 (unauthenticated, unbounded req.json()) -- supabase/functions/workspace-invites (deployed on staging, same behaviour) - -**What the reviewer saw** - -Staging, unauthenticated POST with a 1,000,0xx-byte JSON body, 3 runs each, 75 s client timeout, load average 4.3 (probe-hold.json). admin, voice, ai-stream, announcements, account, export-gdoc and workspace-invites gave no response 3/3 (75,000 ms). The two #118-fixed functions answered quickly: share 401 in 92 ms median, import-source 401 in 149 ms median. ai at 300 KB: 413 at 161 ms, 413 at 172 ms, then no response once. The same functions answer 401 in under 1 s with a small body. Size threshold on voice (probe-threshold*.json): 401 in about 130 ms for 8-256 KB, 401 in 534 ms at 400 KB, no response within 40 s at 600 KB and 800 KB. ai-stream also accepted a 2 MB body from the expired account and answered 402 trial_expired. It parsed the body, so ai-stream has no size bound (probe-auth.json). - -**Why it happens** - -PR #118 added drain-before-refuse (discardRequestBody, readBoundedBody draining) only in share and import-source. The other functions read bodies with req.json() or private readers, and refuse before reading. The hosted runtime holds the response until the body has been consumed, roughly when it exceeds its buffer. - -**Suggested fix** - -Use one helper in every function: read the request with _shared/bounded-body.ts readBoundedBody(req, maxBytes, signal) before authenticating, or call discardRequestBody(req) before every early return (auth failure, 405, banned, quota). Replace ai/request.ts readBoundedJsonBody and voice's private readBoundedBody with the shared reader. Give ai-stream, account, admin, announcements, export-gdoc and demo-code-login explicit byte caps. - -**Who it hits** - -Anyone, with no account, can hold an Edge worker connection for up to the gateway's ~150 s with each ~1 MB request, instead of the ~100 ms a refusal should take. That is cheap resource exhaustion and invocation waste. Honest clients rarely send more than 512 KB, so normal users seldom see the hang. - -**Independent check** - -Evidence: staging HTTP. Unauthenticated ~1,000,010-byte POST, 60 s client timeout, load average 2.0-2.8. No response (60 s) from voice 3/3, ai-stream 3/3, account 3/3, admin 1/1, announcements 1/1, export-gdoc 1/1, workspace-invites 1/1. import-source answered 401 in 179 ms median (3/3). voice with a 1 KB body answered 401 (117 ms on the warm run). The ai function also holds: 300 KB got 413 3/3, 600 KB got no response 2/3 (one 413), 1 MB got no response 3/3. So ai's reader is size-dependent rather than racy: it cancels at 200 KB without draining (request.ts:116-121). Correction: share is verify_jwt=true on staging, because supabase/config.toml never lists it and the default applies, so its fast 401 comes from the gateway, not from #118's drain. import-source is the only function-level control. demo-code-login was deliberately not probed: any body records an invalid_format failure against the shared VPS IP. Code: voice/index.ts:63 returns null without reading when content-length > 8000, and auth runs before the read (113-119). ai-stream authenticates before req.json() and has no byte cap (104-116). account authenticates before readBody (119-120). share and import-source call discardRequestBody. - -**Evidence files** - -[probe-hold.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-edge/probe-hold.json), [probe-threshold.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-edge/probe-threshold.json), [probe-threshold-2.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-edge/probe-threshold-2.json), [probe-hold.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-edge/probe-hold.mjs), [probe-auth.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-edge/probe-auth.json), [probe-hold.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/probe-hold.json), [probe-hold-ai.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/probe-hold-ai.json), [staging-functions-list.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/staging-functions-list.json), [probe-hold.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/scripts/probe-hold.mjs) - - -### security-edge-3: Raw upstream and runtime error text reaches students (OpenRouter bodies up to 16 KB, Deno fetch errors, JS RangeError), English-only - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `raw-error-messages-forwarded-to-users` - -**Where** - -- supabase/functions/ai/provider.ts:226 (throw new Error(`OpenRouter error: ${errorBody}`)) -- supabase/functions/ai/index.ts:799 (returns err.message as {error} with status 500) -- supabase/functions/ai-stream/index.ts:282 (unbounded response.text() of the upstream error) and :359 (sent to the browser as an SSE error) -- supabase/functions/import-source/index.ts:339-342 (catch-all returns error.message) -- supabase/functions/export-gdoc/index.ts:863-887 (details: errorText from Google) -- supabase/functions/share/index.ts:170-175, admin/index.ts:750-762 (raw err.message on 500) -- src/lib/ai/client.ts:898-986 (isProviderErrorMessage matches only OpenAI|Anthropic|Google|Gemini, so the fallback is `AI service error: ${message}`) -- src/lib/ai/error-display.ts:60 (returns error.message verbatim) -- src/pages/LibraryPage.tsx:249-253 (import toast shows the raw message) - -**What the reviewer saw** - -Local repro (openrouter-error-passthrough.json): callAI against a stub server returning OpenRouter's 402 body throws `OpenRouter error: {"error":{"code":402,"message":"This request requires more credits... can only afford 3120... add more credits","metadata":{"provider_name":"Example"}},"user_id":"user_2zZZreviewEXAMPLE"}`. ai/index.ts:799 returns that string unchanged. mapAIError does not see 'OpenRouter' as a provider error, so the UI shows `AI service error: OpenRouter error: {...json...}. Please try again.` Staging import-source with the expired account (probe-auth.json): https://expired.badssl.com/ returned 400 `error sending request for url (https://expired.badssl.com/): client error (Connect): invalid peer certificate: Expired`. A public page containing � returned 400 `Invalid code point 99999999`. LibraryPage shows both in a destructive toast. None of these strings is translated for Spanish or Hungarian users. - -**Why it happens** - -Functions forward exception messages as the user-facing error, and the client shows any error message it does not recognise. - -**Suggested fix** - -Server: catch upstream failures and return a fixed code plus a fixed English fallback: provider_unavailable, provider_quota, provider_rejected, source_unreachable, source_tls, source_parse_failed, google_update_failed. Log the raw body server-side, truncated, never in the response. Read ai-stream's error body with readResponseText and its byte cap. Client: map these codes to i18n strings in mapAIError, mapStreamingError and source-import, and never show a raw message from a 5xx. - -**Who it hits** - -Students see JSON blobs and runtime internals whenever OpenRouter errors, credits run low, or an imported site has a TLS problem. Each one also leaks operator state: credit balance, provider names, the OpenRouter user id. - -**Independent check** - -Evidence: staging HTTP, browser screenshot and code. Staging import-source with the expired account and https://expired.badssl.com/ returned 400 'error sending request for url (https://expired.badssl.com/): client error (Connect): invalid peer certificate: Expired'. In the real Library UI, the destructive toast 'Import Failed' shows that exact runtime string (screenshot). A page containing � returned 400 'Invalid code point 99999999'. The OpenRouter path is code-only: provider.ts:226 throws 'OpenRouter error: ${errorBody}' (capped at 16,000 chars, research.ts:28), and ai/index.ts:799 returns it as a 500. Client: a 500 that does not match /OpenAI|Anthropic|Google|Gemini/ falls through to `AI service error: ${message}. Please try again.` (client.ts:976-984). The same branch produced the edge-4 toast, which proves the pass-through. Nuance: OpenRouter bodies that name a provider in metadata become AIProviderError and get the generic English mapping instead of raw JSON. ai-stream sends `await response.text()` of the upstream error, unbounded, as the SSE error (ai-stream:282, 351-353). Every fallback string is English-only. - -**Evidence files** - -[openrouter-error-passthrough.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-edge/openrouter-error-passthrough.json), [openrouter-error.ts](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-edge/openrouter-error.ts), [probe-auth.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-edge/probe-auth.json), [edge3-library-import-tls-toast.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/raw/edge3-library-import-tls-toast.png), [probe-api.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/probe-api.json), [probe-hold.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/probe-hold.json), [browser-import-toast.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/scripts/browser-import-toast.mjs) - - -### security-edge-4: Prompt-injection regex rejects legitimate debate text ('you are now an adult', 'jailbreaking') with an accusatory error - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `prompt-injection-keyword-blocklist` - -**Where** - -- supabase/functions/ai/request.ts:7-24 (PROMPT_INJECTION_PATTERNS incl. /you\s+are\s+now\s+(a|an)\s+/i and /jailbreak/i) -- supabase/functions/ai/request.ts:26-73 (applied to every string field of input and every history item's content) -- supabase/functions/ai/index.ts:212-216 (400 'Suspicious content detected in <field>') - -**What the reviewer saw** - -Staging, unauthenticated, so no quota or AI was used (probe-auth.json). POST ai {feature:'attack', input:{argument:'At 18 you are now an adult, so you should be able to vote.'}} returned 400 {"error":"Suspicious content detected in argument"}. POST ai {feature:'generate', input:{motion:'This House would legalise jailbreaking smartphones'}} returned 400 {"error":"Suspicious content detected in motion"}. The client has no matching pre-check (grep of src finds none). mapAIError turns this into 'AI service error: Suspicious content detected in motion. Please try again.', and retrying can never succeed. A Current Debate whose motion contains one of these phrases breaks every non-streaming tool for that debate. ai-stream (streaming Ask) skips the list, so the same text works there. - -**Why it happens** - -A keyword blocklist stands in for a prompt boundary. Debate speeches address the audience as 'you', and motions can be about jailbreaking or AI safety. - -**Suggested fix** - -Delete PROMPT_INJECTION_PATTERNS and the rejection branch in sanitizePromptInput. Keep control-character and zero-width stripping, and the 50,000-character truncation. The real defence already exists: role-separated messages, UNTRUSTED_CONTENT_RULES, neutralizeStructureTags. ai-stream already runs without the list. Update ai tests that assert rejection. - -**Who it hits** - -Any student whose motion, opponent speech or question contains a listed phrase is blocked from Generate, Attack, Brainstorm, Factoids, non-streaming Ask, Sparring and Crossfire, and is told their content is suspicious. - -**Independent check** - -Evidence: staging HTTP and browser screenshot, at zero AI cost because the filter runs before auth (index.ts:212 vs requireUser at 279). Unauthenticated attack {argument:'At 18 you are now an adult...'} returned 400 'Suspicious content detected in argument'. generate {motion:'...legalise jailbreaking smartphones'} returned 400 '...in motion'. The control with 'you become an adult' returned 401. Real UI on the pro account: Attack page, a realistic speech containing 'At 18 you are now an adult', Find Weaknesses. The server answered 400 and the toast reads 'Attack Analysis Failed / AI service error: Suspicious content detected in caseText. Please try again.', which leaks the internal field name and invites a retry that can never succeed (screenshot). Correction to scope: the Current Debate motion loaded server-side through debateContext does not pass sanitizeInput. Breakage happens where the phrase is inside `input`: generate, brainstorm and sparring send motion, attack sends caseText, and crossfire uses sanitizePromptInput (crossfire-input.ts:31). Streaming Ask (ai-stream) has no blocklist. - -**Evidence files** - -[probe-auth.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-edge/probe-auth.json), [edge4-attack-suspicious-content-toast.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/raw/edge4-attack-suspicious-content-toast.png), [edge4-attack-toast.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/edge4-attack-toast.json), [probe-api.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/probe-api.json), [browser-injection-toast.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/scripts/browser-injection-toast.mjs) - - -### security-edge-5: Share links survive 'Delete All My Data', cannot be revoked by owner or admin, and banned users can still publish - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `share-links-not-erased-or-revocable` - -**Where** - -- supabase/migrations/20260926090000_current_debates.sql:237-250 (latest clear_current_user_data deletes cases...debates but not shared_items) -- src/locales/en.json:478,483 ('permanently deletes all cases, attacks, brainstorms, factoids, chats, and sources' / 'All your saved data has been permanently removed.') -- supabase/functions/share/index.ts:51-181 (create only, with no delete or list action and no checkUserBanned) -- supabase/migrations/20260819000000_security_hardening.sql:55-57 (all privileges on shared_items revoked from anon/authenticated, so only the service role can delete) -- supabase/functions/share/index.ts:136 (title stored unvalidated: any JSON type, any length up to 512 KB) - -**What the reviewer saw** - -Code: shared_items rows are deleted only through the ON DELETE CASCADE on account deletion (20261001100000:21-24). 'Delete All My Data' (session-store.ts:1198 calls clear_current_user_data) leaves every share of a case, chat or source set publicly readable through get_shared_item until expires_at: 30 days by default, 90 if requested. Nothing in supabase/functions or src deletes from shared_items (grep), so a student who shares a chat by mistake cannot unshare it, and an admin cannot take a share down. share/index.ts never calls checkUserBanned, while import-source, ai and voice all do. - -**Why it happens** - -Share links were added as standalone capability rows, and the deletion and moderation paths never caught up. - -**Suggested fix** - -Add `DELETE FROM public.shared_items WHERE created_by = v_user_id;` to clear_current_user_data in a new migration, with a pgTAP assertion. Add a revoke action to the share function (POST {action:'revoke', shareCode}, checking created_by = caller) and a 'Shared links' list in Settings. Add the checkUserBanned gate before quota. Validate that title is a string of at most 200 characters. - -**Who it hits** - -Students, mostly minors in the EU, are told their data is gone while public copies stay live. There is no takedown path for abusive shares published under debate.pi-stack.org. - -**Independent check** - -Evidence: code only. The staging rules forbid Delete All My Data, and a share created on staging could not be deleted, which is the finding. The latest clear_current_user_data (20260926090000_current_debates.sql:219-255; no later redefinition) deletes 14 tables and not shared_items. Clients hold no privileges on shared_items (20260819:57; 20260923191320 grants it only to service_role). The share function is create-only, and grep finds no delete path in src or supabase/functions. Only the account-deletion cascade removes shares (20261001100000:21-24). Shares last 30 days by default, 90 at most (share/index.ts:12-13). share/index.ts never calls checkUserBanned, so banned users can still publish. title is stored as given (line 136) into a text column, bounded only by the 512 KB body. en.json deleteDialogDesc and deleteSuccessDesc promise everything is removed. security-db-4 is the same cause and fix. - - -### security-edge-6: Demo-code IP lockout counts its own refusals as failures, so a class behind one school NAT stays locked while it retries; IP source unverified - -**Severity:** medium · **Verdict:** partly · **Root cause:** `demo-lockout-counts-own-refusals` - -**Where** - -- supabase/functions/demo-code-login/index.ts:84-116 (counts success=false rows for the IP and the fingerprint over 15 min) -- supabase/functions/demo-code-login/index.ts:148-150 (each rate_limited refusal is recorded as success=false) -- supabase/functions/_shared/demo-code.ts:29 (threshold 10) -- supabase/functions/demo-code-login/index.ts:47-58 (IP = last X-Forwarded-For entry) - -**What the reviewer saw** - -Code review only; the task forbids sending wrong codes to staging. A request is refused while 10 or more success=false rows for that IP exist in the trailing 15 minutes. invalid_format, not_found and rate_limited rows all count, and each refusal adds a rate_limited row. One student retrying cannot keep the lock alive. A classroom sharing one NAT IP can: about 10 typos across the class trips it, and while students keep trying, their own refusals keep 10 or more failures in the window, so everyone on that IP is locked out until the whole class stops for 15 minutes. Separately, getIpAddress trusts the right-most X-Forwarded-For entry. If Supabase's edge appends its own hop last, every caller shares one bucket and 10 failures anywhere lock demo login globally. I could not verify this read-only. - -**Why it happens** - -The brute-force counter counts its own refusals, and the IP bucket assumes one person per IP. School networks break that assumption. - -**Suggested fix** - -Exclude failure_reason='rate_limited' (and invalid_format, which reveals nothing about valid codes) from the failure count. Key the IP limit per (ip, code_hash), or raise it well above a class size, and keep a strict per-fingerprint limit. Codes have 40 bits of entropy, so a looser per-IP limit is still safe. In the PR, log the raw x-forwarded-for layout once from a test function to confirm which entry is the client. - -**Who it hits** - -Teachers onboarding a class with a demo code can find the whole class stuck on 'Too many attempts. Try again later.' If the XFF assumption is wrong, the lockout is global. - -**Independent check** - -Evidence: code only. Sending wrong or empty codes to staging is forbidden, and any POST to demo-code-login records a failure against the shared IP. Confirmed: rate-limited refusals are recorded as success=false with failure_reason 'rate_limited' (demo-code-login/index.ts:148-150). isRateLimited counts every success=false row per IP and per fingerprint over 15 minutes (84-116), with threshold >= 10 (_shared/demo-code.ts:29). So the lockout feeds itself. Correction: the reviewer says one student retrying cannot keep it alive, but a single client retrying 10 or more times per 15 minutes also keeps itself locked. The UI hint 'Enter the code provided by your event host' (en.json demoCodeHint) makes the shared-venue NAT case realistic. Not verified: whether the right-most X-Forwarded-For entry is the client or a Supabase hop. That would make the lockout global, and it cannot be tested within the rules. - - -### security-edge-13: Daily quotas are consumed by requests that never reach a model or that fail upstream - -**Severity:** medium (reviewer said low) · **Verdict:** confirmed · **Root cause:** `quota-reserved-before-validation-never-released` - -**Where** - -- supabase/functions/ai/index.ts:506 (quota reserved before unknown-feature failure and before provider calls; no refund path) -- supabase/functions/_shared/control-enforcement.ts:166-172 (Generate throws on short contention count after the call) -- supabase/functions/import-source/index.ts:259-273 (import quota reserved before the body is read or the URL validated) - -**What the reviewer saw** - -Code: consume_daily_quota runs before the feature is validated and before the provider call. No code decrements it on a provider error, a timeout, or the 'Generate response returned N contentions' throw (grep for refund or release finds only voice, which keeps clips on purpose). Staging: each of my 8 refused import-source probes (file:, private IPs, redirects) used one of the expired account's 50 daily imports, because the quota is taken before validatePublicHttpUrl. - -**Why it happens** - -Quota is reserved at the top of the handler, and no handler gives it back. - -**Suggested fix** - -Validate the feature, controls and URL before reserving. For AI, decrement the slot when the provider returns an error with no billed usage, and on Generate post-validation failures. Or retry those once inside the same slot. For import, reserve after validatePublicHttpUrl. - -**Who it hits** - -A trial student (5 a day) can lose a day's requests to an OpenRouter outage or a short model reply, and sees a raw error each time (see security-edge-3). - -**Independent check** - -Evidence: code only. Forcing provider failures on staging would spend shared quota. checkRateLimit calls consume_daily_quota (entitlements.ts:172-175) at ai/index.ts:506, before feature validation, model routing and the provider call. A repo-wide grep for 'request_count - 1|release_daily|refund' in migrations and functions finds no release path. Generate throws after the call when contentions fall short (control-enforcement.ts:166-172), and the slot is spent. import-source reserves its quota (line 259) before reading the body or calling validatePublicHttpUrl (line 295). My 4 import probes each took an expired-account slot. With a trial limit of 5 a day, every upstream failure costs 20% of the day. Raised from low to medium because most students are on trial. security-db-6 is the same cause and fix for AI. - -**Evidence files** - -[probe-auth.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-edge/probe-auth.json) - - -### security-db-4: 'Delete All My Data' leaves the user's public share links live, and shares can't be revoked at all - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `share-links-not-erased-or-revocable` · **Same fix as:** security-edge-5 - -**Where** - -- supabase/migrations/20260926090000_current_debates.sql:219-255 (clear_current_user_data: deletes 14 tables, not shared_items) -- supabase/migrations/20260819000000_security_hardening.sql:52-70 (shared_items: no client privileges; get_shared_item serves any unexpired code to anon) -- supabase/functions/share/index.ts:12-14,101-140 (30-day default, 90-day max, no delete action) -- src/locales/en.json:475-483 ('Permanently delete all your saved data' / 'All your saved data has been permanently removed.') - -**What the reviewer saw** - -Replay block P7: victim has 1 debate and 1 share (item_data is a copy of a case). After clear_current_user_data(): debates_left=0, shares_left=1, and as anon get_shared_item('code-1') still returns 'Shared case'. The share function has no delete or revoke path, clients hold no privileges on shared_items, and the Settings UI offers nothing, so a student who regrets sharing waits up to 30 or 90 days. Account deletion does cascade shares (FK changed to ON DELETE CASCADE in 20261001100000), so only the erase path and revocation are affected. - -**Why it happens** - -shared_items was left out when clear_current_user_data was extended, and shares were built create-only. - -**Suggested fix** - -Add DELETE FROM public.shared_items WHERE created_by = v_user_id; to clear_current_user_data in a new migration, and extend account_deletion_test or a clear-data pgTAP test to assert it. Add a 'Stop sharing' action: either a share function DELETE path that checks created_by, or a SECURITY DEFINER revoke_share(code) RPC. Optionally purge expired shares on a schedule. - -**Who it hits** - -Students (mostly minors) who erase their data still have full copies of their cases, chats or sources reachable by anyone with the link, which contradicts the success message and the privacy page. - -**Independent check** - -Evidence: code only (see security-edge-5). clear_current_user_data at 20260926090000:219-255 has no DELETE on shared_items, there is no revoke path, and get_shared_item serves any unexpired code to anon (20260819:59-70). Same fix as security-edge-5. - -**Evidence files** - -[replay-proofs.out.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-db/replay-proofs.out.txt) - - -### security-db-5: Anyone holding the public anon key can write unbounded rows into keepalive_events and overwrite the heartbeat; client-writable tables have no size caps - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `keepalive-anon-table-writes-unbounded` - -**Where** - -- supabase/migrations/20260820070000_reconcile_prod_only_objects.sql:150-170 (keepalive_events_anon_insert WITH CHECK only on source; keepalive_heartbeat_anon_update; GRANT ALL TO anon) -- supabase/migrations/20260923191320_explicit_legacy_table_grants.sql:31 (anon USAGE on keepalive_events_id_seq) -- supabase/migrations/20260129000000_init.sql (cases/attacks/brainstorms/factoids input/output jsonb, sources.content, messages.content: no length CHECKs; only debates has them) - -**What the reviewer saw** - -Replay block P6: as anon, three rows with a 1 MB details payload each were accepted (2930 kB). The only check is source = 'external-keepalive-monitor', which any caller can send. There is no length CHECK on details, no rate limit and no pruning. Staging (staging-keepalive-anon-read.json): anon can read the table, it holds 554 rows (oldest 2026-06-20, newest 2026-10-08T12:25Z), and the external monitor still writes, so the anon path is live. Anon can also PATCH keepalive_heartbeat rows (policy keepalive_heartbeat_anon_update), so an outsider can fake the monitor's heartbeat or hide an outage. On staging, the 'rpc-keepalive' heartbeat touched_at equals the newest event's created_at to the microsecond. That points to a server-side function or trigger in the same transaction that no migration contains, so there is probably more unrecorded drift. Separately, any signed-up account can store unlimited bytes in cases, sources, messages and flowcharts, and the client re-downloads all of it on every load (security-db-7). - -**Why it happens** - -An uptime pinger was given direct anonymous table writes instead of a narrow RPC, and user-writable tables were never given size bounds. - -**Suggested fix** - -Replace both keepalive policies with one SECURITY DEFINER keepalive() RPC (EXECUTE granted to anon) that upserts a single heartbeat row with now() and stores no client payload, then REVOKE INSERT, UPDATE and DELETE on both tables from anon and authenticated (or drop keepalive_events entirely and keep only the heartbeat). Capture whatever function or trigger currently touches 'rpc-keepalive' in a migration. Add CHECK (octet_length(...) <= N) bounds on the large text and jsonb columns of client-writable tables (cases/attacks/brainstorms/factoids/crossfires input and output, sources.content, messages.content, flowcharts nodes and edges, user_settings.feature_configs and custom_system_prompt), sized to what the app can produce. - -**Who it hits** - -An unauthenticated script can fill the database disk, causing a read-only outage or paid disk growth, and can fake the uptime signal. Low effort, since the anon key ships in the JS bundle. - -**Independent check** - -Evidence: code plus staging anon reads. No writes were attempted. 20260820070000:150-170: keepalive_events_anon_insert checks only source = 'external-keepalive-monitor', details is unbounded jsonb, keepalive_heartbeat_anon_update lets anon rewrite heartbeat rows, and both tables get GRANT ALL TO anon. Sequence USAGE is granted to anon at 20260923191320:31. Staging anon SELECT works: 554 rows, id 1 at 2026-06-20T05:24Z to id 554 at 2026-10-08T12:25:27.079212Z, so the policies and grants are live there. The rpc-keepalive heartbeat touched_at equals the newest event's created_at to the microsecond (12:25:27.079212). That points to a server-side writer in the same transaction that no migration defines, which is more undocumented drift. The INSERT itself was not exercised (writes are forbidden). The policy is unambiguous. The sub-claim that client tables lack size CHECKs is plausible from init.sql, but I did not verify it table by table. - -**Evidence files** - -[replay-proofs.out.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-db/replay-proofs.out.txt), [staging-keepalive-anon-read.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-db/staging-keepalive-anon-read.json), [staging-anon-table-probes.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-db/staging-anon-table-probes.json), [probe-api.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/probe-api.json) - - -### security-db-6: A reserved daily quota slot is never released, so provider failures and timeouts use up a trial user's requests - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `quota-reserved-before-validation-never-released` · **Same fix as:** security-edge-13 - -**Where** - -- supabase/migrations/20260920125555_scope_daily_usage_counters.sql:18-55 (consume_daily_quota: increment only, no release counterpart) -- supabase/functions/_shared/entitlements.ts:172-193 (slot reserved before the provider call) -- supabase/functions/ai/index.ts:503-530 and supabase/functions/ai-stream/index.ts:177 (checkRateLimit runs before the model call; no decrement on failure) - -**What the reviewer saw** - -Every migration was searched: no function decrements daily_usage_counters. A grep of supabase/functions for request_count, refund and release_quota found only consume_daily_quota callers (entitlements.ts, import-source, share, voice, crossfire-input). The reservation happens inside checkRateLimit before any provider work, and logUsage records the failure as status 'error' without touching the counter. A trial user has a 5/day limit (entitlements.ts:111), so each upstream 5xx, timeout or parse failure costs 20% of their day. - -**Why it happens** - -Quota reservation was made atomic, but no compensating release was designed for requests that fail before they produce any billable output. - -**Suggested fix** - -Add release_daily_quota(p_user_id, p_quota_key), service_role only, which does UPDATE ... SET request_count = request_count - 1 WHERE user_id = p_user_id AND usage_date = CURRENT_DATE AND quota_key = p_quota_key AND request_count > 0. Call it from ai and ai-stream only when the provider returned an error before any tokens were billed (no usage object), and never for user aborts after streaming began. Add a pgTAP test covering release, the zero floor and the date boundary. - -**Who it hits** - -Trial and Pro users on flaky days see 'You've used all N of your daily requests' after failed attempts that gave them nothing, and that drives support tickets. - -**Independent check** - -Evidence: code only. consume_daily_quota only ever increments (20260819:25-45, re-scoped in 20260920125555), and grep finds no decrement or release in migrations or functions. ai (index.ts:506) and ai-stream reserve before the provider call and only log failures. Same fix as security-edge-13. - - -### security-db-7: Hydration reads every row of every table with no limit, so PostgREST's 1000-row cap silently drops data and a failed messages query blanks every chat - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `hydration-unbounded-whole-table-reads` - -**Where** - -- src/lib/stores/session-store.ts:1082-1092 (nine unbounded select('*') queries) -- src/lib/stores/session-store.ts:1118-1123 (messages .in('chat_id', allChatIds).order('created_at', ascending) across all chats) -- src/lib/stores/session-store.ts:1135-1153,1183 (a failed messages query leaves every chat with data: [] while chats are replaced) -- src/lib/stores/sparring-store.ts:487-497 (unbounded, then sets historyComplete: true) - -**What the reviewer saw** - -supabase/config.toml has no [api] section, so max_rows is Supabase's default of 1000 for hosted projects. A capped response returns the first 1000 rows of the ordered set without an error. Messages are ordered ascending across all chats, so a user past 1000 total messages loses the newest ones in the UI. sources and results (ordered descending) lose the oldest, and sparring history is then marked historyComplete. If the messages query fails (network, 5xx, or a 414 once hundreds of chat UUIDs at ~37 bytes each make the URL too long), messagesRes is null, every chat is rebuilt with data: [], savedChats is replaced, and the result is persisted to localStorage, although the comment says a failed list keeps its last state. serverHydration does not consider messages. Not reproduced live: no seeded account has more than 1000 rows. - -**Why it happens** - -Hydration assumes small accounts and fetches whole tables in one request, relying on RLS alone for filtering and on PostgREST returning everything. - -**Suggested fix** - -Scope hydration to the Current Debate plus Unsorted, select the list columns only (not content or output), and page with .range() until a short page comes back, or use count=exact and compare. Fetch messages per chat when a chat is opened, using an index on messages (chat_id, created_at). Treat a failed messages query like the other failed lists: keep each chat's previous data and mark hydration partial. - -**Who it hits** - -Heavy users, such as a student using Ask all term, see missing recent messages or sources with no error shown. Any transient failure makes their chat history look empty until the next reload. - -**Independent check** - -Evidence: browser repro, staging config and code. The blanking path reproduced on the pro account (4 chats, 10 messages) with /rest/v1/messages aborted in this browser only. History > Chats went from '2 messages' each to '0 messages' for all three chats in scope, and Resume opens an empty 'Start a conversation' view (screenshots). Code: settledData returns null on failure, messageRows becomes [], and savedChats is still replaced because chatsData is non-null (session-store.ts:1118-1123, 1135-1153, 1183). serverHydration ignores messages. The Management API shows staging PostgREST max_rows = 1000 (staging-postgrest-config.json), and all nine lists plus messages are unbounded select('*'), so the silent cap applies. Messages are ordered ascending across all chats, so the newest drop first. The 1000-row truncation itself is not reproduced live, since no account has more than 1000 rows. - -**Evidence files** - -[db7-history-chats-normal.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/raw/db7-history-chats-normal.png), [db7-history-chats-messages-failed.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/raw/db7-history-chats-messages-failed.png), [db7-ask-opened-chat-messages-failed.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/raw/db7-ask-opened-chat-messages-failed.png), [db7-messages-blank.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/db7-messages-blank.json), [staging-postgrest-config.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/staging-postgrest-config.json), [browser-messages-blank.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/scripts/browser-messages-blank.mjs) - - -### security-edge-7: import-source HTML parser: catastrophic regex backtracking with the deadline already cleared, and an out-of-range entity crashes the import - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `import-parser-regex-unbounded-and-entity-crash` - -**Where** - -- supabase/functions/import-source/index.ts:313 (deadline cleared before parsing) and :315 (stripHtml/normalizeText run with no time bound) -- supabase/functions/import-source/index.ts:125-126 (getMeta regex: [^>]* twice within one tag) -- supabase/functions/import-source/index.ts:141-160 (stripHtml lazy [\s\S]*? with backreferences) -- supabase/functions/import-source/index.ts:111-120 (decodeEntities String.fromCodePoint without a range check) - -**What the reviewer saw** - -Local Deno run over a copy of the parser functions, load average 8.7 (import-parser-timing.txt). Real pages truncated to the 1 MB cap parse fast: BBC 13-26 ms, Guardian 16-17 ms, two Wikipedia pages 25-38 ms. Crafted inputs: 320 KB of an unclosed `<meta name='og:title'...` took 13.7 s; 750 KB of `<nav>` took 37.6 s; 1 MB of unclosed `<div class="content">` took 3.6 s. Staging: importing https://httpbin.org/base64/<html with �> returned 400 'Invalid code point 99999999' (probe-auth.json). Any page with one malformed numeric entity cannot be imported at all. - -**Why it happens** - -Regex-based HTML extraction runs over attacker-chosen input, the 10 s deadline does not cover it, and the entity decoder trusts numeric values. - -**Suggested fix** - -In decodeEntities, return U+FFFD when code > 0x10FFFF or is a surrogate. Replace the regex extraction with a linear parser (deno-dom or linkedom, parsed once), or rewrite the patterns to scan tag-local text with no nested quantifiers. Keep the deadline running through parsing, and return 422 when it fires. - -**Who it hits** - -A hostile page stalls an import worker until the hosted CPU limit kills it, which can also delay other users' imports on that worker. Each attempt uses one of the attacker's 50 daily imports. Legitimate pages with a stray bad entity fail with a cryptic error. - -**Independent check** - -Evidence: staging HTTP plus local timing of my own copy of the parser (lines 105-233 of import-source/index.ts). Staging: importing an httpbin page with � returned 400 'Invalid code point 99999999' (probe-hold.json edge7_import_bad_entity). My first attempt got 422 because httpbin rejected base64url, a probe error and not a refutation. Locally, the bad entity throws RangeError. getMeta on a repeated unclosed "<meta name='og:title' " is roughly cubic: 11 KB 1.3 s, 22 KB 8.2 s, 44 KB 63.7 s. The cap is 1 MB (resource-limits.ts:3). stripHtml on unclosed <nav> is quadratic: 100 KB 0.64 s, 200 KB 2.36 s. The reviewer's 320 KB space-padded meta case did not reproduce (6-10 ms) because spaces do not trigger it, but the repeated-tag form does. clearTimeout(deadlineTimer) runs at line 313, before parsing at 315. Load average was about 2 during timing. - -**Evidence files** - -[import-parser-timing.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-edge/import-parser-timing.txt), [import-parser-timing.ts](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-edge/import-parser-timing.ts), [import-parser-copy.ts](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-edge/import-parser-copy.ts), [probe-auth.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-edge/probe-auth.json), [import-parser-timing.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/import-parser-timing.json), [import-parser-meta-timing.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/import-parser-meta-timing.json), [probe-hold.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/probe-hold.json), [parser-copy.ts](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/scripts/parser-copy.ts), [meta-timing.ts](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/scripts/meta-timing.ts) - - -### security-edge-8: Sparring, crossfire-signal and voice signing keys fall back to the service-role key; SPARRING_INTEGRITY_SECRET is unset on staging - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `signing-secrets-fallback-to-service-role` - -**Where** - -- supabase/functions/ai/runtime.ts:35-36 -- supabase/functions/_shared/voice-token.ts:55-57 -- supabase/functions/ai/features/crossfire-input.ts:223-233 (HMAC keyed with sparringIntegritySecret) -- supabase/functions/ai/index.ts:463-467 (also accepts tokens keyed with the raw service-role key) -- supabase/functions/_shared/demo-code.ts:52 -- scripts/backend-status.mjs:26-29 (does not flag the missing secrets) - -**What the reviewer saw** - -`supabase secrets list --project-ref brifeyypjauyusmkptkx -o json | jq -r '.[].name'` (names only) shows DEMO_CODE_PEPPER but no SPARRING_INTEGRITY_SECRET. On staging, sparring transcript integrity, crossfire signal tokens and voice tokens are therefore all HMACs derived from SUPABASE_SERVICE_ROLE_KEY. docs/standards/security.md says: 'One secret, one purpose. Do not derive signing keys from the service-role key; rotating it would break every outstanding token (#13)'. supabase/README.md:175 documents the fallback. Production was not checked (off limits). - -**Why it happens** - -An optional secret with a silent fallback, which the drift check does not report. - -**Suggested fix** - -Set SPARRING_INTEGRITY_SECRET (and confirm DEMO_CODE_PEPPER) on staging and production; this needs Adam's authorisation. Then make both required: fail closed at module load, drop the service-role entry from the verify key list, and add both to EXPECTED_SECRETS in scripts/backend-status.mjs. - -**Who it hits** - -Rotating the service-role key silently invalidates in-flight sparring rounds and voice segments, and every demo code where the pepper is unset. The most powerful credential also does double duty as an HMAC key. - -**Independent check** - -Evidence: staging secret names only, plus code. `supabase secrets list --project-ref brifeyypjauyusmkptkx` shows DEMO_CODE_PEPPER present and SPARRING_INTEGRITY_SECRET absent. So sparring integrity (runtime.ts:35-36, derived from the service-role key), crossfire signal tokens (crossfire-input.ts:223-233) and voice tokens (voice-token.ts:55) all key off SUPABASE_SERVICE_ROLE_KEY on staging. ai/index.ts:463 also accepts MACs keyed with the raw service-role key. The fallback is documented on purpose (supabase/README.md:173; backend-status.mjs:26-29 deliberately leaves both names out of EXPECTED_SECRETS), but it contradicts docs/standards/security.md:18. Production not checked. - -**Evidence files** - -[backend-status-staging.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-edge/backend-status-staging.txt), [staging-secret-names.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/staging-secret-names.txt) - - -### security-edge-9: Account delete and export need only a bearer token; every holder of a multi-use demo code is the same account - -**Severity:** low · **Verdict:** partly · **Root cause:** `destructive-account-actions-no-reauth` - -**Where** - -- supabase/functions/account/index.ts:118-136 (the confirmation is the caller's own email or 'DELETE'; no recent-auth check) -- supabase/functions/demo-code-login/index.ts:244-256 (one auth user per code email; later uses sign into the same user) - -**What the reviewer saw** - -Code: an access token, valid for up to an hour, plus the account's email is enough to export everything or delete the account. The typed confirmation only gates the UI. demo-code-login maps a code to a single email and user. With max_uses > 1, every person who redeems the code is the same auth user. Any of them can read the others' chats and cases through 'Download my data' or the app itself, or delete the shared account; the next login recreates it empty. - -**Why it happens** - -Destructive account actions trust any live session, and demo codes model a shared login rather than one user per redemption. - -**Suggested fix** - -Require a fresh authentication for delete: reject when the JWT's auth_time or iat is older than about 10 minutes, or require a just-sent email OTP. Disable account delete and export for demo-code users (app_metadata.demo_access_code_id), or create one user per redemption. Show the shared nature in the admin demo-code form. - -**Who it hits** - -A leaked or shared-device token can wipe a student's account. If a teacher hands one demo code to a class, students see and can destroy each other's work. - -**Independent check** - -Evidence: code only. Delete or export of the account needs no live repro. Confirmed: account/index.ts:118-136 accepts any valid bearer token, and the confirmation is just user.email or 'DELETE' (deletionConfirmation, line 35). There is no recent-auth or auth_time check. That is common practice and low risk. Partly: one auth user per demo code, shared by every redemption (demo-code-login/index.ts:244-256), is documented design ('Existing demo accounts are reusable only when ... bound to that code', docs/architecture.md:121). The 'teacher gives one code to a class' scenario is a usage assumption, not a bug in the flow. A max_uses > 1 code does, however, mean every holder sees and can delete the same data, and the admin form does not say so. - - -### security-edge-10: CORS: export-gdoc allows any localhost/LAN origin in production; preview pattern trusts any matching *.vercel.app host; no Vary: Origin - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `cors-dev-origins-in-prod-path` - -**Where** - -- supabase/functions/export-gdoc/index.ts:733 (getCorsHeaders(origin, { allowDevOrigins: true })) -- supabase/functions/_shared/cors.ts:27-28 (VERCEL_PREVIEW_ORIGIN regex) -- supabase/functions/_shared/cors.ts:73-87 (origin echoed without Vary) - -**What the reviewer saw** - -Staging preflights (probe-cors.json). export-gdoc echoes Access-Control-Allow-Origin for http://192.168.1.5:8080 and http://localhost:4444. All 10 functions using the shared CORS helper echo https://debate-navigator-zz-review-probe-adam-gaborjanis-projects.vercel.app; workspace-invites has its own list and does not. https://evil.example and http://localhost.evil.com correctly get the fallback origin. Responses carry only 'Vary: Accept-Encoding'. Whether another Vercel account can claim a single-label name like debate-navigator-<x>-adam-gaborjanis-projects.vercel.app is unverified. - -**Why it happens** - -On-device testing flags were left on in the production code path, and the preview allowlist uses a broad pattern. - -**Suggested fix** - -Remove allowDevOrigins from export-gdoc, or read it from an env var that only local stacks set. Add 'Vary: Origin' to getCorsHeaders. Optionally tighten the preview regex to Vercel's generated hash segment (e.g. -[a-z0-9]{9}-adam-gaborjanis-projects). - -**Who it hits** - -Low today. Auth is bearer-only, so an allowed origin gains nothing without a stolen token. It does widen the trusted-origin set, which demo-code-login also reuses for magic-link redirectTo. - -**Independent check** - -Evidence: staging HTTP preflights. export-gdoc echoed Access-Control-Allow-Origin for http://192.168.1.5:8080 and http://localhost:4444. ai fell back to https://debate.pi-stack.org for the LAN origin and for https://evil.example. ai echoed the lookalike https://debate-navigator-zz-review-probe-adam-gaborjanis-projects.vercel.app. Every response carried only 'Vary: Accept-Encoding'. Code: export-gdoc/index.ts:733 passes allowDevOrigins:true, and cors.ts:27-28 is the broad preview regex. Impact stays low because auth is bearer-only. Missing Vary: Origin matters only if a cache sits in front, which is nit-level. - -**Evidence files** - -[probe-cors.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-edge/probe-cors.json), [probe-cors.mjs](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-edge/probe-cors.mjs), [probe-api.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/probe-api.json) - - -### security-edge-11: Web research page text enters prompts with no untrusted-content boundary - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `web-research-outside-untrusted-boundary` - -**Where** - -- supabase/functions/_shared/research.ts:575-598 (buildResearchContext concatenates Exa page text as plain text) -- supabase/functions/ai/features/generate.ts:79-84 (searchContext goes into the user message) -- supabase/functions/_shared/prompts/context.ts:16-21 (UNTRUSTED_CONTENT_RULES names only <current-debate> and <source>) -- supabase/functions/_shared/prompts/system.ts:79 (rules added only when sources or debate context are present) - -**What the reviewer saw** - -Code: up to 5 fetched pages × 4,000 characters, plus search snippets from arbitrary websites, are inserted under '=== WEB RESEARCH RESULTS ===' with no delimiting tags, no neutralizeStructureTags, and no rule saying this text is data and never instructions. A Generate request with no Library sources and no debate context gets no untrusted-content rules at all, even though research ran. - -**Why it happens** - -The untrusted-content boundary was designed for student material, and third-party web text was never brought inside it. - -**Suggested fix** - -Render each search result and page inside <web-result url=... quality=...> blocks passed through neutralizeStructureTags (and add web-result to STRUCTURE_TAG). Add a line to UNTRUSTED_CONTENT_RULES covering web results, and include the rules whenever researchContext is set. - -**Who it hits** - -A page crafted for Exa's index can steer case or factoid output: false framing, or instructions to drop citations. The impact is limited to misleading content shown to the student; no data crosses users. - -**Independent check** - -Evidence: code only. buildResearchContext (research.ts:573-596) concatenates search results and up to 4,000 chars of each page as plain text, with no tags and no neutralizeStructureTags. UNTRUSTED_CONTENT_RULES (prompts/context.ts:16-21) names only <current-debate> and <source>. buildSystemPrompt adds those rules only when sources or debateContext exist (system.ts:79). RESEARCH_RULES (common.ts:50-54) cover citation quality only, not 'treat this as data'. Most tool runs happen inside a Current Debate, so the rules are usually present. They just do not cover web text. - - -### security-edge-12: export-gdoc returns success with an empty Google Doc when batchUpdate fails - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `gdoc-batchupdate-failure-swallowed` - -**Where** - -- supabase/functions/export-gdoc/index.ts:903-930 - -**What the reviewer saw** - -Code: after the document is created, a failed batchUpdate (`!batchUpdateResponse.ok`) is only console.error'd. The function then returns 200 {success:true, documentUrl}, so the student opens a blank document titled 'Debate Case' or similar. - -**Why it happens** - -Content insertion failures are logged and never returned to the caller. - -**Suggested fix** - -Return 502 {code:'GOOGLE_DOC_UPDATE_FAILED'} with a localized client message. Optionally delete the empty document through the Drive API (needs drive.file scope), or return its URL marked as partial. - -**Who it hits** - -Students who rely on Google Docs export for class submissions occasionally get an empty doc with a success toast. - -**Independent check** - -Evidence: code only. Google export needs real OAuth, which is not available. export-gdoc/index.ts:902-918: when batchUpdate returns !ok, the function only calls console.error, then returns 200 {success:true, documentUrl} (920-929). - - -### security-edge-14: Student debate text is written to Edge Function logs - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `student-content-in-function-logs` - -**Where** - -- supabase/functions/_shared/research.ts:442,468,488,304,326 (claim, seed and follow-up queries, failed Exa queries and URLs) -- supabase/functions/ai/features/generate.ts:37 (motion) -- supabase/functions/ai/request.ts:33 (first 200 characters of rejected input) -- supabase/functions/ai/index.ts:188 (pre-auth feature string) - -**What the reviewer saw** - -Code: console.log/warn writes the student's motion, research claims and generated queries, plus 200-character snippets of inputs the injection filter rejected, into Supabase function logs on every research run. docs/standards/security.md forbids user content in telemetry; logs are the same data with a different sink. - -**Why it happens** - -Debug logging kept from development. - -**Suggested fix** - -Log request ids, lengths and counts only. Delete the motion, claim and query interpolations and the rejected-input snippet. - -**Who it hits** - -Minors' debate content sits in operator-visible logs outside the export and deletion paths. - -**Independent check** - -Evidence: code only. research.ts:443 logs the claim, 471 the seed query, 483 follow-up queries, and 285/297/318/325 failed queries and URLs. generate.ts:37 logs the motion. request.ts:33 logs 200 chars of rejected input. ai/index.ts:188 logs the raw feature string before auth. - - -### security-edge-15: Admin: user list stops at the first 50 accounts with 4 queries each; stats loads every ai_requests row; NaN days gives 500 - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `admin-aggregation-in-js-library-defaults` - -**Where** - -- supabase/functions/admin/index.ts:383 (listUsers() with no page or perPage, so the GoTrue default page of 50, then .slice(0, 100)) -- supabase/functions/admin/index.ts:386-410 (4 sequential queries per user) -- supabase/functions/admin/index.ts:273-286 (parseInt(days) unchecked; collectAdminUsagePages loads every row in the window) - -**What the reviewer saw** - -Code: users never shows account 51 onwards, so an admin cannot find a later user to change their role or limit. Each load issues about 200 database queries. days=abc produces an Invalid Date, toISOString throws, and the endpoint returns 500 'Invalid time value'. stats pages through every ai_requests row since `since` into memory, which security-edge-1 makes far worse. - -**Why it happens** - -Admin endpoints aggregate in JS and rely on library defaults. - -**Suggested fix** - -Paginate users with an email search, and join usage, roles and settings in one SQL RPC. Clamp days to 1..90. Move the stats aggregation into SQL with GROUP BY. - -**Who it hits** - -Only Adam and other admins notice. The dashboard grows slower and less complete as the user base and ai_requests grow. - -**Independent check** - -Evidence: code only. The admin endpoint is off limits. admin/index.ts:383 calls listUsers() with no page or perPage, so it gets GoTrue's default page of 50, then .slice(0,100). Correction: the 4 queries per user are sequential within a user, but users run under Promise.all, so a page load is a burst of about 200 concurrent queries, not 200 sequential ones. stats: parseInt(days) is unchecked (272-274). NaN gives an Invalid Date, toISOString throws RangeError, and the endpoint returns 500. collectAdminUsagePages loads every row in the window into memory (278-287). - - -### security-edge-16: Hidden Workspace backend is still live: workspace-invites is deployed and reachable, and ai accepts workspaceContext - -**Severity:** low · **Verdict:** partly · **Root cause:** `hidden-workspace-backend-still-deployed` - -**Where** - -- supabase/functions/workspace-invites/index.ts (deployed on staging) -- supabase/functions/ai/index.ts:234-262, 394-457 (workspaceContext handling and start/complete_workspace_thread_generation RPCs) - -**What the reviewer saw** - -npm run backend:status against staging reports 15/15 functions deployed, workspace-invites included. An unauthenticated POST to workspace-invites returns 401 (probe-hold.json), so the endpoint is reachable. It also shows the >512 KB hold (security-edge-2). The production ai function still parses body.workspaceContext and runs the workspace RPCs for members. Production deploy state was not checked. - -**Why it happens** - -The Workspace UI was hidden behind VITE_COLLABORATION_URL, but its backend stayed deployed and wired into ai. - -**Suggested fix** - -Where Workspace is off, undeploy workspace-invites (operations step, needs authorisation). In ai/index.ts, reject workspaceContext with 400 unless a server-side flag (e.g. WORKSPACE_ENABLED) is set. That also lets the workspace branches move out of the hot path. - -**Who it hits** - -Extra attack surface and code in the main AI request path, for a feature no production user can reach. - -**Independent check** - -Evidence: staging HTTP plus function list. workspace-invites is deployed on staging (version 1, created and last updated 2026-10-02T20:45Z), and an unauthenticated POST returns 401, so it is reachable. It also shows the 1 MB hold from edge-2. That contradicts docs/staging.md:50, which says workspace-invites is 'never deployed', so this is undocumented staging drift from a hand deploy. The function itself is the out-of-scope hidden Workspace. The in-scope part is ai/index.ts:381-457, which parses workspaceContext on the main request path. Membership is checked (loadAuthorizedWorkspaceContext), so there is no auth hole, just dead surface. Production deploy state is unknown. - -**Evidence files** - -[backend-status-staging.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-edge/backend-status-staging.txt), [probe-hold.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-edge/probe-hold.json), [staging-functions-list.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/staging-functions-list.json), [probe-api.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/probe-api.json) - - -### security-db-3: Client-role grants drift by environment: replay gives anon TRUNCATE on 28 tables, SELECT on global prompts and admin RPCs, while staging was hardened out of band - -**Severity:** low (reviewer said medium) · **Verdict:** partly · **Root cause:** `client-role-grants-rely-on-platform-defaults` · **Known in plan:** next-15 open follow-ups from #80 - -**Where** - -- supabase/migrations/20260204000000_admin_improvements.sql:63-67 (rate_limits_select_own includes user_id IS NULL; global_prompts_select_all USING (true), roles {public}) -- supabase/migrations/20260204000000_admin_improvements.sql:151-153 (GRANT EXECUTE get_daily_usage/get_usage_by_user/get_active_rate_limits TO authenticated; PUBLIC EXECUTE never revoked) -- supabase/migrations/20260923191320_explicit_legacy_table_grants.sql (grants what clients need but revokes nothing extra) -- supabase/migrations/20260820070000_reconcile_prod_only_objects.sql:49,115,154,170 (explicit GRANT ALL TO anon on curated_sources, evidence_cards, keepalive_*) - -**What the reviewer saw** - -Fresh replay catalog (replay-catalog/final-table-grants.txt): anon and authenticated hold DELETE, INSERT, REFERENCES, SELECT, TRIGGER, TRUNCATE and UPDATE on 28 public tables, from Supabase default privileges. That list includes cases, sources, debates, messages, subscriptions, user_roles, user_settings (TRUNCATE, TRIGGER, REFERENCES), promo_codes and global_prompts. Replay P8b: as anon, TRUNCATE public.cases removed another user's row (count went from 1 to 0, rolled back). RLS does not apply to TRUNCATE. Replay P9: anon sees global_prompts rows and global rate_limits rows. final-functions.txt: get_daily_usage, get_usage_by_user and get_active_rate_limits are EXECUTE for PUBLIC and anon (guarded inside by require_admin). Staging (staging-probes.json) differs: global_prompts and promo_codes return 42501 for both anon and authenticated, and get_daily_usage returns 42501 even for authenticated, which 20260204 explicitly granted. So someone tightened staging by hand and recorded it nowhere. rate_limits anon SELECT still returns 200 on staging (0 global rows exist today). The #80 PR found the TRUNCATE leak on six tables; it actually covers 28. PostgREST cannot issue TRUNCATE today, so that part is defense in depth. Production grants are unverified. - -**Why it happens** - -Tables and functions created by the postgres role inherit Supabase's default privileges for anon and authenticated (ALL on tables, EXECUTE on functions), and migrations never revoke what clients don't need. Hand fixes on hosted projects don't flow back into migrations, so CI, db reset, verify stacks, staging and production each end up with different grants, and backend:status can't detect it. - -**Suggested fix** - -One migration that: REVOKE TRUNCATE, TRIGGER, REFERENCES ON ALL TABLES IN SCHEMA public FROM anon, authenticated; revokes every privilege on server-only tables (global_prompts, promo_codes, daily_usage_counters, rate_limits for anon) and anon SELECT on user_settings and similar; DROP POLICY global_prompts_select_all and narrow rate_limits_select_own to TO authenticated USING (user_id = (select auth.uid())); REVOKE EXECUTE ... FROM PUBLIC, anon on the three admin RPCs (or drop the two unused ones, see security-db-11); and ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES/FUNCTIONS FROM anon, authenticated so new objects start closed. Add a pgTAP check that no client role holds TRUNCATE, TRIGGER or REFERENCES on any public table and that anon has EXECUTE only on get_shared_item. Then confirm production's ACLs with a read-only catalog query (Adam's call). - -**Who it hits** - -Today it mainly leaks operator prompts and limits, and it means the security posture can't be read from the repo. Any future SQL-injection or SECURITY INVOKER mistake would let a client TRUNCATE every user's data, bypassing RLS. - -**Independent check** - -Known in plan: next-15 open follow-ups from #80. Evidence: staging HTTP plus code. Staging grants differ from what the migrations produce. authenticated gets 42501 'permission denied for function get_daily_usage' (and get_usage_by_user), although 20260204:151-152 grants EXECUTE to authenticated and 20260819:84 uses CREATE OR REPLACE, which keeps the ACL. anon holds SELECT on user_settings and rate_limits (200 []), which no migration grants to anon, but gets 42501 on global_prompts and promo_codes, which platform defaults would also have granted. So hand changes on staging are real and undocumented. rate_limits_select_own still allows user_id IS NULL rows to anon (staging has 0 such rows). Not verifiable without a replay DB, which I did not run: the '28 tables with anon TRUNCATE' count. Repo fact: 20260820070000 explicitly runs GRANT ALL TO anon on curated_sources, evidence_cards, keepalive_events and keepalive_heartbeat. That gives anon TRUNCATE on those everywhere (sparring_sessions was later revoked, 20260830:44), and no migration revokes TRUNCATE. PostgREST cannot issue TRUNCATE, so it is defense in depth. Production unverified. - -**Evidence files** - -[final-table-grants.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-db/replay-catalog/final-table-grants.txt), [final-functions.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-db/replay-catalog/final-functions.txt), [replay-proofs.out.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-db/replay-proofs.out.txt), [staging-probes.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-db/staging-probes.json), [staging-anon-table-probes.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-db/staging-anon-table-probes.json), [probe-api.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-1/probe-api.json) - - -### security-db-8: Judged sparring rounds are saved with no debate; a separate client call files them, and it is skipped if the student leaves during judging - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `sparring-claim-insert-missing-debate-id` - -**Where** - -- supabase/migrations/20260830000000_complete_sparring_sessions.sql:98-106 (claim INSERT has no debate_id) -- supabase/functions/ai/sparring-persistence.ts:530-547,550-565 (payload has no debate_id) -- src/pages/SparringPage.tsx:230-239 (attachSessionToDebate runs only if the same session is still on screen) -- src/lib/stores/sparring-store.ts:456-469 - -**What the reviewer saw** - -The server already receives the debate (the debateContext of the score request) but claim_sparring_score_session inserts the row with debate_id NULL. The client then calls attach_to_debate('sparring', ...) in .then(), only if judgeRequestRef and currentSession still match. If the student reloads or navigates away while the judge runs (tens of seconds with a real model), or the attach call fails (toast 'Could not save this result to the debate'), the judged round stays in Unsorted. This breaks the 'every saved result belongs to one debate' rule until the student files it by hand. - -**Why it happens** - -Debate assignment was added after sparring persistence and implemented as a second, non-atomic client step. - -**Suggested fix** - -Pass the validated debate id in the claim payload and set debate_id (and side) in claim_sparring_score_session's INSERT. The composite FK (user_id, debate_id) already guarantees ownership. Remove the client-side attach for sparring. - -**Who it hits** - -Students who don't wait on the Sparring page find their judged round missing from the debate and sitting in Unsorted. - -**Independent check** - -Evidence: code only. A judge round costs AI, and the shared pro account should not get a stray round. claim_sparring_score_session inserts without debate_id (20260830000000:98-106), and no later migration redefines it. The sparring-persistence payload has no debate_id. SparringPage.tsx:230-239 calls attachSessionToDebate only inside .then, guarded by judgeRequestRef and the current session id, so a reload or navigation during judging skips it. Hydration (sparring-store.ts:487-497) does not re-attach. - - -### security-db-9: Sixteen legacy policies call bare auth.uid() with roles {public} instead of (select auth.uid()) TO authenticated - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `legacy-rls-bare-auth-uid` - -**Where** - -- supabase/migrations/20260129000000_init.sql:150-197 (user_settings_*, cases_all, attacks_all, brainstorms_all, factoids_all, chats_all, messages_* x4, sources_all, source_files_all, api_keys_all) -- supabase/migrations/20260206103000_create_flowcharts.sql:17-29 (flowcharts_* x4) -- supabase/migrations/20260521000001_source_folders_table.sql:23-24 (source_folders_all) - -**What the reviewer saw** - -replay-catalog/final-policies.txt shows qual '(user_id = auth.uid())' with roles {public} on these policies, while newer tables use (SELECT auth.uid()) TO authenticated. The messages policies run an EXISTS subquery on chats for every message row. Supabase's advisor rule auth_rls_initplan flags this pattern because hosted auth.uid() parses the JWT claims per evaluation. Honest scale note: on the replay image (where auth.uid() is a simple current_setting), EXPLAIN of SELECT count(*) FROM messages as authenticated over 20k rows used a hashed subplan and took about 4 ms, so the cost is modest at today's sizes. Policies for {public} are also evaluated for anon requests that the grants allow. - -**Why it happens** - -Init-era policies predate the project's later convention. - -**Suggested fix** - -One migration that drops and recreates these policies as TO authenticated USING/WITH CHECK (user_id = (select auth.uid())). For messages, use EXISTS (SELECT 1 FROM chats WHERE chats.id = messages.chat_id AND chats.user_id = (select auth.uid())). Existing owner_scoped pgTAP tests cover behavior. - -**Who it hits** - -Small CPU cost on every list query that grows with history size. Mostly a consistency and advisor-noise fix. - -**Independent check** - -Evidence: code only. Correction: 20 policies, not 16. 15 survive from init.sql (user_settings select/insert/update, cases/attacks/brainstorms/factoids/chats _all, messages x4, sources_all, source_files_all, api_keys_all; ai_requests_all and user_settings_delete were dropped in 20260819). Add 4 flowcharts policies (20260206103000:18-29) and source_folders_all (20260521000001:24). All use bare auth.uid() with no TO clause, so roles {public}. None were recreated later. The performance impact is modest at current sizes. This is mostly consistency and advisor noise. - -**Evidence files** - -[final-policies.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-db/replay-catalog/final-policies.txt), [replay-explain-auth-uid.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-db/replay-explain-auth-uid.txt) - - -### security-db-10: Items can be moved between the owner's own debates by direct UPDATE, and pins are never cleaned up when their item is deleted or moved - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `debate-id-reassignment-unguarded` - -**Where** - -- supabase/migrations/20260129000000_init.sql:157-175 (cases_all etc.: FOR ALL with no column restriction) -- supabase/migrations/20260926090000_current_debates.sql:89-104 (require_source_debate only blocks detaching; attach_to_debate only attaches from NULL) -- supabase/migrations/20260927170000_debate_context_pins.sql:6-18 (item_id has no FK; no cleanup trigger) - -**What the reviewer saw** - -Replay block P5: as the owner, UPDATE cases SET debate_id = <debate B> on a case in debate A succeeded. After DELETE of the case, the pin row remained (pins_left_after_item_delete = 1). attach_to_debate deliberately allows only NULL -> debate, and the sources trigger forbids detaching, but nothing stops A -> B for results or sources. Cross-user moves are blocked by the composite FK (P4), so this is integrity, not security. The client hides dangling pins with resultKeys and the edge loader filters them, so the visible effect is only stale rows plus 'unavailable' counts if a client sends them. - -**Why it happens** - -Debate membership is guarded on attach and detach but not on reassignment, and pins reference polymorphic items without referential cleanup. - -**Suggested fix** - -Add a BEFORE UPDATE OF debate_id trigger on the nine debate-scoped tables that raises when OLD.debate_id IS NOT NULL AND NEW.debate_id IS DISTINCT FROM OLD.debate_id (extend private.require_source_debate). Add AFTER DELETE (and AFTER UPDATE OF debate_id) triggers on the result tables, chats and sparring_sessions that delete matching debate_context_pins rows, or purge pins whose item no longer exists in their debate when loading. - -**Who it hits** - -Small. Today no UI path moves items, but the database doesn't enforce the 'nothing crosses debates' invariant it documents, and pin rows pile up. - -**Independent check** - -Method (applies to all four): a throwaway public.ecr.aws/supabase/postgres:17.6.1.165 container (verify-sec2-pg on 127.0.0.1:55497). This is not the Supabase CLI stack and uses no shared ports or db-gate lock. All 39 migrations applied cleanly (replay-migrations.log). storage.objects was stubbed because the plain image lacks storage-api. The image ships the legacy auth.uid()/auth.role(), which read request.jwt.claim.sub/role, so those settings stood in for PostgREST claims. Staging was probed read-only only, so every write path below was tested on the replay, not on staging. Results: as the owner, UPDATE cases SET debate_id = B on a case in debate A returned UPDATE 1 (P10a). The same worked for sources (P10b), which only blocks setting debate_id to NULL (P10c, 23514). Moving into another user's debate fails on the composite FK (P10d, 23503). attach_to_debate refuses an item already in a debate (P10e). Messages can also cross debates: UPDATE messages SET chat_id = <own chat in debate B> succeeded (replay-db10-messages.out.txt), because messages_update has no WITH CHECK and only checks chat ownership. Grants show authenticated can UPDATE debate_id on cases, attacks, brainstorms, factoids, chats, flowcharts, crossfires and sources. sparring_sessions has no client UPDATE grant. So the scope is 8 tables plus messages, not nine; the fix should cover messages.chat_id too. Pins: after the case moved, its pin stayed in debate A. After DELETE, pins_left_after_item_delete = 1 (P10f). A pin can also be inserted for an item id that never existed (P10g): item_id has no FK. User-visible effect is nil. No UI path updates debate_id (the only client .update calls touch other columns). src/lib/debate/context.ts:158-161 drops dangling pins from resultKeys, DebateContextControl.tsx:139 passes resultKeys whenever hydration is complete, and the edge loader (_shared/debate-context-loader.ts:109-111) re-filters by user_id + debate_id. The pin-limit lockout only happens if hydration failed (serverHydration 'partial'). The pro account's debate_context_pins on staging had 0 rows, so no real dangling pin was observed. Rated low because the documented invariant is unenforced in the DB; the pin-cleanup half alone is a nit. The suggested fix stands: a BEFORE UPDATE OF debate_id guard on the 8 tables plus a chat_id guard on messages, and pin cleanup on delete. - -**Evidence files** - -[replay-proofs.out.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-db/replay-proofs.out.txt), [replay-db10-db11.sql](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-2/replay-db10-db11.sql), [replay-db10-db11.out.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-2/replay-db10-db11.out.txt), [replay-db10-messages.sql](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-2/replay-db10-messages.sql), [replay-db10-messages.out.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-2/replay-db10-messages.out.txt), [replay-migrations.log](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-2/replay-migrations.log), [staging-probes.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-2/staging-probes.json) - - -### security-db-11: Unused but client-writable tables and dead admin RPCs widen the attack surface; source FKs are not owner-scoped - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `dead-prototype-schema-objects` - -**Where** - -- supabase/migrations/20260129000000_init.sql (api_keys, source_files) -- supabase/migrations/20260820070000_reconcile_prod_only_objects.sql:18-49,94-115 (curated_sources GRANT ALL TO anon; evidence_cards GRANT ALL TO anon, source_id REFERENCES sources(id)) -- supabase/migrations/20260819000000_security_hardening.sql:85-108 (get_daily_usage, get_usage_by_user read auth.users emails) - -**What the reviewer saw** - -No code in src/, supabase/functions or scripts references api_keys, source_files, evidence_cards, curated_sources or keepalive_*. get_daily_usage and get_usage_by_user have no caller (only get_active_rate_limits is used, by admin/index.ts:785). Yet all of these are writable by clients under RLS, and the two RPCs are SECURITY DEFINER readers of auth.users.email, executable by authenticated (and by PUBLIC/anon on replay). Replay P3: a user inserted an evidence_card and a source_file pointing at another user's source_id; a random id fails with 23503, so the insert also tells you whether a source id exists (a nit given v4 UUIDs). When the victim deletes the source, the attacker's rows cascade or have their reference set to null. The tables are also covered by export, clear-data and pgTAP code, which is upkeep with no product value. - -**Why it happens** - -Prototype and production-only objects were brought into migrations as they were, and the features that would use them never shipped or were removed. - -**Suggested fix** - -After confirming the production row counts are empty (Adam's call), drop api_keys, source_files, evidence_cards and curated_sources, and DROP FUNCTION get_daily_usage and get_usage_by_user, along with their lines in clear_current_user_data and the tests. If any of them stays, give the source FK (user_id, source_id) -> sources(user_id, id) as debates already does, and revoke anon grants. - -**Who it hits** - -Less code and fewer grants to audit. Removes a cross-user reference path and two dead email-reading definer functions. - -**Independent check** - -Confirmed as dead-schema cleanup. It is not an exploitable surface. No src/, supabase/functions or scripts code references api_keys, source_files, evidence_cards or curated_sources, apart from clear_current_user_data, the export/deletion SQL, pgTAP tests and docs/architecture.md:106 (source_files). The only RPCs called from code are listed by grep; get_daily_usage and get_usage_by_user have no caller. Replay: as user B, INSERT evidence_cards and INSERT source_files with user A's source_id both succeeded (P11a/P11b), even though B cannot SELECT that source. A random source_id fails with 23503 (P11c), an existence oracle that is a nit against v4 UUIDs. When A deletes the source, B's evidence_card.source_id is set to NULL and B's source_file cascades away (P11e). Corrections: (1) the anon GRANT ALL on curated_sources and evidence_cards is moot. Their policies are TO authenticated, so anon reads 0 rows and its inserts fail RLS (replay P11g; staging: anon GET on both returns 200 with 0 rows). (2) Both RPCs are gated by require_admin. On the replay, authenticated and anon get 'administrator access required'. On staging, anon (401) and trial (403) both get 42501 'permission denied for function', and so does get_active_rate_limits. So staging's function ACLs differ from what the migrations produce. The cause is unknown and it is harmless, because admin/index.ts:19 uses the service role. (3) curated_sources is writable only by admins. api_keys, source_files and evidence_cards are writable by any authenticated user for their own rows (trial GET on each returns 200, 0 rows). Fix caveats: keepalive_events and keepalive_heartbeat are written by an external uptime monitor by design (comment in 20260820070000_reconcile_prod_only_objects.sql:139) and must stay out of any DROP list. Dropping source_files also touches docs/architecture.md:106, supabase/tests/legacy_table_grants_test.sql, account_deletion_test.sql and owner_scoped_tables_rls_test.sql. Production row counts are Adam's call. - -**Evidence files** - -[replay-proofs.out.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-db/replay-proofs.out.txt), [final-table-grants.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-db/replay-catalog/final-table-grants.txt), [replay-db10-db11.sql](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-2/replay-db10-db11.sql), [replay-db10-db11.out.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-2/replay-db10-db11.out.txt), [staging-probes.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-2/staging-probes.json) - - -### security-db-12: Index hygiene: duplicate and redundant indexes on hot write tables, plus a few unindexed foreign keys - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `redundant-indexes-unindexed-fks` - -**Where** - -- supabase/migrations/20260208000000_stripe_schema.sql + 20260520000000_payment_correctness.sql (idx_subscriptions_stripe_customer and idx_subscriptions_stripe_customer_id are identical; idx_subscriptions_stripe_sub duplicates the UNIQUE key) -- supabase/migrations/20260204000000_admin_improvements.sql:42-45 (idx_user_roles_user_id, idx_global_prompts_feature, idx_rate_limits_user_id duplicate unique keys) -- supabase/migrations/20260129000000_init.sql:120-127 (idx_{cases,attacks,brainstorms,factoids,chats,sources}_user_id are prefixes of the *_user_debate_idx composites added in 20260926090000) -- supabase/migrations/20260926090000_current_debates.sql:35 (current_debates_user_debate_idx is redundant with the PK on user_id); 20260927170000_debate_context_pins.sql:20 (debate_context_pins_debate_idx is unused because every query and the cascade lead with user_id) - -**What the reviewer saw** - -replay-catalog/all-indexes.txt, by inspection. Exact duplicates: subscriptions (stripe_customer_id x2, stripe_subscription_id x2), promo_codes.code (idx_promo_codes_code plus promo_codes_code_key), global_prompts.feature, user_roles.user_id, demo_access_codes.email (email_idx plus email_key), user_promos.user_id and source_folders.user_id (prefix of a unique key). Prefix-redundant: seven single-column user_id indexes on result tables, each also covered by (user_id, debate_id, created_at DESC). Unindexed FKs (replay-catalog/unindexed-fks.txt): shared_items.created_by (scanned on every account deletion), user_promos.promo_code_id, source_files.source_id and user_id. Missing for the edge loader's 'last 6 messages per chat': messages (chat_id, created_at). My automated redundancy query returned 0 rows because of an int2vector slicing bug, so this list comes from reading the dump by hand. - -**Why it happens** - -Indexes were added migration by migration without pruning the ones newer composites or constraints made obsolete. - -**Suggested fix** - -One migration that drops the duplicates and prefix-redundant single-column user_id indexes (check pg_stat_user_indexes.idx_scan on production first), replaces idx_messages_chat_id with (chat_id, created_at), and adds an index on shared_items (created_by). - -**Who it hits** - -Every saved result, source and message write maintains one or two unnecessary indexes. Account deletion scans shared_items. Small at today's scale. - -**Independent check** - -Checked with catalog queries on the replay, not by hand (replay-indexes.sql/.out.txt). There are 6 exact duplicate pairs: subscriptions has idx_subscriptions_stripe_customer and idx_subscriptions_stripe_customer_id (identical), plus idx_subscriptions_stripe_sub and subscriptions_stripe_subscription_id_key. The others are promo_codes (idx_promo_codes_code / promo_codes_code_key), global_prompts (idx_global_prompts_feature / global_prompts_feature_key), user_roles (idx_user_roles_user_id / user_roles_user_id_key) and demo_access_codes (email_idx / email_key). Correction: idx_rate_limits_user_id is not an exact duplicate. It is prefix-redundant under UNIQUE (user_id, limit_type). Prefix-redundant single-column user_id indexes, each a prefix of (user_id, debate_id, created_at DESC): cases, attacks, brainstorms, factoids, chats, sources and flowcharts. idx_user_promos_user_id and idx_source_folders_user_id are prefixes of unique keys. current_debates_user_debate_idx and debate_context_pins_debate_idx are redundant with their PKs. The PKs are on user_id and (user_id, debate_id, ...), and loadPins gets user_id from RLS. I did not EXPLAIN them, so call them 'redundant', not 'unused'. Unindexed FKs confirmed: shared_items.created_by, user_promos.promo_code_id, and source_files.source_id and user_id. Not in the finding but the same fix: api_keys.user_id, promo_codes.created_by, global_prompts.created_by, curated_sources.created_by and demo_access_codes.created_by. All of them make the auth.users cascade scan on every account deletion. (Workspace FKs also show up but are out of scope.) messages has only idx_messages_chat_id, with no (chat_id, created_at) index for the loader's 'last 6 messages' query. The replay is schema-only: production may differ, and checking idx_scan on production before dropping anything is Adam's call. Impact is small at today's scale; low is generous but fair. - -**Evidence files** - -[all-indexes.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-db/replay-catalog/all-indexes.txt), [unindexed-fks.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/security-db/replay-catalog/unindexed-fks.txt), [replay-indexes.sql](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-2/replay-indexes.sql), [replay-indexes.out.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-2/replay-indexes.out.txt) - - -### security-db-13: PII outlives the account: demo-code email kept after deletion and left out of export; demo login IPs and expired shares never purged - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `no-operational-data-retention` - -**Where** - -- supabase/migrations/20260820070000_reconcile_prod_only_objects.sql:55-87 (demo_access_codes.email has no FK; demo_access_attempts stores ip_address and browser_fingerprint) -- supabase/migrations/20261001100000_account_deletion_and_export.sql:118-123 (export skips demo_access_codes; prepare_account_deletion doesn't touch it) -- supabase/functions/demo-code-login/index.ts:67-73 (inserts every attempt; nothing deletes) -- src/locales/en.json privacy p6 ('Deleting your account permanently removes it and all data stored with it') - -**What the reviewer saw** - -When a demo-code user deletes their account, the demo_access_codes row keeps their email, the admin label (often a name or school), use_count and last_used_at. The export doesn't include it (v_skipped lists demo_access_codes). demo_access_attempts keeps IP addresses and browser fingerprints forever, though the limiter only reads 15 minutes back (demo-code-login/index.ts:78). Expired shared_items and old daily_usage_counters rows are never deleted. No pg_cron jobs or cleanup functions exist in migrations. - -**Why it happens** - -No retention policy or scheduled cleanup exists for operational tables, and the demo-code record is keyed by email instead of user id. - -**Suggested fix** - -Add a service-role-only private.purge_expired_operational_data() that deletes demo_access_attempts older than 30 days, shared_items expired more than 30 days ago and daily_usage_counters older than 90 days. Schedule it with pg_cron or call it from an existing edge job. In the account function, disable the demo code and blank its email when the matching demo user deletes their account, or document that the admin's record is kept. - -**Who it hits** - -Small privacy and compliance gap for an EU student audience. The privacy page promises full removal on deletion. - -**Independent check** - -Replay (replay-db13.out.txt): deleting a demo-code user's auth.users row, which is the last step of account deletion, leaves the demo_access_codes row with its label ('Example School, Year 10' in the test) and email. It also leaves demo_access_attempts with IP 203.0.113.7 and the browser fingerprint, linked through code_hash. Expired shared_items and old daily_usage_counters do cascade with the account (shared_items FK made ON DELETE CASCADE in 20261001100000:21-24). So only the two demo tables outlive the account. For shares and counters, 'never purged' is true only while the account exists. That is retention hygiene, not data outliving deletion. Code confirms: demo-code-login/index.ts:66-75 inserts every attempt. The limiter reads back only 15 minutes (index.ts:78). Nothing deletes from demo_access_attempts (grep plus the pg_proc scan). No pg_cron extension exists, and no purge function in public or private. export_account_data's v_skipped lists demo_access_codes (20261001100000:112), because its ownership column created_by is the admin. The account function has no demo handling. Corrections: the locale key is privacyPage.p6 in src/locales/en.json (line 1738), and the Settings copy deleteAccountSuccessDesc (line 503) also says 'all of its data'. The demo email is admin-supplied or a generated demo-<uuid>@debate.pi-stack.org address (admin/index.ts:457, 523), so the PII is mostly the admin label, any real email the admin entered, and IP addresses. The privacy page never mentions IP logging. Functional consequence that supports the 'disable the code on deletion' fix: the surviving code recreates a fresh account the next time it is used (demo-code-login/index.ts:207-250, where findUserByEmail returns null and createUser runs). Low is right. - -**Evidence files** - -[replay-db13.sql](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-2/replay-db13.sql), [replay-db13.out.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-security-2/replay-db13.out.txt) - diff --git a/plans/2026-10-08-ultracode-review-report/ux.md b/plans/2026-10-08-ultracode-review-report/ux.md deleted file mode 100644 index 8f2a07b6c..000000000 --- a/plans/2026-10-08-ultracode-review-report/ux.md +++ /dev/null @@ -1,2865 +0,0 @@ -# Journeys, AI surfaces, accessibility and Hungarian: findings - -**Status:** Review evidence from 2026-10-08/09, for [the ranked report](../2026-10-08-ultracode-review-report.md). Not started. - -77 findings: 2 high, 32 medium, 37 low, 5 nit and 1 refuted (listed last). An independent verifier re-checked every one. - -Each entry has the final severity, where the problem lives, what the reviewer saw, why it happens, a suggested fix, the independent check and links to the screenshots and measurements in `.verify/ultracode-review-2026-10-08/`. Line numbers are against main at 40738ea. - -Not covered by the journeys reviewer: - -- Signing back in through the UI: admin has no demo code and email OTP would send mail; I re-entered with freshly minted sessions instead -- PDF and DOCX source file import (only .txt tried) -- Share: no UI to create a share link was found on results; /share/:code itself not exercised -- Google Docs export, Export PNG in Flowchart, Discuss, Open in Crossfire, voice input -- The pro account's inactivity gate (I observed the same component on expired and did not touch the shared pro account) -- Spanish and Hungarian UI, dark mode, tablet, small-mobile, iPhone -- Delete All My Data, Delete account, promo codes, admin controls (forbidden) -- Deep AI-tool behaviour (Attack, Factoids, Ask, Crossfire, Sparring), result quality, quota accounting of the timed-out Generate (AI lane) -- Real Stripe checkout success path (no Stripe on staging by design) - -Not covered by the ai-surfaces reviewer: - -- Voice mode (Crossfire/Sparring voice rounds, dictation): cannot be tested headless -- Google Docs export: needs Google OAuth -- A real mid-stream network drop: Chromium's context.setOffline(true) did not cut the open SSE connection (the stream finished), so the 'interrupted stream' UX was only reached by simulation -- Whether the stream-failure fallback double-charges on real traffic: inferred from code plus a simulation, not observed in staging usage logs -- Spanish/Hungarian output and UI: the account language must not be changed (owned by the i18n agent) -- 'Cheapest model' was assumed to be DeepSeek V4 Flash (a non-Pro flash model); pricing not verified. Auto Fast avoided because its routing varies -- Generate's 'web research option' does not exist as a form control: handleGenerate (supabase/functions/ai/features/generate.ts:36) always runs deepResearch, so there was nothing to toggle -- Rate-limit/quota refusal UX against the real server (only simulated with a faked 429) -- Every model in the selector, and medium/high reasoning (kept at the stored Low) -- Ask file attachments -- Discuss buttons against the real AI (simulated only, to save budget) -- WebKit/Safari - -Not covered by the a11y-i18n reviewer: - -- /app/admin (trial is not an admin; admin account owned by the journeys agent) -- A real /share/<code> page (only the not-found state was scanned) -- Sparring, Crossfire and Ask streaming/live regions during an actual round (no extra AI budget spent) -- Voice mode and ElevenLabs audio -- PDF/DOCX/Google Docs export text in hu/es -- Stripe checkout and pricing modal flows -- Login OTP and demo-code error texts in the browser (checked in code only; wrong codes were deliberately not sent) -- axe runs with the UI in hu/es (axe ran in English only) -- Spanish desktop overflow scan (es scanned on mobile only) -- Real screen-reader testing (NVDA, VoiceOver); findings come from DOM/ARIA and live-region mutation measurements -- WebKit and Firefox -- Collaborative Workspace and the iOS shell (out of scope) - -### journeys-1: When the user lookup fails (offline or network blip), every save becomes local-only and the app still reports success - -**Severity:** high · **Verdict:** confirmed · **Root cause:** `getuser-network-fallback-local-only-writes` - -**Where** - -- src/lib/stores/session-store.ts:288-291 (getUserId returns null on any getUser failure) -- src/lib/stores/session-store.ts:506-512 (addSource: no userId -> local-only insert, return) -- src/lib/stores/session-store.ts:767-789 (removeSource/toggleSource: skip server write when userId is null) -- src/pages/LibraryPage.tsx:217-226 (handleAdd shows 'Source Added' toast) -- Same pattern in ~23 getUserId() call sites: saveFlowchart (991-997), saveCurrentCase (816-823), folders, etc. -- /app/library - -**What the reviewer saw** - -Repro (20-offline-add.json): signed in as admin on /app/library, went offline with context.setOffline(true), added the manual source 'ZZ review offline source' and clicked Save to Library. The network log shows only 'FAILED GET /auth/v1/user net::ERR_INTERNET_DISCONNECTED'; no POST to /rest/v1/sources was made. The UI showed the toast 'Source Added. Your source is now available for grounding.' and the row appeared. Toggling a source offline flipped the switch (true) with no error. After going back online and reloading, the source was gone (afterReloadOfflineSource: 0) and the toggle had reverted (false). An 'Offline mode' pill is visible at the same time as the success toast (screenshot 20-offline-add.png). Root cause in code: getUserId() does `const { data } = await supabase.auth.getUser(); return data.user?.id || null;`, and every writer treats null as 'signed-out local mode', writing only to zustand state. - -**Why it happens** - -The store cannot tell 'signed out' apart from 'could not reach auth'. Every write path calls supabase.auth.getUser() (a network request), and on failure falls back to the local-only branch meant for signed-out use. The UI then shows its normal success toast. - -**Suggested fix** - -In session-store.ts, read the user id from the cached session (supabase.auth.getSession() or the AuthProvider user), not getUser(). If there is genuinely no user while the app is in an authenticated route, throw. Delete the 'no userId -> local only' branches for authenticated routes, or make them throw a NetworkError. In LibraryPage/Flowchart/Generate, wrap writes in try/catch and show an error toast with Retry instead of success. Optionally queue writes while offline and keep the form contents. Cover it with a unit test: getUser rejecting must make addSource reject, not resolve. - -**Who it hits** - -Any student on flaky school or phone networks who adds a source, saves a flowchart or saves a case during a blip is told it saved, then loses it on reload. It also silently skips server writes when getUser has a transient 5xx. Borderline critical (silent data loss); held at high because the 'Offline mode' pill is visible. - -**Independent check** - -Reproduced on admin /app/library: setOffline(true), added manual source 'ZZ review offline src' -> only GET /auth/v1/user failed (ERR_INTERNET_DISCONNECTED, twice), no POST /rest/v1/sources, toast 'Source Added. Your source is now available for grounding.' and row shown. Toggling a source offline flipped aria-checked true->false with no error. After online+reload the offline row was gone (0) and the switch was back to true. Code: session-store.ts:288-291 getUserId returns null on any getUser failure; addSource 506-512, removeSource 767-773, toggleSource 775-789 treat null as signed-out local mode. - -**Evidence files** - -[20-offline-add.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/20-offline-add.json), [20-offline-add.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/20-offline-add.png), [20-after-reload.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/20-after-reload.png), [s3-flows.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s3-flows.json), [s3-offline-add-toast.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s3-offline-add-toast.png) - - -### ai-surfaces-1: Ask chat leaves 0–143 px for messages: on phones the answer is invisible - -**Severity:** high · **Verdict:** confirmed · **Root cause:** `ask-chat-fixed-height-card-on-phones` · **Same fix as:** mobile-visual-1 - -**Where** - -- src/components/features/ask/AskChat.tsx:517 (Card h-[calc(100vh-14rem)] min-h-[400px] max-h-[800px]) -- src/components/features/ask/AskChat.tsx:568-700 (shrink-0 composer: mode row, 4 toggles, ModelSelector with model and Reasoning, context control, input) -- route /app/ask - -**What the reviewer saw** - -I measured the message-pane height (the ScrollArea viewport) on an empty chat on each device: -- 1600x1000: 343 px. -- 1280x800: 143 px. -- 1024x700 laptop: 43 px. -- iPad: 321 px. -- Pixel 7 (412x839): 30 px. -- iPhone 14 (390x664): 0 px. -- iPhone SE (320x568): 0 px. - -The always-visible composer (shrink-0) is 307 px tall on desktop and 439–571 px on phones, inside a card capped at 400–800 px (`ask-layout.json`). - -During a real streamed answer on a Pixel 7, the mid-stream screenshot shows no message text at all, and the final screenshot shows only the bubble's bottom border. On desktop the empty-state copy is cut off mid-sentence. Adding the header 'Save' button squeezes the pane further. Even at 1600x1000 the pane stays at or below 343 px because of max-h-[800px]. - -**Why it happens** - -The chat card has a fixed viewport-relative height. Every option control (4 switches, the model and Reasoning selects, the context strip) sits permanently in a shrink-0 footer inside that card, so the flex-1 message area gets whatever height is left, which is often none. - -**Suggested fix** - -In AskChat.tsx: -- Let the message list own the space: use min-h-0 flex-1 with a floor of at least 50–60% of the card. -- Move the mode/toggle/model controls into a collapsible 'Options' popover or sheet (one row on mobile). -- Show the context strip as a compact chip. -- On small screens drop the card height cap and use a full-height layout (100dvh minus the header), with the composer sticky at the bottom. - -Check that the pane is at least 300 px on iPhone SE. - -**Who it hits** - -Every phone user of Ask, which is most students. The answer is unreadable while it streams and after. It is also cramped on 13-inch laptops. - -**Independent check** - -Code: AskChat.tsx:517 Card h-[calc(100vh-14rem)] min-h-[400px] max-h-[800px] with a shrink-0 composer block (AskChat.tsx:582-700). Re-measured on staging (empty chat, pro): message pane 143 px at 1280x800 (card 576, composer 307), 43 px at 1024x700, 46 px on Pixel 7 (composer 423), 0 px on iPhone 14 (card 440, composer 463) and 0 px on iPhone SE (card 400, composer 555). On desktop mid-stream only about 150 px of answer is visible (screenshot). Same cause and fix as mobile-visual-1; this finding adds the desktop/laptop numbers. - -**Evidence files** - -[ask-layout.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/ask-layout.json), [ask-long-mobile-DeepSeekV4Flash-mid-stream.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/ask-long-mobile-DeepSeekV4Flash-mid-stream.png), [ask-long-mobile-DeepSeekV4Flash-final.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/ask-long-mobile-DeepSeekV4Flash-final.png), [ask-layout-small-mobile.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/ask-layout-small-mobile.png), [explore-ask-desktop-light.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/explore-ask-desktop-light.png), [explore-ask-mobile-dark.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/explore-ask-mobile-dark.png), [misc-layout.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/misc-layout.json), [ask-layout-iphone.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/raw/ask-layout-iphone.png), [ask-layout-small-mobile.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/raw/ask-layout-small-mobile.png), [ask-layout-desktop.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/raw/ask-layout-desktop.png), [ask-desktop-midstream.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/raw/ask-desktop-midstream.png) - - -### journeys-2: 'Save' on a case reopened from History creates a duplicate case outside the debate that cannot be deleted where it lands - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `reopened-case-save-creates-unscoped-copy` - -**Where** - -- src/pages/GeneratePage.tsx:38 (recordedCaseId is component state, only set right after generation) -- src/pages/GeneratePage.tsx:395 (onSave shown when recordedCaseId is null) -- src/lib/stores/session-store.ts:816-833 (saveCurrentCase inserts a new cases row with no debate_id) -- src/components/features/debate/UnsortedWork.tsx (no delete action) -- /app/history -> Load case -> Save - -**What the reviewer saw** - -Steps (10-save-dup.json): History > Cases > Load the auto-recorded case. The Save button is visible (saveVisible: 1). Click Save, then Save in the 'Save case' dialog. The toast says 'Case Saved. Your case has been saved to history.' But the Cases tab still shows 1 case, and the copy appears only under 'One-off and unsorted work', which has no delete button (unsortedDeleteButtons: 0). The API afterwards showed two cases rows: e78bafe7 with the debate_id, and b32068f5 with debate_id null. The same happens after any reload or navigation, because recordedCaseId resets to null. Section regenerations after a reload are also not written back (GeneratePage.tsx:159 only updates when recordedCaseId is set). I removed the duplicate by attaching it to the debate and deleting it from Cases (11-attach-delete.json). - -**Why it happens** - -Whether the visible case is already saved is tracked in component state, not on the loaded item. loadSavedCase only sets generateResult, dropping the row id and debate. The legacy saveCurrentCase path predates Current Debate and inserts with no debate_id. - -**Suggested fix** - -Have loadSavedCase store the loaded row id and debate id in the session store (e.g. generateRecordId), and have GeneratePage hide Save, and send regenerations to updateRecordedResult, when that id is set. If Save remains (for one-off results), insert with activeDebateId(). Apply the same check to Attack, Brainstorm, Factoids and Ask, which have saveCurrent* paths. - -**Who it hits** - -Students who press the obvious Save button after reopening work get silent duplicates in a hidden 'unsorted' bucket. The toast says it went to History, but it is not in the debate's History. - -**Independent check** - -Inserted a case in admin's ZZ debate, History > Cases > Load -> Generate shows Save (count 1); Save -> toast 'Case Saved. Your case has been saved to history.' REST then had 2 cases rows: original with debate_id and a copy with debate_id null; Cases tab still lists 1. Code: GeneratePage.tsx:38/90/395 (recordedCaseId is component state, null after load), session-store.ts:816-833 saveCurrentCase inserts without debate_id; GeneratePage.tsx:159 regenerations only written back when recordedCaseId set. UnsortedWork has no delete (UnsortedWork.tsx). - -**Evidence files** - -[10-save-dup.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/10-save-dup.json), [10-save-dialog.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/10-save-dialog.png), [10-history-unsorted.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/10-history-unsorted.png), [11-attach-delete.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/11-attach-delete.json), [s4-case.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s4-case.json), [s4-history-after-save.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s4-history-after-save.png) - - -### journeys-3: No way to delete the Current Debate, the last debate, or any brainstorm, attack, factoid, chat, crossfire or unsorted item - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `missing-delete-paths` - -**Where** - -- src/components/features/debate/DebateDialogs.tsx:90-104 (trash only for non-current debates in DebatePicker, the only deleteDebate caller) -- src/lib/stores/session-store.ts:176,209 (only deleteSavedCase and deleteSavedFlowchart exist; no delete for attacks, brainstorms, factoids, chats, crossfires) -- src/pages/HistoryPage.tsx (trash only on Cases and Sparring tabs) -- src/components/features/debate/UnsortedWork.tsx (no delete) -- src/pages/CurrentDebatePage.tsx (no delete) - -**What the reviewer saw** - -grep shows deleteDebate is called only from DebatePicker, and that picker renders a Check icon instead of a trash button when debate.id === currentDebateId. A user with one debate (e.g. created with a typo, or as a test) can never delete it. A user with several can delete only the non-current ones, so one debate always remains. For cleanup I had to delete both review debates through the REST API. History shows a delete button only for cases and sparring sessions (aria snapshot 09-history.aria.txt). The brainstorm I generated has no delete anywhere in the UI, and neither do chats (which may hold personal text), attacks, factoids, crossfires or one-off items. The only remaining routes are 'Delete All My Data' or deleting the account. - -**Why it happens** - -Delete was built per feature, and the Current Debate rework only added debate deletion inside the Switch picker, excluding the current one. Result tables gained no delete methods. - -**Suggested fix** - -Add 'Delete debate' to the Current Debate page header (and the header menu) with the same confirmation, and leave the user with no current debate afterwards. Add a generic deleteRecordedResult(kind, id) to session-store backed by RLS-scoped deletes on attacks, brainstorms, factoids, chats and crossfires. Show a trash button with confirmation on every History row, Current Debate item row and UnsortedWork row. - -**Who it hits** - -Every user eventually has items they want gone. The gap is sharpest for students in the EU (privacy: chats) and for anyone whose first debate was a mistake. - -**Independent check** - -Code: deleteDebate's only caller is DebatePicker (DebateDialogs.tsx:44-104), which renders a Check instead of a trash for the current debate, so the current/only debate cannot be deleted from the UI. Stores have deletes only for cases, flowcharts, sparring sessions, folders (session-store.ts:176,209,216; sparring-store.ts:80); HistoryPage trash only on Cases and Sparring tabs (170, 337). My two ZZ brainstorms on admin had no UI delete and were removed via REST during cleanup. Some of this may be a product choice (one debate always current), but brainstorm/attack/factoid/chat deletion is genuinely absent. - -**Evidence files** - -[09-history.aria.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/09-history.aria.txt), [09-current-debate.aria.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/09-current-debate.aria.txt), [10-history-unsorted.aria.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/10-history-unsorted.aria.txt), [25-switch-no-debates.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/25-switch-no-debates.png), [s6-brain.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s6-brain.json) - - -### journeys-4: Library row trash and saved-flowchart delete act instantly, with no confirmation and no undo (the sheet, History and Clear All do confirm) - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `destructive-actions-without-confirm` - -**Where** - -- src/pages/LibraryPage.tsx:127-137 (row trash calls onRemove directly) -- src/pages/LibraryPage.tsx:409-416 (handleRemoveSource: no confirm, no undo) -- src/pages/LibraryPage.tsx:1025-1043 (the sheet's Delete uses an AlertDialog: inconsistent) -- src/pages/FlowchartPage.tsx:382-390 and 535-546 (Load dialog trash deletes immediately) -- /app/library row, /app/flowchart > Load - -**What the reviewer saw** - -05-library2.json: clicking 'Delete ZZ review notes file' in the row produced no alertdialog (alertAfterRowTrash: 0). The source disappeared and the toast said 'Deleted. Source removed.' with no Undo. The trash is 28x28 px, 8 px left of the 44x24 toggle on both desktop (x 1026 vs 1062) and Pixel 7 (x 270 vs 306; 21-mobile-a.json), so aiming for 'pause' can delete. 16-flowchart-save.json: Load > trash on 'ZZ review flowchart' deleted it at once (alertAfterDelete: 0; 'Flowchart deleted'; list shows 'No saved flowcharts yet'). Meanwhile deleting a flowchart node, a History case, Clear All and the sheet's Delete all ask first. handleRemoveSource also has no .catch, so a failed delete is an unhandled rejection with no message. - -**Why it happens** - -Each surface chose its own delete pattern. No shared 'destructive action' helper (confirm or undo toast) exists. - -**Suggested fix** - -Use one pattern everywhere. Prefer an optimistic delete with a sonner toast action 'Undo' (re-insert the row) for single items, and keep the AlertDialog for bulk actions. Apply it to LibraryPage SourceRow and FlowchartPage handleDeleteFlowchart, and add .catch with an error toast. Move the row trash into the sheet or an overflow menu, or put at least 16 px and a divider between it and the toggle on mobile. - -**Who it hits** - -Every Library user, especially on phones, where the delete icon sits beside the pause toggle. One mis-tap loses an imported source or a flowchart with no way back. - -**Independent check** - -Row trash on 'ZZ review src two' (28x28 at x=1026, 8px left of the 44x24 switch at x=1062) deleted instantly: alertdialog count 0, toast 'Deleted / Source removed.' with no Undo. Code: LibraryPage.tsx:127-137 and handleRemoveSource 409-416 (no confirm, no .catch, so a failed delete is an unhandled rejection with a success-free silence). Flowchart: Load dialog > trash on 'ZZ review fc' deleted it at once (alertdialog 0, toast 'Flowchart deleted', list empty). Code FlowchartPage.tsx:382-390, 535-546. Other deletes (History case, Clear All, sheet Delete, flowchart node) do confirm, so the pattern is inconsistent. - -**Evidence files** - -[05-library2.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/05-library2.json), [frames-row-trash](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/frames-row-trash), [21-m-library-row.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/21-m-library-row.png), [16-flowchart-save.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/16-flowchart-save.json), [16-after-delete.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/16-after-delete.png), [s3b-flows.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s3b-flows.json), [s3b-library-after-row-trash.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s3b-library-after-row-trash.png), [s3d-fcdelete.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s3d-fcdelete.json), [s3d-load-dialog.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s3d-load-dialog.png), [s3d-load-dialog-after-trash.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s3d-load-dialog-after-trash.png) - - -### journeys-6: Closing the 'Start new debate' dialog flashes a full 'Switch debate' dialog for about 200 ms - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `debate-dialog-content-keyed-on-nullable-store` - -**Where** - -- src/components/features/debate/DebateDialogs.tsx:130-157 (content is `dialog === 'create' ? <create/> : <switch/>` inside one Radix Dialog) -- Header 'Start debate' / Current debate menu > Start new debate, Home and Library Start debate buttons, the tour's Start debate - -**What the reviewer saw** - -CDP screencast frames-create-cancel: after clicking Cancel, frames 002 (t=105 ms) and 003 (t=137 ms) show an opaque 'Switch debate. Pick the motion you are preparing now.' dialog with the debate list and 'Start new debate', shrinking from the tall create form to the short picker before fading out. The same flash happens on Escape (frames-create-escape) and after a successful submit: trackMovement on submit reported the root `div#radix-:r3: "Switch debatePick the motion you are pre…"` as removed (02-tour-create.json). openDialog(null) flips the ternary to the switch branch while Radix runs its 200 ms exit animation. - -**Why it happens** - -The dialog body is chosen from a nullable store value that becomes null at the start of the close animation, so the fallback branch renders during the exit. - -**Suggested fix** - -Keep the last non-null dialog kind in a ref (const shown = dialog ?? lastDialogRef.current) and render from that, or use two separate Dialog components (one for create, one for switch), each with open={dialog === kind}. Check DebateMotionGate and other store-driven dialogs for the same pattern. - -**Who it hits** - -Every new user sees it on their very first action (creating a debate), and again on every create or cancel. It looks like the app opened the wrong dialog. - -**Independent check** - -rAF sampling after Cancel on 'Start new debate': h2 switches from 'Start new debate' (538px tall) to 'Switch debate' (186px) within one frame (t=69ms) while opacity is still 1, then fades out over ~190ms; same on Escape (t=18ms). Mid-close screenshot shows the Switch debate dialog ('You have no other debates yet.', 'Start new debate'). Code: DebateDialogs.tsx:131-157 ternary on dialog==='create' while openDialog(null) runs before Radix exit animation. - -**Evidence files** - -[002-t105.jpg](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/frames-create-cancel/002-t105.jpg), [003-t137.jpg](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/frames-create-cancel/003-t137.jpg), [frames-create-escape](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/frames-create-escape), [02-tour-create.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/02-tour-create.json), [s2-empty.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s2-empty.json), [s2-create-dialog-mid-close.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s2-create-dialog-mid-close.png) - - -### journeys-7: Selecting a Flowchart node pushes the canvas down 127 px under the cursor - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `flowchart-selection-panel-in-flow` - -**Where** - -- src/pages/FlowchartPage.tsx:714-745 (a 'Selected' panel is inserted above the canvas when selectedNode is set) -- src/pages/FlowchartPage.tsx:650-669 (Edit and Delete also appended to the header toolbar, which wraps to a second row) -- /app/flowchart - -**What the reviewer saw** - -15-flowchart2.json: 'Argument 2' node top moved from y=423 to y=550 after a single click (nodeYBeforeAfterClick [423.4, 550.4]). trackMovement reports #root resized +127 px. Two things are inserted on selection: the toolbar gains Edit and Delete and wraps, and a 'Selected <label> / description / Edit / Delete' strip appears between the stats bar and the canvas. Clicking empty canvas removes both, so the canvas jumps back up. Screenshot 14-flowchart-edit-thesis.png shows the panel after the first click of a double-click. Video 15-flowchart-node-click-shift.webm shows it live. - -**Why it happens** - -Selection state adds content in normal document flow above the canvas instead of overlaying it. - -**Suggested fix** - -Render the selected-node details as an overlay inside the ReactFlow container (a Panel at the top-right) or reserve its height permanently. Drop the duplicate Edit/Delete buttons from the header toolbar so it never wraps. - -**Who it hits** - -Anyone using Flowchart. Each click on a node makes the diagram jump away. Double-click-to-edit (as the thesis hint suggests) and drag start become unreliable. - -**Independent check** - -Clicking 'Argument 2' moved the node from y=423.4 to y=550.4; trackMovement reports #root resized +127px; clicking the pane returns it to 423.4. Clicking the thesis moved it 79px, which also made a real double-click on the thesis miss (the second click lands after the shift) so the edit dialog did not open on dblclick. Code: FlowchartPage.tsx:714-745 inserts the 'Selected' strip above the canvas; 650-669 adds Edit/Delete to the toolbar (desktop 1280 toolbar did not wrap, so the shift there comes from the strip alone). - -**Evidence files** - -[15-flowchart2.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/15-flowchart2.json), [15-flowchart-node-click-shift.webm](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/video/15-flowchart-node-click-shift.webm), [15-selected-duplicate-buttons.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/15-selected-duplicate-buttons.png), [14-flowchart-edit-thesis.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/14-flowchart-edit-thesis.png), [s3c-flows.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s3c-flows.json), [s3c-flowchart-node-selected.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s3c-flowchart-node-selected.png) - - -### journeys-8: Timer's global key handler resets a running speech on Escape and steals Space from focused buttons - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `timer-global-shortcuts-unscoped` - -**Where** - -- src/pages/TimerPage.tsx:249-272 (window keydown: Space toggles and preventDefault, Escape/R reset, arrows change speaker; only input and textarea are excluded) -- src/pages/TimerPage.tsx:372 (reset button aria-label uses timer.resetKey = 'R to reset') - -**What the reviewer saw** - -12-timer.json: started the Introductor timer (3:57 SPEAKING), opened the format dropdown, pressed Escape to close it, and the timer reset to '4:00 PAUSED' (afterEscapeCloseSelect). Escape is also how users close the header Current debate popover and language menu on this page. Focusing the 'Disable sound' button and pressing Space did not toggle sound (label unchanged) but paused the timer instead (3:58 PAUSED). The reset button's accessible name is 'R to reset' (aria snapshot 12-timer.aria.txt). - -**Why it happens** - -Shortcuts are bound on window with no check for event.defaultPrevented, open popovers or focused interactive elements, and Escape is used for a destructive action. - -**Suggested fix** - -Ignore events whose target is a button, select, [role=combobox|listbox|menu|option] or contenteditable, or when event.defaultPrevented is true. Drop Escape as a reset key (keep R). Ask before resetting a timer that is more than a few seconds in. Rename the reset button to 'Reset timer' and show 'R' as a hint. - -**Who it hits** - -Students timing a live speech: closing a menu wipes the countdown mid-round, and keyboard users cannot press buttons on this page. - -**Independent check** - -Started timer (3:58 SPEAKING), opened format Select, pressed Escape: listbox closed AND timer reset to '4:00 PAUSED'. Focused 'Disable sound' and pressed Space: label stayed 'Disable sound' and timer paused (3:59 PAUSED). Reset button accessible name is 'R to reset'. Code: TimerPage.tsx:249-272 (window keydown, only input/textarea excluded; Escape=reset), :372 aria-label uses timer.resetKey. - -**Evidence files** - -[12-timer.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/12-timer.json), [12-timer-select-open.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/12-timer-select-open.png), [12-timer-after-escape.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/12-timer-after-escape.png), [12-timer.aria.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/12-timer.aria.txt), [s3b-flows.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s3b-flows.json), [s3b-timer-select-open.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s3b-timer-select-open.png), [s3b-timer-after-escape.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s3b-timer-after-escape.png) - - -### journeys-9: Unsaved work is discarded silently: Flowchart canvas and running Timer reset on navigation; source sheet edits drop on Escape - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `page-local-work-state-no-draft` - -**Where** - -- src/pages/FlowchartPage.tsx:272-273 (canvas lives in useNodesState/useEdgesState, nothing persisted) -- src/pages/FlowchartPage.tsx:374-380 (Load replaces the canvas without asking) -- src/pages/TimerPage.tsx:132-141 (timer state is component state) -- src/pages/LibraryPage.tsx:917 (Sheet onOpenChange closes and discards sourceDraft with no dirty check) - -**What the reviewer saw** - -15-flowchart2.json: renamed the thesis to 'ZZ review thesis: ban homework' and added 'ZZ review node' (7 nodes), clicked Home, then Back: the canvas was back to the 6-node sample and the thesis edit was gone (afterNavThesisEdited: 0, afterNavNodes: 6). There was no warning and no autosave. 12-timer.json: timer at 3:56 SPEAKING, opened Library, came back: '4:00 PAUSED'. 05-library2.json: changed a source title in the detail sheet to '... EDITED', pressed Escape: the sheet closed and reopening showed the original title (reopenTitle unchanged), with no 'discard changes?' prompt. Loading a saved flowchart also replaces whatever is on the canvas without asking. - -**Why it happens** - -Work-in-progress lives in page-local React state with no draft persistence and no dirty checks, unlike the tool forms, which persist their inputs in the session store. - -**Suggested fix** - -Keep the flowchart draft (nodes, edges, loaded id) and the timer (format, speech index, deadline timestamp, running) in a zustand store, persisted to sessionStorage, so navigation and reload restore them. A deadline-based timer can keep running across routes. Add a dirty check to the source sheet ('Discard changes?') and to Flowchart Load when the canvas has unsaved edits. - -**Who it hits** - -Students building a flowchart or timing a speech who glance at the Library or History lose their work with no warning. - -**Independent check** - -Flowchart: added 'ZZ review node' (7 nodes), Home then Back -> 6 sample nodes, ZZ node gone, no warning. Timer: 3:58 SPEAKING -> Library -> Back -> '4:00 PAUSED'. Library sheet: title edited to '... EDITED', Escape closed with no prompt, reopen showed original title. Code: FlowchartPage.tsx:272-273, TimerPage.tsx:132-141, Library sheet onOpenChange. - -**Evidence files** - -[15-flowchart2.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/15-flowchart2.json), [15-after-nav.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/15-after-nav.png), [12-timer.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/12-timer.json), [05-library2.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/05-library2.json), [s3c-flows.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s3c-flows.json), [s3b-flows.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s3b-flows.json), [s3-flows.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s3-flows.json) - - -### journeys-10: On a phone, tapping the highlighted 'Start debate' in the first tour step skips the step instead of opening the dialog - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `tour-spotlight-blocks-touch` - -**Where** - -- src/components/features/onboarding/OnboardingTour.tsx:19-47 (step 1 spotlights the Home card whose Start debate button cannot be tapped through Joyride's overlay) -- src/components/features/onboarding/OnboardingTour.tsx:30 (code comment acknowledges: clicks pass the highlight only after a mouse move) -- src/pages/HomePage.tsx:109-120 - -**What the reviewer saw** - -22-mobile-first-run.json (Pixel 7, fresh account, skipTour:false): step 1 spotlights the 'No current debate' card with its full-width blue 'Start debate' button. Tapping that button did not open the dialog (dialogAfterCardTap: 0). Instead the tour advanced: the page scrolled to the bottom and only a pulsing beacon sat on the Utilities > Library link, with no tooltip (22-m-after-card-tap.png, video 22-mobile-tour-tap-highlighted-start-debate.webm). Only the smaller Start debate button inside the tooltip works (23-mobile-first-run.json). Step 1 also shows three 'Start debate' buttons at once on desktop (nav, card, tooltip) (01-app-first-5.png). - -**Why it happens** - -Joyride's overlay intercepts the tap on the spotlighted element. An overlay click counts as advancing the step, and the next step then renders as a beacon. - -**Suggested fix** - -Set spotlightClicks: true on step 1, or disableOverlayClose: true with spotlightClicks, so the highlighted button works on touch. Remove the duplicate tooltip button. Set disableBeacon on all steps so an overlay tap can never leave a lone beacon. - -**Who it hits** - -Most students are on phones. Their first tap in the app does the wrong thing and drops them at the bottom of the page with an unexplained pulsing dot. - -**Independent check** - -Pixel 7, admin with no debates, tour step 1: elementFromPoint at the card's Start debate centre is DIV.react-joyride__spotlight; touchscreen.tap there opened no dialog (0) and the tour advanced (scrollY 573, beacon on Utilities > Library). 3 'Start debate' buttons visible at step 1. Code comment at OnboardingTour.tsx:30 acknowledges it. - -**Evidence files** - -[22-m-tour-step1.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/22-m-tour-step1.png), [22-m-after-card-tap.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/22-m-after-card-tap.png), [22-mobile-tour-tap-highlighted-start-debate.webm](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/video/22-mobile-tour-tap-highlighted-start-debate.webm), [22-mobile-first-run.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/22-mobile-first-run.json), [01-app-first-5.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/01-app-first-5.png), [s1-tour.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s1-tour.json), [s1-tour-step1-mobile.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s1-tour-step1-mobile.png), [s1-tour-after-card-tap-mobile.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s1-tour-after-card-tap-mobile.png) - - -### journeys-12: Expired users get no warning before submitting; pricing calls their state 'Free, Current'; checkout failure shows 'Request failed (HTTP 500)' - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `expired-state-not-surfaced-before-submit` - -**Where** - -- src/hooks/use-subscription-error.ts:32-33 (expiry surfaced only after a server 402) -- src/pages/GeneratePage.tsx (and every AI tool page: form fully enabled when tier is expired) -- src/components/features/subscription/PricingModal.tsx:43-50 (toast shows the raw error message), :99 ('Current' badge on Free) -- src/lib/stripe.ts:24 (getEdgeFunctionErrorMessage passes 'Request failed (HTTP 500)' through) - -**What the reviewer saw** - -18-expired.json, with the gate bypassed by a browser-only route rewrite: Home and Generate show no expired notice. The Generate form is fully enabled and lists every model, including ones tagged Pro. Only after clicking 'Generate Debate Case' (refused in 922 ms with a server 402) does a modal say 'Free Trial Ended. Your 3-day free trial has ended. Upgrade to Pro…'. Upgrade opens pricing, where 'Free $0/mo, Starter AI prep access…, 3-day free trial' is badged 'Current', though this user has no AI access. 'Upgrade Now' shows 'Redirecting…' (good), then the toast 'Checkout Error / Request failed (HTTP 500)' (18-expired-checkout-failed.png); staging has no Stripe by design, but the same copy would reach production users on any checkout failure. The console also logs the expected 402 as an error ('Failed to generate case: AISubscriptionError…'). Settings does say 'Expired. Your access has expired.' On the plus side, History, reading and PDF export still work for expired users. - -**Why it happens** - -Subscription state is available on the client (useSubscription has isExpired) but is not used to gate the tool UIs or the pricing copy. Edge-function error text is shown verbatim. - -**Suggested fix** - -When useSubscription().isExpired: show a persistent banner on Home and the tool pages ('Your trial ended. You can still read and export your work. Upgrade to run AI tools.'), and swap the submit buttons for 'Upgrade to use'. In PricingModal, label the expired state 'Trial ended' instead of 'Current'. Map checkout and portal failures to friendly copy ('We couldn't open checkout. Try again in a minute or contact us'), keeping the technical detail for Sentry only. Don't console.error expected 402s. - -**Who it hits** - -Every expired trial user, the people DebateLab most wants to convert. They fill in forms for nothing, then see contradictory plan copy and a raw HTTP error. - -**Independent check** - -Expired account (gate bypassed browser-side): /app/generate shows no expired notice (0 matches), Generate button enabled; click -> server 402 after ~1.5s -> modal 'Free Trial Ended...'. Upgrade -> pricing shows 'Free ... Current' with 'Starter AI prep access ... 3-day free trial' though the user has no AI access (PricingModal.tsx:99 unconditional badge). Console logs the expected 402 as error. Checkout error path (PricingModal.tsx:43-50 shows err.message, stripe.ts:24) verified in code only; I did not call create-checkout. The raw-message part shares raw-error-messages-forwarded-to-users. Used 1 AI request (refused). - -**Evidence files** - -[18-expired-generate.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/18-expired-generate.png), [18-expired-refusal.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/18-expired-refusal.png), [18-expired-pricing.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/18-expired-pricing.png), [18-expired-checkout-failed.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/18-expired-checkout-failed.png), [18-expired.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/18-expired.json), [s5-expired.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s5-expired.json), [s5-expired-generate.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s5-expired-generate.png), [s5-expired-limit-modal.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s5-expired-limit-modal.png), [s5-expired-pricing.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s5-expired-pricing.png) - - -### journeys-13: AI failures appear only in a 4-second toast; the results panel quietly returns to its 'Ready to Generate' empty state - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `ai-error-toast-only` - -**Where** - -- src/pages/GeneratePage.tsx:98-114 (errors shown only via toast, then loading cleared) -- src/hooks/use-toast.ts (sonner default 4 s duration, same for errors) -- src/lib/ai/client.ts:50 (REQUEST_TIMEOUT_MS = 200 s) -- Same pattern in Brainstorm/Attack/Factoids pages - -**What the reviewer saw** - -My first Generate run (07-generate-history.json, 07-generate-loading.png 'Reading the motion…') was still waiting at 200 s. When the script gave up at 240 s, the page showed the initial empty state 'Ready to Generate. Enter your motion…' with the button enabled and no error visible (07-error.png). The API showed no case row. This timing matches the 200 s client timeout firing a 'Request Timed Out' toast that disappears after 4 s. A student who looked away during the wait comes back to a screen that suggests nothing happened. A retry worked in 62 s (08-generate-retry.json). I could not see the toast text because my polling missed it, so confidence is medium on the exact copy. Whether a timed-out request still counts against quota belongs to the AI lane. - -**Why it happens** - -Error state is not part of the result area's state machine (empty, loading, result, error); it is a side-channel toast with the default auto-dismiss. - -**Suggested fix** - -Store the last error per tool in the session store and render an inline error card in the results column ('Generation timed out after 3 minutes. Your inputs are kept.' plus a Retry button) until the next submit. Give destructive toasts duration: Infinity or at least 10 s. - -**Who it hits** - -Anyone whose 1 to 3 minute AI request fails, likely on slow networks and with Pro models. They wait, then see a blank 'start' screen and may assume the app is broken. - -**Independent check** - -Intercepted the edge function POST with a browser-side 500 (no AI cost). Toast 'Generation Failed / AI service error: Internal error. Please try again.' at 1.5s, gone at 7s; results panel shows 'Ready to Generate' empty state with no inline error. Code: GeneratePage.tsx:98-114 (toast only), sonner default 4s (use-toast.ts, sonner.tsx sets no duration). Toast text 'AI service error: Internal error' also forwards a server message (see raw-error-messages-forwarded-to-users). - -**Evidence files** - -[07-generate-loading.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/07-generate-loading.png), [07-error.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/07-error.png), [08-generate-retry.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/08-generate-retry.json), [s4-case.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s4-case.json), [s4-generate-error-toast.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s4-generate-error-toast.png), [s4-generate-after-toast-gone.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s4-generate-after-toast-gone.png) - - -### ai-surfaces-2: No Stop/Cancel on any AI tool; Ask's only exit (Clear) wipes the conversation - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `ai-requests-not-cancellable` - -**Where** - -- src/pages/GeneratePage.tsx:76-118 (generateCase called without a signal) -- src/pages/AttackPage.tsx:35-60 -- src/pages/BrainstormPage.tsx:34-58 -- src/pages/FactoidsPage.tsx:34-58 -- src/components/features/ask/AskChat.tsx:536-545 (only Save/Clear in the header) -- src/pages/AskPage.tsx:390-393 (handleClear aborts and clears everything) - -**What the reviewer saw** - -callSupabaseAI already accepts an AbortSignal (client.ts:516-518). None of generateCase, attackCase, brainstorm or factoids passes one, and no tool page renders a cancel control. The submit button is simply disabled for the whole request. - -Measured real waits: -- Generate: 64 and 97 s. -- Regenerate: 67 s. -- Attack: 56 and 92 s. -- Brainstorm: 49 and 76 s. -- One Factoids run (DeepSeek V4 Flash) waited 130 s and then failed. - -In Ask, Clear during a stream removed the question and the partial answer, with no toast (`ask-clear-desktop-DeepSeekV4Flash.json`, afterClear bubbles 0). - -Code-inferred, not observed: a reload mid-Generate kills the client closure while the server finishes and logs usage. Because the loading flags are excluded from persistence (session-store.ts:1281-1291), the student loses the result and recordResult never runs, but the request still counts against the quota. - -**Why it happens** - -The tools never create an AbortController, and there is no Stop affordance. Ask's AbortController is only reachable through Clear, unmount, or a change of debate scope. - -**Suggested fix** - -Add a Stop button next to the loading state on all four tool pages. Wire it to an AbortController passed through generateCase/attackCase/brainstorm/factoids into callSupabaseAI (its signal option already exists), and say 'Stopped' rather than show an error. - -In AskChat, swap the Send button for Stop while askLoading. Stopping should keep the question and any partial text, marked 'Stopped'. Edge functions already cancel upstream on client abort (ai-stream/index.ts:255-258). - -**Who it hits** - -Anyone who picked the wrong side, model or motion waits 1–2 minutes with no way out. A reload throws away a request the student is charged for. - -**Independent check** - -Code: generateCase/attackCase/brainstorm/factoids never pass an AbortSignal although callSupabaseAI accepts one (client.ts:513-518); no tool page renders a Stop control; AskChat's header only has Save/Clear and handleClear (AskPage.tsx:390-393) aborts and clears everything. Reproduced on Ask with an in-page fake stream: no Stop/Cancel button exists (count 0); Clear 1.5 s into the stream removed both the question and the partial answer, with no toast. The Generate no-cancel state is visible in gen-desktop-loading.png (disabled 'Generating Case...' only). Wait times and the reload-loses-result claim are the reviewer's; the latter is code-consistent (loading flags excluded from persistence, session-store.ts:1281-1291). - -**Evidence files** - -[ask-clear-desktop-DeepSeekV4Flash.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/ask-clear-desktop-DeepSeekV4Flash.json), [ask-clear-desktop-DeepSeekV4Flash-before-clear.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/ask-clear-desktop-DeepSeekV4Flash-before-clear.png), [ask-clear-desktop-DeepSeekV4Flash-after-clear.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/ask-clear-desktop-DeepSeekV4Flash-after-clear.png), [factoids-DeepSeekV4Flash.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/factoids-DeepSeekV4Flash.json), [ask-sim-desktop-all.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/ask-sim-desktop-all.json), [gen-desktop-loading.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/raw/gen-desktop-loading.png) - - -### ai-surfaces-3: Ask auto-scroll fights the reader and scrolls the whole page on every streamed chunk - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `ask-stream-rerender-and-autoscroll` · **Same fix as:** render-bundle-1 - -**Where** - -- src/components/features/ask/AskChat.tsx:379-382 -- src/pages/AskPage.tsx:369-377 (visibleMessages rebuilt every render) - -**What the reviewer saw** - -The useEffect deps are [messages], and AskPage builds a new array on every render (30 ms delta flushes). So `messagesEndRef.scrollIntoView({behavior:'smooth'})` runs on every chunk. scrollIntoView with the default block:'start' also scrolls the window. - -- **Desktop:** the window scrolled to scrollY 140 during streaming, hiding the h1 and the debate strip. After a 400 px wheel-up the pane never left the bottom; samples read [547/547 … 692/692] and each chunk re-pinned it (`ask-long-desktop-default.json` scrollFight). -- **Pixel 7:** the user's wheel moved the page to scrollY 0, and auto-scroll pulled it back to 234, 304, 309 within about 300 ms, repeated on every chunk (`ask-long-mobile-DeepSeekV4Flash.json`). - -**Why it happens** - -The scroll effect is keyed on an array identity that changes on every render. It uses element.scrollIntoView, which scrolls every ancestor including the document, and there is no 'is the user at the bottom' check. - -**Suggested fix** - -Scroll only the Radix viewport: set `viewport.scrollTop = viewport.scrollHeight`, and only when the user was already within about 80 px of the bottom before the update (stick-to-bottom). Key the effect on message count plus the streaming text length, not the array. Use behavior 'auto' during streaming. Add a 'Jump to latest' pill when the user has scrolled up. - -**Who it hits** - -Every Ask user who tries to re-read earlier text while an answer streams. On phones the whole page keeps jumping. - -**Independent check** - -Code: AskChat.tsx:379-382 calls messagesEndRef.scrollIntoView({behavior:'smooth'}) on every [messages] change, and AskPage rebuilds visibleMessages every render (AskPage.tsx:334-343), so it fires on every 30 ms flush. Reproduced on desktop with an in-page fake SSE stream (nothing reached staging): window.scrollY went from 0 to 140 during streaming (h1 and the debate strip scrolled away). After a 400 px wheel-up inside the chat viewport, 10 samples over 1 s stayed pinned to the bottom (scrollTop 847/871, 871/895 ... 1217/1223). Same root cause and fix as render-bundle-1. - -**Evidence files** - -[ask-long-desktop-default.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/ask-long-desktop-default.json), [ask-long-mobile-DeepSeekV4Flash.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/ask-long-mobile-DeepSeekV4Flash.json), [ask-long-desktop-default-mid-stream.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/ask-long-desktop-default-mid-stream.png), [ask-stream-long-desktop.webm](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/ask-stream-long-desktop.webm), [ask-sim-desktop-all.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/ask-sim-desktop-all.json), [ask-desktop-midstream.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/raw/ask-desktop-midstream.png) - - -### ai-surfaces-4: Results wait behind ~18 sequential database writes after the AI has answered - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `tool-results-await-sequential-autosave` - -**Where** - -- src/lib/ai/client.ts:1238-1277 (autosaveDiscoveredSources awaits each source in turn) -- src/lib/ai/client.ts:1308, 1324, 1340, 1375 (awaited before the result is returned) -- src/lib/stores/session-store.ts:288-291 (getUserId calls supabase.auth.getUser, a network request, per write) -- src/lib/stores/session-store.ts:499-531 (addSource: getUser, then folder upsert, then insert) -- src/pages/GeneratePage.tsx:85-117 (recordInDebate awaited before setGenerateLoading(false) in finally) - -**What the reviewer saw** - -Generate (default model): the AI response body arrived at t=67,660 ms and the results became visible at t=68,964 ms. In between, 18 requests ran strictly one after another: 6× GET /auth/v1/user, 5× POST source_folders upsert, 5× POST sources, then POST cases (`generate-default-summary.json`). - -Attack, Brainstorm and Factoids showed the same pattern: 18 requests and 1.3–1.8 s between the AI response and the 'complete' toast. - -On this VPS link that is about 1.3 s. At student mobile round-trip times (150–300 ms) it is about 3–5 s of extra spinner after the AI has already answered. - -**Why it happens** - -Several bookkeeping steps happen inline and in sequence before the loading state ends: source auto-save, the folder upsert repeated per source, getUser per write, and recordResult. - -**Suggested fix** - -Return and render the result as soon as the AI response is parsed. Run the auto-save and record steps in the background without awaiting them. Insert all sources in one bulk insert, and upsert the folder once. Use the cached session user id (getActiveUserId) instead of auth.getUser() per write. Clear generateLoading before recording, and show the 'saved to debate' state once the save completes. - -**Who it hits** - -Every Generate/Attack/Brainstorm/Factoids run gets 1–5 s slower than it needs to be. - -**Independent check** - -Code: generateCase/attackCase/brainstorm/factoids (client.ts:1308,1324,1340,1375) await autosaveDiscoveredSources, which awaits each source in turn (client.ts:1247-1276). Each addSource calls getUserId() -> supabase.auth.getUser() (session-store.ts:288-291), then a source_folders upsert, then the insert (499-531). GeneratePage awaits recordInDebate before the 'complete' toast; the loading card only clears in finally (GeneratePage.tsx:85-117). Reproduced with a faked /functions/v1/ai response carrying 5 strong sources and every PostgREST write faked (120 ms each; nothing reached the database): after the AI body arrived at t=6875 ms, 16 requests ran strictly one after another (5 x [GET /auth/v1/user, POST source_folders, POST sources], then GET /auth/v1/user, POST cases) and the 'Generate complete' toast appeared at t=9064 ms, 2.2 s later. Real auth/v1/user RTT here was 35-55 ms. Load average about 10 during the run. The getUser part shares a fix with getuser-network-fallback-local-only-writes (use the cached session id), but the awaited serial loop and folder upsert per source are separate. - -**Evidence files** - -[generate-default-summary.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/generate-default-summary.json), [attack-default.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/attack-default.json), [brainstorm-default.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/brainstorm-default.json), [gen-sim-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/gen-sim-desktop.json), [gen-desktop-result.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/raw/gen-desktop-result.png) - - -### ai-surfaces-5: Every tool run auto-saves ~5 sources with no de-duplication; copies pile up and crowd the context - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `autosave-sources-no-dedup` - -**Where** - -- src/lib/ai/client.ts:1238-1277 (autosaveDiscoveredSources inserts each URL with no existence check) -- supabase/migrations (no unique constraint on sources(debate_id, origin)) -- src/components/features/AutoSavedSourcesUndo.tsx (never rendered: ExportToolbar's showUndo is never passed) - -**What the reviewer saw** - -After my 8 tool runs in one debate, the debate had 43 sources: 3 seeded and 40 auto-saved by my runs. Exact-URL copies: the cepr.org 'Climate policy…' column 5×, an exa.ai library page 5×, Cities-Speak.pdf 3×, the hEART 2025 paper 3×, and four others 2× (`cleanup-read.json`). The Library UI lists 'Evaluating the effectiveness…' 5× and 'Climate policy may crowd out…' 5×. - -One Factoids response alone saved the same report twice from two mirrors. Auto-saved strong/moderate sources are switched on (isActive), and across my runs the Review strip's 'items are left out to fit the request' count rose from 3 to 8 to 13. - -The only undo control, AutoSavedSourcesUndo, is dead code. If it were wired up as written, it would delete every auto-saved source in the debate, not just this run's. - -**Why it happens** - -Discovered sources are inserted blindly on every run (and on every regenerate), with no client check, upsert or DB constraint on (user, debate, normalized origin). - -**Suggested fix** - -- Upsert auto-saved sources on (debate_id, normalized origin). Add a unique partial index plus `on conflict do update` for metadata/semantic. -- Or skip URLs the debate already has. -- Cap auto-saves per run. -- Delete AutoSavedSourcesUndo, or rewrite it to undo only the ids saved by the current run, and render it. - -**Who it hits** - -Every student who uses the tools more than once in a debate. The Library fills with duplicates, and the AI context budget goes to copies instead of the student's own sources. - -**Independent check** - -Code: autosaveDiscoveredSources inserts every retained source with no existence check (client.ts:1238-1277); migrations have no unique index on sources(debate_id, origin) (only idx_sources_user_id, init.sql:126). AutoSavedSourcesUndo is imported only by ExportToolbar and showUndo is never passed, so it never renders; as written it would delete every Auto-saved source in the Current Debate, not just this run's. Reproduced with faked writes: three Generate runs in the same debate returning the same 5 URLs issued 15 POST sources and 15 POST source_folders upserts, with no lookup first. I did not re-check the reviewer's staging duplicates (they deleted them). - -**Evidence files** - -[cleanup-read.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/cleanup-read.json), [library-autosaved-duplicates.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/library-autosaved-duplicates.png), [generate-cheap-summary.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/generate-cheap-summary.json), [generate-cheap-regenerating.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/generate-cheap-regenerating.png), [gen-sim-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/gen-sim-desktop.json) - - -### ai-surfaces-6: 'Regenerate section' re-runs the full deep research and the whole case, then keeps one section - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `regenerate-section-reruns-full-generate` - -**Where** - -- src/pages/GeneratePage.tsx:120-165 (handleRegenerateSection calls generateCase with the full input) -- supabase/functions/ai/features/generate.ts:26-60 (no section parameter; always deepResearch, JEV ranking and full generation) - -**What the reviewer saw** - -The regenerate request body was identical to a normal Generate. Its input keys were [motion, side, format, role, length, audience, tone, language], with no section field (`generate-cheap-summary.json` aiBodies). - -It took 67 s, against 97 s for a full run on the same model. It auto-saved 5 more sources (duplicates) and counted as a full request. It also replaced the 'Researched sources' panel with sources from the new research, while the other sections still come from the earlier run (setDiscoveredSources at GeneratePage.tsx:159). - -While one section regenerates, the whole form shows 'Generating Case...' and is disabled. - -**Why it happens** - -There is no section-level server operation. The client reuses full generation and throws away most of the output. - -**Suggested fix** - -Add a `section` field to the generate input. On the server, reuse the existing case and its research and prompt only for that section, then return just that section. Keep the original discoveredSources panel, or merge into it. Label the form state 'Regenerating Arguments…'. - -**Who it hits** - -Every regenerate click costs a full request (time, money and daily quota) and adds duplicate sources. - -**Independent check** - -Code: handleRegenerateSection (GeneratePage.tsx:120-165) calls generateCase with the full input and no section field; supabase/functions/ai/features/generate.ts has no section parameter. It replaces the Researched sources panel with the new run's sources (GeneratePage.tsx:159) while other sections stay from the first run, and autosaves the new sources again. Reproduced with a faked AI response: clicking 'Regenerate Arguments' sent POST /functions/v1/ai with feature=generate and input keys [motion, side, format, role, length, audience, tone, language], section null. While it ran the form submit button read 'Generating Case...' (generateLoading || regeneratingSection disables the form). Duration and cost claims are the reviewer's real-run numbers; I did not spend AI budget on them, but the identical request body implies a full deep-research run. - -**Evidence files** - -[generate-cheap-summary.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/generate-cheap-summary.json), [generate-cheap-regenerating.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/generate-cheap-regenerating.png), [generate-cheap-after-regen.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/generate-cheap-after-regen.png), [gen-sim-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/gen-sim-desktop.json), [gen-desktop-regenerating.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/raw/gen-desktop-regenerating.png) - - -### ai-surfaces-7: After leaving the page or reloading, a recorded Generate result loses its History link (Save comes back; regenerate stops saving; sources panel gone) - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `generate-recorded-id-in-component-state` - -**Where** - -- src/pages/GeneratePage.tsx:37-41 (recordedCaseId and resultDebateId are component state) -- src/pages/GeneratePage.tsx:94 and 395 (onSave hidden only while recordedCaseId is set) -- src/pages/GeneratePage.tsx:156 (updateRecordedResult only when recordedCaseId is set) -- src/lib/stores/session-store.ts:816-834 (saveCurrentCase always inserts) - -**What the reviewer saw** - -Observed: -- I started Generate, opened Library via client-side navigation after 4 s, and came back. The result landed and the cases row was inserted (created-rows: cases bd91c654), but the toolbar showed 'Save' and the 'Researched sources' panel was missing (`generate-cheap-summary.json`: after-nav-state saveBtn true, sourcesPanel false). -- After a reload, the result was shown with 'Save' visible and no sources panel. - -Code-inferred, not exercised: clicking that Save inserts a second cases row (saveCurrentCase inserts unconditionally), and a regenerate after navigating or reloading does not update the recorded History row (updateRecordedResult is guarded by recordedCaseId). - -**Why it happens** - -The link between the on-screen result and its History row, plus the discovered sources, lives in page-local useState. The result itself lives in the persisted store, so the two drift apart on unmount or reload. - -**Suggested fix** - -Store recordedId, debateId and discoveredSources next to generateResult in the session store (persisted). Have recordResult set them. Hide Save whenever the result already has a recorded id. Apply the same fix to the other tools if they gain Save or regenerate. - -**Who it hits** - -Students who switch tabs while waiting or come back later: duplicate History entries, regenerated sections that are not saved, missing source provenance. - -**Independent check** - -Code: recordedCaseId, resultDebateId and discoveredSources are useState in GeneratePage (37-41, 62), while generateResult lives in the persisted session store. Remounting resets them, so onSave reappears (395) and updateRecordedResult is skipped on regenerate (156). saveCurrentCase always inserts a new cases row with debate_id unset (session-store.ts:816-834). Reproduced (writes faked): started Generate, client-navigated to Library after 1 s, went back after 6 s. The cases insert happened while away (writesDuringAway ends with 'cases'), yet on return the toolbar showed 'Save' and the 'Researched sources' panel was gone. Same after a reload. I did not click Save (it would insert a second row); that part is code-inferred. - -**Evidence files** - -[generate-cheap-summary.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/generate-cheap-summary.json), [generate-cheap-after-reload.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/generate-cheap-after-reload.png), [generate-cheap-back-while-loading.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/generate-cheap-back-while-loading.png), [gen-sim-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/gen-sim-desktop.json), [gen-desktop-after-nav.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/raw/gen-desktop-after-nav.png) - - -### ai-surfaces-8: AI error messages are raw server/SDK text, English-only, with no retry; client and server timeouts disagree - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `raw-error-messages-forwarded-to-users` - -**Where** - -- src/lib/ai/client.ts:898-990 (mapAIError: generic 'AI service error: ${message}. Please try again.'; the 'signal' check at :910 is case-sensitive) -- src/lib/ai/error-display.ts:18-65 (hard-coded English; returns error.message as-is) -- src/lib/ai/client.ts:50 (REQUEST_TIMEOUT_MS = 200 s) vs supabase/functions/ai/provider.ts:190 (REQUEST_BUDGET_MS = 130 s) -- src/pages/{Attack,Brainstorm,Factoids}Page.tsx catch blocks (no retry action) -- src/lib/ai/schemas/{generate,attack,factoids}.ts:5-6 (zod messages 'Motion is required' etc. in English) - -**What the reviewer saw** - -Toasts seen. The offline cases and the malformed 200 were simulated via a Playwright route abort or fake and never reached staging. The Signal timed out case was a real staging failure. The Crossfire case was a simulated failed turn. - -- **Offline, all four tools:** 'Generation Failed / AI service error: Failed to send a request to the Edge Function. Please try again.' -- **Real staging failure, Factoids on DeepSeek V4 Flash after 130 s:** HTTP 500, shown as 'AI service error: Signal timed out.. Please try again.' (jargon plus a double period). The friendly timeout copy never fires, because 'Signal' does not match the lowercase 'signal' check. -- **Malformed AI output:** 'AI response did not contain the expected number of contentions.' -- **Crossfire failed turn:** 'Something went wrong — AI service error: Failed to send a request to the Edge Function.' -- **Ask's generic title:** 'Error'. - -All of this text is English whatever the UI language. Only Ask and Crossfire offer Retry; Generate/Attack/Brainstorm/Factoids toasts have no action. - -**Why it happens** - -The error layer passes server, SDK and validator strings straight to users. There is no mapping from error class to translated copy, and the 'signal' check is case-sensitive. The client's 200 s timeout is longer than the server's 130 s budget, so the server's raw abort text reaches the user instead of the client's timeout message. - -**Suggested fix** - -- Map each error class (network/offline via navigator.onLine or FunctionsFetchError, timeout including server 'timed out', provider, invalid output, quota, rate limit) to i18n keys in getUserFacingAIError. -- Never append raw messages; log them only. -- Make the abort/timeout detection case-insensitive and also match 'timed out'. -- Have the server return a stable code (for example `timeout`). -- Set the client timeout just above the server budget (about 140 s). -- Add a Retry action to all four tool toasts. -- Translate the zod required-field messages. - -**Who it hits** - -Any failure in any tool. Students see developer jargon, in English even in Hungarian or Spanish UI, and must re-click the main button to try again. - -**Independent check** - -Code: mapAIError's abort check is case-sensitive (client.ts:910 includes('signal')), so the server's 'Signal timed out.' (AbortSignal.timeout at provider.ts:190-194, 130 s budget vs client REQUEST_TIMEOUT_MS 200 s at client.ts:50) falls to the generic `AI service error: ${message}. Please try again.`; getUserFacingAIError returns error.message for non-provider errors (error-display.ts:65); all copy is hard-coded English. Reproduced (routed /functions/v1/ai, never reached staging): offline Attack -> 'Attack Analysis Failed / AI service error: Failed to send a request to the Edge Function. Please try again.'; offline Factoids -> 'Research Failed / same text'; a 500 with {error:'Signal timed out.'} -> 'AI service error: Signal timed out.. Please try again.' (double period). None of these toasts has a Retry action (only Close). Same root cause as security-edge-3 for the pass-through; the case-insensitive timeout match, the 130/200 s mismatch and missing Retry on the four tool pages are additional fix points. - -**Evidence files** - -[sim-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/sim-desktop.json), [sim-generate-offline-desktop.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/sim-generate-offline-desktop.png), [sim-generate-malformed-desktop.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/sim-generate-malformed-desktop.png), [factoids-DeepSeekV4Flash.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/factoids-DeepSeekV4Flash.json), [crossfire-strip.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/crossfire-strip.png), [misc-tools.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/misc-tools.json), [offline-toast-attack.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/raw/offline-toast-attack.png), [timeout500-toast-attack.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/raw/timeout500-toast-attack.png) - - -### ai-surfaces-9: When an Ask stream fails before any text, a second, non-streaming request is sent silently; a partial stream is saved as a normal answer - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `ask-stream-fallback-and-partial-handling` - -**Where** - -- src/pages/AskPage.tsx:207-228 (catch: if no text yet, result = await askChat(...)) -- src/pages/AskPage.tsx:214-225 (partial kept; toast and uncertainty share the key ask.streamInterrupted; no Retry) -- src/pages/AskPage.tsx:79-90 (the auto-record effect then saves the partial) - -**What the reviewer saw** - -Simulated via Playwright route fakes; none of these reached staging. -- **429 on /ai-stream:** the client immediately POSTs /functions/v1/ai as well (calls ['ai-stream','ai']). The user sees one 'Error / Daily limit reached' toast after two refused requests. -- **SSE error event before any delta:** same two calls. -- **Partial stream with no final event:** the partial is kept. The toast title is the full sentence 'The streamed response was interrupted before completion.' (same key as the uncertainty line). There is no Retry. The footer shows the requested model and '0.1s' as if the answer completed. The partial is then written to History (chats and messages POSTs were captured). - -On real traffic, a server-side failure after the provider was called means a second paid call. A stream timeout (200 s) followed by the fallback can stretch the wait to about 400 s. - -**Why it happens** - -The fallback was designed for 'streaming unsupported', but it fires on any stream error, including refusals (402/429), provider errors and timeouts. - -**Suggested fix** - -- Fall back only when the stream endpoint is unreachable or unsupported (404/405, or no body). -- Never fall back on HTTP status refusals, 'error' SSE events or timeouts. -- Give the interrupted case its own title key, a Retry action, and a visible 'Incomplete' badge. -- Skip auto-recording (or mark incomplete) for interrupted answers, and don't stamp model or duration on them. - -**Who it hits** - -Quota refusals cost two quota checks. Provider failures can double-charge and double the wait. History holds truncated answers that look complete. - -**Independent check** - -Code: AskPage.tsx:207-228. Any stream error before the first delta (including AIRateLimitError, SSE 'error' events and the client TimeoutError, which is not a DOMException AbortError) falls through to a second, non-streaming askChat call. A partial stream is kept as a normal answer, the toast title and the uncertainty line share ask.streamInterrupted (en.json:420, a full sentence), and there is no Retry. Reproduced with an in-page fake SSE plus a routed /functions/v1/ai (no staging calls): a 429 from ai-stream produced 1 stream call + 1 non-stream call and no toast; an SSE error event before any delta did the same. A partial stream with no final event showed toast 'The streamed response was interrupted before completion.' as the title, the footer stamped the model and '2.0s' like a finished answer, and auto-record wrote POST chats x1 + POST messages x2 (writes were faked). - -**Evidence files** - -[ask-sim.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/ask-sim.json), [ask-sim-partial.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/ask-sim-partial.png), [ask-sim-ratelimit.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/ask-sim-ratelimit.png), [ask-sim-desktop-all.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/ask-sim-desktop-all.json), [ask-desktop-partial.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/raw/ask-desktop-partial.png) - - -### ai-surfaces-10: Discuss renders its summary as a code block with literal ** marks; its reply lands after Clear; text is English-only - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `discuss-sends-from-tool-page` - -**Where** - -- src/pages/GeneratePage.tsx:195-203 -- src/pages/AttackPage.tsx:67-75 -- src/pages/FactoidsPage.tsx:91-97 -- src/pages/{Generate,Attack,Brainstorm,Factoids}Page.tsx handleDiscuss (askChat outside AskPage's request tracking; subscription modal state lives on the page that just unmounted) - -**What the reviewer saw** - -Simulated via route fakes (faked Generate result and faked Ask reply; never reached staging). - -The Discuss template literal indents its lines by 4 spaces after a blank line. react-markdown therefore renders them as an indented code block. The user bubble contains <pre><code> with '**Thesis:** …', '**Arguments:**' and 'Can we refine this?' in monospace on a dark block that overflows the bubble (hasPre true, 4 literal '**'). - -Clicking Clear on Ask while the Discuss reply was in flight left the chat holding just the orphan reply 'FAKE DISCUSS REPLY (simulated)'. - -The summary text ('I'd like to discuss…') is hard-coded English. Limit or model-restriction errors raised after navigate('/app/ask') set modal state on the unmounted tool page, so they would show nothing (code-inferred). - -**Why it happens** - -Indented multi-line template literals produce the markdown. Discuss fires its own askChat from the tool page instead of going through AskPage's send path (request id, abort, streaming, subscription modals). - -**Suggested fix** - -- Build the summary with an array join and no leading indentation, using i18n strings. -- Instead of calling askChat from the tool page, navigate to /app/ask with the prefilled message in router state, and let AskPage send it through handleSend (streaming, abort on Clear, its own modals). - -**Who it hits** - -Every Discuss click from Generate, Attack or Factoids shows a broken-looking message, and the reply can't be cancelled. - -**Independent check** - -Code: the Discuss template literal in GeneratePage.tsx:195-203 (same pattern in Attack/Factoids) indents lines with 4 spaces after a blank line, which Markdown renders as an indented code block. handleDiscuss calls askChat from the tool page outside AskPage's requestId/abort tracking, so Clear on Ask cannot cancel it. Reproduced (faked AI reply, held 3.5 s): the first Ask bubble contains a <pre> with '**Thesis:** ...', '**Arguments:**' and '1. ZZ claim 1' in monospace on a dark block (4 literal **). Clicking Clear before the reply arrived left the chat holding only 'FAKE DISCUSS REPLY (verifier)'. The English-only summary text is confirmed in code. The unmounted-page modal claim is code-inferred and plausible (subError state lives on the tool page). - -**Evidence files** - -[discuss-sim-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/discuss-sim-desktop.json), [discuss-user-bubble-desktop.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/discuss-user-bubble-desktop.png), [discuss-after-clear-desktop.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/discuss-after-clear-desktop.png), [gen-sim-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/gen-sim-desktop.json), [discuss-desktop-first-bubble.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/raw/discuss-desktop-first-bubble.png), [discuss-desktop-after-clear.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/raw/discuss-desktop-after-clear.png) - - -### ai-surfaces-11: Dashed loading card drops 10 px, blurs and flashes on every tool submit (Adam's dashed-border example) - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `motion-swap-translate-blur-on-state-swap` - -**Where** - -- src/pages/GeneratePage.tsx:384 and :399 (empty and loading cards both dashed, keyed separately, class motion-swap) -- src/pages/AttackPage.tsx:139 -- src/pages/BrainstormPage.tsx:127 -- src/pages/FactoidsPage.tsx:145 -- src/index.css:349-351 and :932-942 (motion-swap: opacity 0, translateY(10px) scale(.99) blur(2px)) -- src/components/ui/streaming-indicator.tsx:53 (a second motion-swap inside) - -**What the reviewer saw** - -Measured with a faked AI response: the click is real, and the AI request was intercepted so it never reached staging. Per-frame sampling of the results column after clicking Generate: -- t=61 ms: the dashed empty card at y=-1, opacity 1. -- t=125 ms: the new dashed loading card at y=11, opacity 0, transform matrix(0.99,…,0,10), blur 2px. -- Then it eases back: y=5.1 (opacity 0.49), 3.1, 1.7 … to 0 over about 300 ms. - -The same dashed box visibly blinks out, drops about 10 px and slides back up. The loading-to-results swap then scrolls the page (scrollY 280 to 199) while results stagger in. The submit label also shifts 13.5 px sideways ('Generating Case...' with spinner). All four tools share this. - -**Why it happens** - -Two visually identical dashed containers are swapped by key. Each mount replays the motion-swap entrance (translateY, scale, blur and opacity from 0), and StreamingIndicator adds its own motion-swap. - -**Suggested fix** - -Keep one persistent dashed container across empty, loading and error states and crossfade only its inner content (opacity only, no translate or blur). Or drop motion-swap from the loading card. Remove the inner motion-swap in StreamingIndicator. Give the submit button content a fixed width, or keep the icon slot constant. - -**Who it hits** - -Every submit on the four core tools. It is exactly the 'moves down and back up when clicked' glitch Adam described. - -**Independent check** - -Code: the empty and loading cards are separate keyed elements that both carry motion-swap (GeneratePage.tsx:384 and :399; Attack 139, Brainstorm 127, Factoids 145), and @keyframes motion-swap starts at opacity 0, translateY(10px) scale(.99), blur(2px) (index.css:349-351, 932-943). Reproduced on desktop with a faked AI response, per-frame sampling of the results column after clicking Generate: empty dashed card at y=15, opacity 1 until t=91 ms; at t=140 ms the new dashed loading card is at y=27, opacity 0, matrix(0.99,0,0,0.99,0,10), blur(2px); it eases back (y 23.8, 21.1, 17.7, 16.1 ...) to y=15 by about 340 ms. The submit label 'Generating Case...' shifts 13.5 px sideways and resizes 27 px (trackMovement). Different mechanism from motion-polish-4 (dashed-hover-animation-restarts-entrance, the hover/marching-ants restart): deleting the hover rule does not stop this click-time dip, so it needs its own fix (one persistent container, opacity-only crossfade). - -**Evidence files** - -[generate-click-dashed-swap-desktop.webm](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/generate-click-dashed-swap-desktop.webm), [swap-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/swap-desktop.json), [swap-contact-sheet.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/swap-contact-sheet.png), [sim-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/sim-desktop.json), [gen-sim-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/gen-sim-desktop.json), [gen-desktop-before.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/raw/gen-desktop-before.png), [gen-desktop-loading.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/raw/gen-desktop-loading.png) - - -### ai-surfaces-12: Saved results can't be managed: Crossfire debriefs can't be reopened, and chats/attacks/brainstorms/factoids/crossfires can't be deleted - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `saved-results-missing-crossfire-and-delete` - -**Where** - -- src/pages/HistoryPage.tsx:137-144 (tabs: Cases, Chats, Attacks, Brainstorms, Factoids, Sparring; no Crossfire) -- src/pages/CurrentDebatePage.tsx:44 (canOpen = item.kind !== 'crossfire') -- src/pages/HistoryPage.tsx:160-340 (delete buttons only for cases and sparring) -- src/lib/stores/session-store.ts:176, 209 (only deleteSavedCase and deleteSavedFlowchart exist) - -**What the reviewer saw** - -My Crossfire round recorded a `crossfires` row (55b70273…) with the full debrief. History has no Crossfire tab, and the Current Debate page lists the item without a Load button, so the debrief can never be revisited once the student clicks 'Start another crossfire' or the local state is gone. - -History offers Load but no Delete for Chats, Attacks, Brainstorms and Factoids, and the store has no delete action for those kinds or for crossfires. Every run is auto-recorded, so failed, duplicate or test runs (and the duplicates from finding 7) can't be removed except by deleting the whole debate. For cleanup I had to delete them through PostgREST. - -**Why it happens** - -Auto-recording was added for every tool, but the History and Current Debate surfaces only got load paths for some kinds and delete paths for two. - -**Suggested fix** - -Add a Crossfire tab and an openable item that restores the debrief view read-only. Add delete actions (store and UI, with a confirm dialog) for chats, attacks, brainstorms, factoids and crossfires. RLS 'for all' policies already allow owner deletes. - -**Who it hits** - -Students lose Crossfire feedback after one session and can't tidy their History. - -**Independent check** - -Code: HistoryPage tabs are Cases, Chats, Attacks, Brainstorms, Factoids, Sparring (HistoryPage.tsx:137-144); savedCrossfires is hydrated but never rendered except as a non-openable row (CurrentDebatePage.tsx:44 canOpen = kind !== 'crossfire'); delete UI and store actions exist only for cases, sparring and flowcharts (session-store.ts:176, 209). Verified on staging History: tab list has no Crossfire; Cases has 1 row with 1 delete button, Attacks 1 row with 0 delete buttons, Factoids 1 row with 0 delete buttons. - -**Evidence files** - -[crossfire.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/crossfire.json), [crossfire-debrief.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/crossfire-debrief.png), [history-desktop-light.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/history-desktop-light.png), [history-desktop-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/history-desktop-light.json), [misc-tools.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/misc-tools.json), [history-attacks-tab.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/raw/history-attacks-tab.png) - - -### ai-surfaces-14: An Ask exchange that finishes while offline is silently not recorded, and never retried when back online - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `getuser-network-fallback-local-only-writes` · **Same fix as:** integrity-money-1 - -**Where** - -- src/lib/stores/session-store.ts:1299-1308 (syncAskChatNow: getUserId() returns null offline, then returns silently) -- src/pages/AskPage.tsx:79-90 (only re-runs when message count or loading changes) - -**What the reviewer saw** - -Real run, cheap model. I went offline (context.setOffline(true)) mid-stream. Chromium kept the open stream, and the 7,456-character answer completed while offline. The page then showed no toast; no chats or messages insert was ever made (created [] for this run); after going back online, still nothing. The exchange shows on screen but is missing from History and the Current Debate. - -The console shows ERR_INTERNET_DISCONNECTED for the GET /auth/v1/user that syncAskChatNow uses. Compare the short run online, which recorded chats b5616632 and its messages. - -**Why it happens** - -syncAskChatNow uses a network getUser() call and treats its failure as 'signed out', exiting without an error or a retry. Nothing schedules a re-sync on reconnect. - -**Suggested fix** - -Use the cached session user id. On a failed write, keep the pending count and retry on the 'online' event, or show the existing 'Could not save this result to the debate' toast with Retry. - -**Who it hits** - -Students on flaky school wifi lose answers from History without knowing it. - -**Independent check** - -Code: syncAskChatNow returns silently when getUserId() (a supabase.auth.getUser network call) yields null (session-store.ts:1299-1308), and AskPage's auto-record effect (AskPage.tsx:79-90) only re-runs when message count or loading changes, so nothing retries on 'online'. Reproduced: fake SSE stream, context.setOffline(true) mid-stream; the answer finished on screen, GET /auth/v1/user failed with ERR_INTERNET_DISCONNECTED, no chats/messages write was attempted and no toast shown; 5 s after going back online still no write and no toast. Same cause as integrity-money-1 (syncAskChatNow is listed there); the missing online retry is the only extra piece. - -**Evidence files** - -[ask-offline-desktop-DeepSeekV4Flash.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/ask-offline-desktop-DeepSeekV4Flash.json), [ask-offline-desktop-DeepSeekV4Flash-after-offline.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/ask-offline-desktop-DeepSeekV4Flash-after-offline.png), [ask-sim-desktop-all.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/ask-sim-desktop-all.json) - - -### a11y-i18n-1: Timer page global shortcuts hijack Escape, Space and arrows: closing a dialog resets a running timer, Space on any button toggles the timer - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `timer-global-shortcuts-unscoped` - -**Where** - -- src/pages/TimerPage.tsx:249-272 -- /app/timer - -**What the reviewer saw** - -The window keydown handler skips only input and textarea targets. For Space it calls preventDefault and toggleTimer; for KeyR and Escape it calls resetTimer; ArrowLeft/Right change the speech. Measured on staging as trial (desktop): after Start, timer at 3:57. Focus the header Language button and press Enter (dialog opens), then Escape to close it: timer goes to 4:00 PAUSED. Start again, focus the Language button, press Space: the dialog does not open and the timer pauses (SPEAKING→PAUSED). Focus 'Disable sound', press Space: sound is not toggled (label still 'Disable sound') and the timer toggles instead. Focus the format select, press ArrowRight: speech jumps from 3:59 Introductor to 6:00 Refutation 1. AppLayout's own Escape listener only closes the mobile menu; grep found no other global keydown handler. - -**Why it happens** - -The shortcut handler is on window and ignores everything except input/textarea. It does not check event.defaultPrevented, focused interactive elements (button, [role=combobox|switch|slider|option]) or open overlays, and Escape is bound to reset. - -**Suggested fix** - -In TimerPage.tsx, return early when e.defaultPrevented, when e.target is inside button, a, select, [role=combobox], [role=switch], [role=slider], [role=listbox], [role=dialog] or [contenteditable], or when any [role=dialog] or [data-radix-popper-content-wrapper] is open. Drop Escape from the reset keys (keep R). Optionally scope Space and arrows to when focus is on body or the timer card. - -**Who it hits** - -Every keyboard user on the Timer, and any user who presses Escape to close a dialog or select while timing a speech, loses the running time mid-speech. Space cannot press buttons on this page. - -**Independent check** - -Code TimerPage.tsx:249-272 (window keydown, skips only input/textarea; Escape and KeyR reset, Space preventDefault+toggle, arrows change speech). Reproduced on staging (trial, desktop): running 3:57 -> open Language dialog with Enter -> Escape -> '4:00 PAUSED' (reset). Space on focused Language button: dialog did not open, timer toggled. Space on 'Disable sound': label unchanged, timer toggled instead. ArrowRight on format combobox: jumped to '6:00 Refutation 1'. - -**Evidence files** - -[timer-escape-space-arrow-shortcuts.webm](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/keyboard/video/timer-escape-space-arrow-shortcuts.webm), [timer-after-escape-dialog.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/keyboard/timer-after-escape-dialog.png), [s2-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s2-light.json) - - -### a11y-i18n-2: Light-mode --primary fails AA: primary buttons 3.86:1, primary-coloured links 3.75:1 - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `light-primary-token-too-light` - -**Where** - -- src/index.css:23-24 (--primary 200 98% 39% = #0284c5, --primary-foreground #f0f9ff) -- Every bg-primary button on /, /login, /share/*, /app, /app/attack, brainstorm, crossfire, factoids, flowchart, generate, library, settings, sparring, timer, ask -- text-primary links: /expocit/sources (20 links), /app/library (Change link, badges) - -**What the reviewer saw** - -axe color-contrast (serious), light mode only, desktop and mobile. #f0f9ff on #0284c5 = 3.85:1 (34 nodes across 15 routes, including the landing hero preview toggle at 11px, the Timer Start button and the Generate/Attack submit buttons). #0284c5 text on #f1f5f9 = 3.75:1 (42 nodes on /expocit/sources and /app/library). #0284c5 on #dfeef7 badge = 3.46:1. Dark mode passes (axe found none of these in dark). - -**Why it happens** - -One light-theme token is too light for white text and too light as text on the near-white background. - -**Suggested fix** - -Darken the light --primary in src/index.css to about hsl(200 98% 32%) (sky-700 #0369a1). This gives 5.57:1 with #f0f9ff and 5.42:1 as text on #f1f5f9. Keep --ring in sync and re-run axe on the route matrix. - -**Who it hits** - -All light-mode users (the default for many) see low-contrast text on the main call-to-action buttons and on links. - -**Independent check** - -Computed + axe on staging (light): #f0f9ff on #0284c5 = 3.85:1 on bg-primary buttons on /login (Google, Send code), /app, generate, library, settings, timer Start; #0284c5 text on #f1f5f9 = 3.75:1 (library link) and 3.46:1 on the #dfeef7 badge. Light --primary is src/index.css:23. Sky-700 #0369a1 gives 5.56:1 / 5.41:1. Dark passes. - -**Evidence files** - -[summary.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/axe/summary.txt), [trial-desktop-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/axe/trial-desktop-light.json), [anon-desktop-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/axe/anon-desktop-light.json), [anon-desktop-hu_login.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/raw/i18n/anon-desktop-hu_login.png), [s1-signed-out.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s1-signed-out.json), [s2-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s2-light.json) - - -### a11y-i18n-3: Form controls with no accessible name: Radix Select triggers, Factoids slider, Timer progress bar - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `select-triggers-unlabelled` - -**Where** - -- src/components/features/generate/GenerateForm.tsx:176,189 (Side, Speech role) -- src/components/features/brainstorm/BrainstormForm.tsx (side, output format selects) -- src/components/features/factoids/FactoidsForm.tsx:98,115 (Position, Source type) and :134-139 (Slider thumb) -- src/pages/LibraryPage.tsx:496 (folder filter, w-[160px]; the sort select at :547 is labelled) -- src/pages/FlowchartPage.tsx node-type select ('Support') -- src/pages/TimerPage.tsx:347 (<Progress>) - -**What the reviewer saw** - -axe button-name (critical): 16 nodes on /app/generate and /app/brainstorm, 8 on /app/factoids, 4 each on /app/library and /app/flowchart (all 4 theme/device combos). axe aria-input-field-name (serious): the Factoids slider span[role=slider]. axe aria-progressbar-name (serious): the Timer progress bar. In each case the visible <Label> has no htmlFor/id link to the SelectTrigger, and role=combobox does not take its name from its content. The keyboard walk confirms screen readers get only the value ('In favor', 'R1 - Rebuttal/Arg (6 min)'). - -**Why it happens** - -shadcn <Label> is rendered without htmlFor, and <SelectTrigger> has no id or aria-labelledby. Slider's Label htmlFor points at the root, not the thumb. Progress has no label. - -**Suggested fix** - -Give each SelectTrigger an id and set Label htmlFor to it (or aria-labelledby). Pass aria-label to the Slider thumb (or aria-labelledby on Slider). Add aria-label={t('timer.displayLabel')} or similar to the Timer <Progress>. Add an aria-label to the Library folder filter. Fold into the axe route check from next-15 #14. - -**Who it hits** - -Screen-reader and voice-control users cannot tell which setting a dropdown controls on every Prepare tool form. - -**Independent check** - -Own axe run (trial, light, desktop): button-name 2 on /app/generate (OptionSelect, GenerateForm.tsx:35-50 used at :177,:190), 2 on /app/brainstorm (BrainstormForm.tsx:96-121), 2 on /app/factoids (FactoidsForm.tsx:98-122) + aria-input-field-name 1 (slider thumb, Label htmlFor targets the root at :134), 1 on /app/library (folder filter LibraryPage.tsx:495; the sort select is labelled), 1 on /app/flowchart (node-type select), aria-progressbar-name on /app/timer (TimerPage.tsx:347). - -**Evidence files** - -[summary.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/axe/summary.txt), [walk-trial-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/keyboard/walk-trial-desktop.json), [s2-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s2-light.json) - - -### a11y-i18n-4: Inactive-debate gate opens with focus on <body> behind the modal - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `alertdialog-without-cancel-no-initial-focus` - -**Where** - -- src/components/features/debate/DebateDialogs.tsx:186-205 (InactiveDebateGate AlertDialog, no AlertDialogCancel) -- Any /app route after 5 days without activity - -**What the reviewer saw** - -On staging as trial with the gate showing, document.activeElement was <body data-scroll-locked style='pointer-events:none'>, not inside the alertdialog. Radix AlertDialogContent's onOpenAutoFocus calls event.preventDefault() and then cancelRef.current?.focus() (node_modules/@radix-ui/react-alert-dialog/dist/index.mjs:58-60). This gate renders no AlertDialogCancel (DebateDialogs.tsx has 0 AlertDialogCancel), so focus goes nowhere. Escape is also blocked here by design (onEscapeKeyDown preventDefault). After Continue, focus stays on body. - -**Why it happens** - -An AlertDialog with no Cancel element gets no initial focus because Radix's built-in autofocus targets the Cancel ref. - -**Suggested fix** - -Pass onOpenAutoFocus to AlertDialogContent in InactiveDebateGate that focuses the Continue button (ref), or render Continue as AlertDialogCancel asChild. After continuing, move focus to the page h1 or main. - -**Who it hits** - -Keyboard and screen-reader users land on a blocking modal without its title being announced and have to Tab blindly into it. It shows to every returning student after 5 days away. - -**Independent check** - -Code: DebateDialogs.tsx:186-205 InactiveDebateGate renders no AlertDialogCancel (0 occurrences in file); Radix AlertDialogContent onOpenAutoFocus preventDefaults and focuses cancelRef only (node_modules/@radix-ui/react-alert-dialog/dist/index.mjs:58-60), so focus is left outside the dialog. (provisional) - -**Evidence files** - -[trial-gate-dialog-desktop.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/raw/trial-gate-dialog-desktop.png) - - -### a11y-i18n-5: Library source rows: Enter on the link and Space on the switch open the viewer instead (nested interactive role=button) - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `library-row-nested-interactive` · **Known in plan:** next-15 #14 (partly done: keyboard handlers exist, but the nesting and preventDefault remain) - -**Where** - -- src/pages/LibraryPage.tsx:69-79 (div role=button tabIndex=0 with onKeyDown preventDefault on Enter/Space) -- src/pages/LibraryPage.tsx:85 (inner external <a>), :128-145 (inner Delete button and Switch) -- /app/library - -**What the reviewer saw** - -On staging, trial, desktop: focus the inner source link and press Enter. No new tab opens; the source viewer dialog opens instead. Focus the inner 'Toggle …' switch and press Space: aria-checked stays true and the source viewer opens. The outer row's onKeyDown receives the bubbled key, calls preventDefault (which cancels the inner element's activation) and calls onOpen. axe nested-interactive (serious) flags both rows on all 4 combos. axe target-size also flags the inner link (truncated inline link under 24px tall). The Delete button inside the row is inferred to behave the same (Enter keydown bubbles and is prevented); I did not test it, to avoid deleting seeded data. - -**Why it happens** - -The whole row is a pseudo-button that contains real interactive controls, and its key handler does not check event.target === event.currentTarget. - -**Suggested fix** - -Make the title a real <button> that opens the viewer (or a stretched-link pattern) and keep the link, delete and switch as siblings outside it. As a stopgap, return early in onKeyDown when event.target !== event.currentTarget. - -**Who it hits** - -Keyboard users cannot open a source's original URL, switch a source on or off, or (by code) delete it from the Library list. - -**Independent check** - -Code LibraryPage.tsx:69-79: row div role=button onKeyDown preventDefaults Enter/Space for any bubbled target and opens the viewer; inner link (:85), Delete button (:128) and Switch (:139) are nested. Reproduced (trial, desktop): Space on the focused 'Toggle Teaching to the test…' switch left aria-checked=true and opened the source viewer; Enter on the inner source link opened the viewer, no new tab. axe nested-interactive on both rows. The same bubbling means Enter on the inner Delete button opens the viewer too (not tested, to protect seeded data). Fix: return early when event.target !== event.currentTarget, or restructure. - -**Evidence files** - -[kb-library-enter-on-link-desktop.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/keyboard/kb-library-enter-on-link-desktop.png), [kb-library-space-on-switch-desktop.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/keyboard/kb-library-space-on-switch-desktop.png), [dialogs-desktop.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/keyboard/dialogs-desktop.txt), [s3.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s3.json), [s3-library-switch-space.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s3-library-switch-space.png), [s2-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s2-light.json) - - -### a11y-i18n-6: Flowchart nodes cannot be edited or deleted with the keyboard - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `flowchart-selection-pointer-only` - -**Where** - -- src/pages/FlowchartPage.tsx:756-757 (onNodeClick sets selectedNode; onNodeDoubleClick opens edit) -- src/pages/FlowchartPage.tsx:714-745 (Selected panel with Edit/Delete only renders when selectedNode is set) -- /app/flowchart - -**What the reviewer saw** - -On staging, trial, desktop: Tab to the Thesis node (div role=button 'Thesis Your Thesis Click to edit…') and press Enter. React Flow draws its selected ring, but no 'Selected' panel appears, no dialog opens, and focus stays on the node. The custom selectedNode state is only set by mouse onNodeClick, so keyboard selection never shows Edit or Delete, and editing needs a double-click. The node copy says 'Click to edit'. Each node and each of the 5 edges is also a Tab stop (11 stops before the zoom controls). - -**Why it happens** - -App selection state is wired to pointer events instead of React Flow's selection state (onSelectionChange / node.selected). - -**Suggested fix** - -Drive selectedNode from onSelectionChange (or derive it from nodes.find(n => n.selected)) so Enter/Space selection shows the panel. Add an onKeyDown on the custom node (Enter opens openEditDialog). Consider edgesFocusable={false}. - -**Who it hits** - -Keyboard and switch users can add nodes but cannot change or remove any of them. - -**Independent check** - -Reproduced (trial, desktop): Tab focus on Thesis node, Enter -> React Flow adds 'selected' class but no 'Selected' panel, no dialog; mouse click shows the panel. FlowchartPage.tsx:756-757 wires selectedNode only to onNodeClick/onNodeDoubleClick. - -**Evidence files** - -[kb-flowchart-node-enter-desktop.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/keyboard/kb-flowchart-node-enter-desktop.png), [walk-trial-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/keyboard/walk-trial-desktop.json), [s2-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s2-light.json), [s2-flowchart-keyboard-enter.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s2-flowchart-keyboard-enter.png) - - -### a11y-i18n-7: Server and AI-client error messages appear in English under the Hungarian and Spanish UI - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `raw-error-messages-forwarded-to-users` - -**Where** - -- supabase/functions/import-source/index.ts:242-345 (English error strings) shown raw by src/pages/LibraryPage.tsx:252 -- src/lib/supabase/edge-function-error.ts:2,44-49 (returns body.error or 'Request failed (HTTP n)') -- src/lib/ai/error-display.ts:31-63 (all getUserFacingAIError strings are English; used by 6 tool pages) -- src/lib/ai/client.ts:925-929, 989-992 ('Authentication failed…', 'AI service error: …. Please try again.') -- src/pages/GeneratePage.tsx:105-107 (error.message plus hard-coded ' uses') -- src/pages/LoginPage.tsx:57,89,110,123,143, src/components/features/subscription/PricingModal.tsx:47,73, src/pages/SettingsPage.tsx:113,130 (error.message as toast body) - -**What the reviewer saw** - -Live on staging: with trial UI set to Magyar, Library → Forrás Hozzáadása → URL https://debatelab-staging.vercel.app/favicon.png → URL importálása. import-source answered 415, and the toast showed the Hungarian title 'Import sikertelen' over the English body 'Only HTML and plain text pages can be imported from a URL.' The other paths are confirmed in code. They cover daily import limit, suspended account, timeouts, OTP and demo-code errors, checkout, portal, promo and AI provider/rate-limit messages. - -**Why it happens** - -Edge Functions return human English sentences instead of stable codes, and the client forwards error.message to toasts. - -**Suggested fix** - -Have Edge Functions return a machine code (e.g. code: 'unsupported_type', 'daily_limit', 'timeout') next to error. Map codes to i18n keys in getEdgeFunctionErrorMessage and getUserFacingAIError, falling back to a translated generic message rather than raw text. Remove the hard-coded ' uses' in GeneratePage. - -**Who it hits** - -Hungarian and Spanish students (the main audience) get English text exactly when something goes wrong. - -**Independent check** - -Reproduced: trial UI set to Magyar, Library -> 'Forrás Hozzáadása' -> URL https://debatelab-staging.vercel.app/favicon.png -> 'URL importálása': import-source returned 415 and the toast read 'Import sikertelen' over the English body 'Only HTML and plain text pages can be imported from a URL.' Nothing was created (still 2 sources). Code paths: LibraryPage.tsx:250-253 forwards error.message; edge-function-error.ts:44-49 returns body.error or 'Request failed (HTTP n)'; error-display.ts:31-63 English-only strings; GeneratePage.tsx:105 appends hard-coded ' uses'. Same cause and fix (stable error codes mapped to i18n keys) as raw-error-messages-forwarded-to-users. Language restored to English right after. - -**Evidence files** - -[hu-import-error-raw-english.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/i18n/hu-import-error-raw-english.png), [s4-hu-import.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s4-hu-import.json), [s4-hu-import-toast.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s4-hu-import-toast.png) - - -### a11y-i18n-8: document.title is 'DebateLab' on every route and overwrites the landing page's SEO title after hydration - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `no-per-route-document-title` - -**Where** - -- src/i18n.ts:22 (document.title = i18n.t('home.title')) -- src/lib/stores/settings-store.ts:370 (onRehydrateStorage calls applyAppLanguage on every load) -- index.html:9 (original title 'DebateLab by Pi-Stack | AI Debate Prep, Cases & Rebuttals') - -**What the reviewer saw** - -Every one of the 84 axe page scans (public and app, both languages tested) recorded document.title === 'DebateLab', including '/' signed out. Navigating /app → Library via keyboard leaves the title 'DebateLab'. No per-route title code exists (grep for document.title or Helmet finds only i18n.ts). - -**Why it happens** - -Language application sets one global title and there is no route-level title. - -**Suggested fix** - -Set a translated per-route title in RouteContent (App.tsx) from ROUTE_FALLBACKS labels, e.g. `${label} · DebateLab`, updated on pathname and language change. Stop applyAppLanguage from touching the title, and keep index.html's full SEO title for '/' (localized). - -**Who it hits** - -Screen-reader users get no page identity on navigation (WCAG 2.4.2), every tab and history entry reads 'DebateLab', and JS-rendering crawlers see 'DebateLab' instead of the marketing title on the landing page. - -**Independent check** - -Staging: document.title is 'DebateLab' on / (signed out, after load), /login, 404 and all 12 app routes scanned, although index.html:9 ships 'DebateLab by Pi-Stack | AI Debate Prep, Cases & Rebuttals'. Cause src/i18n.ts:22 in applyAppLanguage, called on rehydrate (settings-store.ts:370). Landing SEO title loss is the costlier half (crawlers that run JS see 'DebateLab'). - -**Evidence files** - -[anon-desktop-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/axe/anon-desktop-light.json), [trial-desktop-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/axe/trial-desktop-light.json), [s1-signed-out.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s1-signed-out.json), [s2-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s2-light.json) - - -### a11y-i18n-9: Dark mode: Flowchart zoom controls are white-on-white and the minimap is a white box - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `reactflow-default-light-styles` - -**Where** - -- src/pages/FlowchartPage.tsx:132-148 (ControlButtons), :764 (MiniMap) -- /app/flowchart in dark mode - -**What the reviewer saw** - -Computed styles in dark mode: each .react-flow__controls-button has background rgb(254,254,254) and color rgb(248,250,252), with the Lucide icon stroke using currentColor, so the icons are invisible. The .react-flow__minimap background is rgb(255,255,255). The screenshot shows four blank white squares bottom-left and a white panel bottom-right on the dark canvas. - -**Why it happens** - -React Flow 11's default stylesheet hard-codes a light control background, while the icons inherit the dark theme's foreground colour. No theme override exists in index.css. - -**Suggested fix** - -Add theme overrides in src/index.css: .react-flow__controls-button { background: hsl(var(--card)); color: hsl(var(--foreground)); border-color: hsl(var(--border)); } and .react-flow__minimap { background: hsl(var(--card)); }, or pass className/style. Use maskColor on MiniMap. - -**Who it hits** - -Dark-mode users cannot see the zoom, fit and lock controls. - -**Independent check** - -Staging /app/flowchart, trial, colorScheme dark (html.dark): every .react-flow__controls-button background rgb(254,254,254) with color rgb(248,250,252) (icons invisible); .react-flow__minimap background rgb(255,255,255). Screenshot shows four blank white squares bottom-left and a white minimap box. No override in src/index.css. - -**Evidence files** - -[trial-desktop-dark_app_flowchart.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/raw/axe/trial-desktop-dark_app_flowchart.png), [flowchart-dark-controls.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/misc/flowchart-dark-controls.png), [flowchart-dark-minimap.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/misc/flowchart-dark-minimap.png), [s2-dark.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s2-dark.json), [s2-flowchart-dark.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s2-flowchart-dark.png) - - -### a11y-i18n-10: Timer display is a polite, atomic live region, so screen readers re-announce it every second - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `timer-live-region-every-tick` - -**Where** - -- src/pages/TimerPage.tsx:329 (role=timer aria-live=polite aria-atomic=true) - -**What the reviewer saw** - -While running, the timer region mutated 6 times in 4.2 s and its full text ('3:56 SPEAKING Introductor') is atomic, so each tick queues a full announcement. role=timer is implicitly aria-live=off; the explicit polite overrides that. - -**Why it happens** - -aria-live was added to the ticking element itself. - -**Suggested fix** - -Remove aria-live/aria-atomic from the ticking display (keep role=timer). Add a separate sr-only polite region announcing only state changes: started, paused, 1 minute left, time up, speech changed. - -**Who it hits** - -A screen-reader user timing a speech hears constant chatter that drowns out everything else. - -**Independent check** - -Code TimerPage.tsx:329 role=timer aria-live=polite aria-atomic=true; staging DOM shows the same attributes and the text changes every second while running. - -**Evidence files** - -[dialogs-desktop.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/keyboard/dialogs-desktop.txt), [s2-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s2-light.json) - - -### journeys-5: The 'Still preparing this debate?' gate blocks every /app route, including Settings and Sign out, and cannot be dismissed - -**Severity:** low (reviewer said medium) · **Verdict:** confirmed · **Root cause:** `inactive-gate-blocks-utility-routes` - -**Where** - -- src/components/features/debate/DebateDialogs.tsx:167-226 (InactiveDebateGate, onEscapeKeyDown preventDefault, open whenever the current debate is older than 5 days) -- src/components/layout/AppLayout.tsx:288 (rendered for every route under the app layout) -- src/lib/stores/debate-store.ts:26 (INACTIVE_DEBATE_MS = 5 days) - -**What the reviewer saw** - -18-expired.json, expired account (last activity 2026-10-02): the gate appeared on /app, /app/settings and /app/timer. Escape did not close it (gateAfterEscape: 1), and clicking the overlay did not close it either. On /app/settings, Sign out was not reachable (signOutReachable: 0), and neither were 'Download my data' and 'Delete account' (screenshot 18-expired-gate-settings.png). The wording: 'Still preparing this debate? <motion> Last activity 6 days ago. Choose how to continue.' with buttons Switch debate / Start new debate / Continue. The only way through is a write (Continue updates last_activity_at). I did not click it. The gate did not appear on /share/:code. - -**Why it happens** - -The gate is a modal mounted in the app layout with no route allow-list and no dismiss path, so a check about the debate blocks account and utility pages. - -**Suggested fix** - -Skip the gate on /app/settings, /app/timer, /app/history and /app/library (or only show it when a debate-scoped tool is opened). Let Escape or a close button mean 'not now' for the session (keep the 5-day reminder). Rename Continue to 'Keep working on this debate'. Consider a non-blocking banner on Home instead of a modal. - -**Who it hits** - -Every returning student after 5 days, i.e. most students between weekly club sessions. They must make a debate decision before they can even sign out of a shared school computer. - -**Independent check** - -Expired account's debate was continued by someone on 2026-10-08, so I simulated inactivity browser-side (route rewrite of the debates GET to last_activity_at = now-7d, no writes). Gate appeared on /app, /app/settings and /app/timer; Escape and an overlay click left it open (count 1); Sign out on /app/settings not clickable (trial click failed). Code: DebateDialogs.tsx:167-226 (onEscapeKeyDown preventDefault, no route allow-list), AppLayout.tsx:288. Downgraded to low: the gate is intentional and Continue is one click, but it should not block Settings/Sign out/Timer. - -**Evidence files** - -[18-expired-gate-app.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/18-expired-gate-app.png), [18-expired-gate-settings.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/18-expired-gate-settings.png), [18-expired.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/18-expired.json), [s5b-gate.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s5b-gate.json), [s5-expired-gate-settings.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s5-expired-gate-settings.png) - - -### journeys-11: Case PDF export: title overlaps the subtitle and long 'Target:' headings run off the page - -**Severity:** low (reviewer said medium) · **Verdict:** partly · **Root cause:** `pdf-export-no-text-wrap-in-headers` - -**Where** - -- src/lib/pdf-export-utils.ts:109-121 (2-line 18pt title at y=18; subtitle at fixed y=28 overlaps the second line; 3+ lines overflow the 40 mm header) -- src/lib/pdf-export-utils.ts:303-310 (addSubsectionLabel uses doc.text without splitTextToSize) -- src/lib/pdf-export-utils.ts:438 (counters use addSubsectionLabel for the full target argument) - -**What the reviewer saw** - -Exported the generated case from History (09-case-export.pdf, 5 pages) and rendered it with pdf.js. Page 1 header: the motion 'ZZ review This House would ban homework' wraps and 'homework' is drawn over 'In favor • CUSTOM Format' (09-case-export-pdf-header-crop.png). Page 4 'Prepared Counters': 'Target: A ban just pushes families toward private tutoring and worsens inequality (China's Do…' and 'Target: Well-designed short homework has a real positive effect, so the fix is better design, no…' are cut at the page edge (09-case-export-pdf-p4-crop.png). Extracted text also ends at 'China's Do'. The DOCX of the same case is fine. - -**Why it happens** - -The header layout uses fixed y positions, and the subsection label helper skips text wrapping. - -**Suggested fix** - -In addHeader, compute the subtitle y from titleLines.length times the line height and grow the header rect to fit, or clamp the title to 2 lines with an ellipsis. In addSubsectionLabel, wrap with doc.splitTextToSize(label, contentWidth - indent), call checkPageBreak for the full block height, and advance y per line. Add a unit test with a 200-character motion and target that asserts no text x exceeds pageWidth - margin (this is a renderer, so a regression is easy to miss by eye). - -**Who it hits** - -Every student who prints or submits a case PDF, which is the main hand-in format. Long motions are normal, and the counters section loses words. - -**Independent check** - -Exported my seeded ZZ case PDF from Generate and read glyph positions with pdfjs-dist: Counters label 'Target: A ban just pushes ... as seen in o' starts at x=62.4 and ends at x=602.5 on a 595.3pt page and the rest of the sentence is missing (addSubsectionLabel, pdf-export-utils.ts:303-310, no splitTextToSize) - confirmed. Header: 2-line title second baseline y=71.7pt, subtitle (11pt) baseline y=79.4pt, so the subtitle caps start right at the title baseline: touching, and descenders (g,j,p,y) on line 2 would overlap; 'drawn over' is overstated for my motion. 3+ line titles would collide fully (fixed y=28mm). Severity low. - -**Evidence files** - -[09-case-export.pdf](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/09-case-export.pdf), [09-case-export-pdf-header-crop.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/09-case-export-pdf-header-crop.png), [09-case-export-pdf-p4-crop.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/09-case-export-pdf-p4-crop.png), [09-case-export.pdf.render.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/09-case-export.pdf.render.png), [09-case-export.pdf.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/09-case-export.pdf.txt), [s4-case.pdf.text.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s4-case.pdf.text.json), [s4-case.pdf](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s4-case.pdf) - - -### journeys-14: Each AI run auto-saves research into the Library with duplicates, and Smart sort buries the student's own sources - -**Severity:** low (reviewer said medium) · **Verdict:** partly · **Root cause:** `autosave-sources-no-dedupe` - -**Where** - -- Auto-save of discovered sources into the 'Auto-saved' folder (AI tool path; src/components/features/AutoSavedSourcesUndo.tsx) -- src/pages/LibraryPage.tsx:343-350 (Smart sort ranks semantic metadata first, so manual sources sink) -- src/pages/LibraryPage.tsx:39 (600 px inner scroll list) - -**What the reviewer saw** - -Starting from my 2 hand-added sources, one Brainstorm and one Generate grew the debate's Library to 12 sources. 19-slow-toggle.json lists 4 exact duplicates (same title and URL): 'Homework Statistics | 2026 Edition' x2, 'Homework Statistics 2026: Insights from 2,000+ UK Students' x2, 'Homework Effectiveness: What the Research Shows | Future Proof' x2, 'Should Homework Be Banned Statistics (2026)…' x2. The Current Debate page lists the duplicate too (09-current-debate.aria.txt). With the default 'Smart' sort, my own 'ZZ review pasted text' and the Wikipedia import are the last two rows. A source added right after (20-offline-add.png) was not visible in the list without scrolling. The Brainstorm result offered no 'Undo auto-saved sources' button. The undo that exists (AutoSavedSourcesUndo) removes all auto-saved sources in the debate, not just the latest run's. The AI agent may also cover auto-save itself; this is about the Library journey. - -**Why it happens** - -Auto-save does not dedupe by URL within the debate. Smart sort favours rows with semantic metadata, which only auto-saved research has. - -**Suggested fix** - -Dedupe auto-saved sources by normalised URL per debate (upsert, or skip when the origin already exists). Default the Library sort to 'Date added (newest)', or pin user-added sources first. After a manual add or import, scroll to and highlight the new row. Scope 'Undo auto-saved sources' to the run that created them. - -**Who it hits** - -Every student who runs more than one tool. Their Library fills with repeated web pages and the evidence they pasted themselves drops out of sight. - -**Independent check** - -Code confirms: autosaveDiscoveredSources (ai/client.ts:1238-1276) inserts every retained source with no check for an existing origin in the debate, and there is no unique constraint on sources (only source_folders unique). Smart sort (LibraryPage.tsx:355-360) ranks by semantic metadata, so manual sources without it sink. AutoSavedSourcesUndo removes all Auto-saved sources of the debate, not the last run. I could not reproduce actual duplicates: two Brainstorm runs on admin (with 3 active manual sources) auto-saved nothing, and pro/trial have no duplicate origins. Downgraded to low. - -**Evidence files** - -[19-slow-toggle.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/19-slow-toggle.json), [19-library-after-tools.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/19-library-after-tools.png), [09-current-debate.aria.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/09-current-debate.aria.txt), [20-offline-add.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/20-offline-add.png), [s6-brain.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s6-brain.json) - - -### journeys-15: No-debate states disagree across pages and lead to dead ends (Library Clear All error, 'no other debates', History with nothing to do) - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `no-debate-empty-states-inconsistent` - -**Where** - -- src/pages/LibraryPage.tsx:466-484 (debate notice) + 560-581 (Clear All enabled) + 868-886 (No Sources Yet) + 897-913 (Grounding Disabled) -- src/components/features/debate/DebateDialogs.tsx:49-51 ('You have no other debates yet.' when there are none at all) -- src/pages/HistoryPage.tsx:107-108,135 (falls back to One-off and unsorted with no Start debate path) - -**What the reviewer saw** - -24-empty-states.json and screenshots, admin with no debates. /app/library shows three empty messages at once: a dashed 'Select or create a debate…' box with Start debate and Switch debate, a dashed 'No Sources Yet. Add sources to ground AI responses…' box, and a 'Grounding Disabled' card. Add Source is disabled, but New folder and Clear All are enabled. Clear All > confirm gives 'Error: Failed to clear sources' (25-keyboard.json). Switch debate opens a dialog saying 'You have no other debates yet.' /app/history shows 'One-off and unsorted work: Nothing outside your debates' with no hint to start a debate. /app/debate uses the EmptyState component and Home a dashed card, while tool pages show an inline 'No current debate. Results stay one-off until you start one. Start debate' strip: four different patterns for the same state. - -**Why it happens** - -Each page added its own no-debate handling during the Current Debate rework, with no shared component. - -**Suggested fix** - -Create one NoCurrentDebate component (icon, title, one primary 'Start debate', a secondary 'Switch' only when debates.length > 0) and use it on Home, Current Debate, Library and History. When no debate exists, hide Library's toolbar, stats and grounding card. Change the copy to 'You have no debates yet' when the list is empty. - -**Who it hits** - -Brand-new users, who meet all of these screens in their first minutes. - -**Independent check** - -Admin with no debates on /app/library: three dashed/empty blocks at once (debate notice, 'No Sources Yet', 'Grounding Disabled'); Add Source disabled but Clear All and New folder enabled; Clear All > confirm -> toast 'Error / Failed to clear sources' plus console.error DebateRequiredError. Switch debate dialog says 'You have no other debates yet.' with zero debates. /app/history shows only 'One-off and unsorted work: Nothing outside your debates' with no Start debate path. - -**Evidence files** - -[24-empty_app_library.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/24-empty_app_library.png), [24-empty_app_history.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/24-empty_app_history.png), [24-empty_app_debate.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/24-empty_app_debate.png), [25-switch-no-debates.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/25-switch-no-debates.png), [25-keyboard.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/25-keyboard.json), [24-empty-states.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/24-empty-states.json), [s2-empty.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s2-empty.json), [s2-empty-library.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s2-empty-library.png), [s2-empty-library-clearall-toast.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s2-empty-library-clearall-toast.png), [s2-empty-switch-dialog.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s2-empty-switch-dialog.png), [s2-empty-history.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s2-empty-history.png) - - -### journeys-16: Form dialogs swallow input: Flowchart Add Node and Save silently do nothing when empty, Enter never submits, labels are unlinked - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `form-dialogs-no-form-semantics` - -**Where** - -- src/pages/FlowchartPage.tsx:317-318 (handleSaveNode returns silently on empty label) -- src/pages/FlowchartPage.tsx:359-361 (handleSaveFlowchart returns silently on empty title) -- src/pages/FlowchartPage.tsx:562-646 (no <form>, no Cancel, <Label> without htmlFor) -- src/locales/en.json:1491 (Save Flowchart title placeholder is 'Node title...') -- src/components/features/debate/DebateForm.tsx:63-72 (motion is a textarea: Enter inserts a newline) -- src/pages/LibraryPage.tsx:691-708 (manual source Title/Content labels unlinked; validation only via toast) - -**What the reviewer saw** - -15-flowchart2.json and 16-flowchart-save.json: Add Node with an empty label leaves the dialog open with no message (addEmptyDialogStillOpen: 1). Enter in the label field does not submit (enterSubmitsAddNode: false). Save Flowchart with an empty title does nothing (saveEmptyStillOpen: 1), and Enter does not submit (enterSubmitsSave: false). The title textbox's accessible name is its placeholder 'Node title...' (saveDialogAria), and neither dialog has a Cancel button. In the create-debate dialog, Enter after typing the motion added '\n' (02-tour-create.json afterEnter). Library manual-source fields have accessible names only from placeholders ('Source title...'), and an empty save shows only a toast 'Title and content are required'. - -**Why it happens** - -These dialogs are built from loose inputs and onClick handlers instead of a <form> with labelled fields and inline validation like DebateForm's. - -**Suggested fix** - -Wrap each dialog body in <form onSubmit> so Enter submits. Link Label htmlFor to the input ids. Disable the primary button until valid, or show an inline role=alert message. Add a Cancel button. Fix the placeholder to 'Flowchart name…'. For the motion field, submit on Enter (Shift+Enter for a newline) or collapse newlines in normalizeMotion. - -**Who it hits** - -Keyboard and screen-reader users and anyone in a hurry: a click that does nothing looks broken. - -**Independent check** - -Flowchart Add Node: empty submit leaves dialog open with no message; Enter in label input does not submit; no Cancel button. Save Flowchart: empty title does nothing; input has placeholder 'Node title...' and 0 associated labels. Create-debate motion textarea: Enter inserted a newline. Code: FlowchartPage.tsx:317-318, 359-361, 562-646 (no <form>, Label without htmlFor), en.json flowchart.titlePlaceholder='Node title...', LibraryPage.tsx:704-718 unlinked labels. - -**Evidence files** - -[15-add-empty.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/15-add-empty.png), [16-save-dialog.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/16-save-dialog.png), [16-flowchart-save.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/16-flowchart-save.json), [02-tour-create.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/02-tour-create.json), [04-library-empty-save.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/04-library-empty-save.png), [s3c-flows.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s3c-flows.json), [s3-flows.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s3-flows.json) - - -### journeys-17: Flowchart editing rough edges: duplicate Edit/Delete buttons, blank Type when editing the thesis, new nodes dropped on top of others, no page h1 - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `flowchart-toolbar-duplicates-and-thesis-type` - -**Where** - -- src/pages/FlowchartPage.tsx:650-669 and 731-743 (two Edit and two Delete buttons when a node is selected) -- src/pages/FlowchartPage.tsx:614-625 (Type select has no 'thesis' option, so editing the thesis shows an empty trigger) -- src/pages/FlowchartPage.tsx:337-347 (new node position is random) -- src/pages/FlowchartPage.tsx:489 (title is a CardTitle h3; the page has no h1, unlike other tools) - -**What the reviewer saw** - -With 'Argument 1' selected, there are 2 'Edit' and 2 'Delete' buttons (editButtons: 2, deleteButtons: 2; 15-selected-duplicate-buttons.png). Editing the thesis shows an empty Type combobox (15-edit-thesis-dialog.png; aria 'combobox' with no value). The added 'ZZ review node' landed overlapping Argument 1 and 2 (15-selected-duplicate-buttons.png). The h1 list on /app/flowchart is just the header 'DebateLab' (14-flowchart.json). The sample thesis says 'Click to edit', but a single click only selects. On a phone the toolbar takes 3 rows and the canvas starts below the fold (21-m-flowchart.png). - -**Why it happens** - -Toolbar and selection panel were both given node actions, the thesis type was left out of the editor, and placement uses Math.random. - -**Suggested fix** - -Keep node actions in one place (the selection panel overlay from journeys-7). Show the type as read-only 'Thesis' when editing the thesis. Place new nodes below the selected node, or at the viewport centre offset from existing ones. Use an h1 page title like the other tools and change the thesis hint to 'Double-click to edit'. - -**Who it hits** - -Flowchart users; small but constant friction. - -**Independent check** - -With a node selected: 2 'Edit' and 2 'Delete' buttons (toolbar + Selected strip). Editing the thesis shows an empty Type combobox (text ''), as the Select has no 'thesis' item (FlowchartPage.tsx:614-625). New node position random (337-347). Page h1s: only 'DebateLab' (title 'Argument Flowchart' is a CardTitle). - -**Evidence files** - -[15-selected-duplicate-buttons.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/15-selected-duplicate-buttons.png), [15-edit-thesis-dialog.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/15-edit-thesis-dialog.png), [21-m-flowchart.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/21-m-flowchart.png), [14-flowchart.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/14-flowchart.json), [s3c-flows.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s3c-flows.json), [s3c-flowchart-node-selected.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s3c-flowchart-node-selected.png), [s3c-flowchart-edit-thesis.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s3c-flowchart-edit-thesis.png) - - -### journeys-18: Focus is not returned after closing the global debate dialogs - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `debate-dialog-focus-not-restored` · **Known in plan:** next-15 #14 - -**Where** - -- src/components/features/debate/DebateDialogs.tsx:125-160 (opened from the store, not a DialogTrigger, so Radix has no element to return focus to) -- src/components/features/debate/CurrentDebateMenu.tsx:31-36 - -**What the reviewer saw** - -25-keyboard.json: focused the header 'Start debate', pressed Enter (dialog opened), then Escape: document.activeElement became BODY. The same happened after closing Switch debate from the Library link (focusAfterEscSwitch: BODY). Keyboard users are thrown back to the top of the page. - -**Why it happens** - -Store-driven dialogs bypass Radix's trigger-based focus restoration. - -**Suggested fix** - -Record document.activeElement in openDialog and restore it in DialogContent onCloseAutoFocus (event.preventDefault(); saved?.focus()). Apply the same to DebateMotionGate. - -**Who it hits** - -Keyboard and screen-reader users of the most common dialog. - -**Independent check** - -Focused header 'Start debate', Enter opened dialog, Escape -> document.activeElement is BODY. DebateDialogs is store-driven with no DialogTrigger. knownInPlan next-15 #14. - -**Evidence files** - -[25-keyboard.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/25-keyboard.json), [s2-empty.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s2-empty.json) - - -### journeys-19: Exports leave out sections shown on screen, brainstorm files never name the motion, and filenames are inconsistent - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `exports-omit-case-sections` - -**Where** - -- src/lib/pdf-export-utils.ts:426-443 (only likelyArguments from predictedOpponentCase; no definitions, strategic weaknesses or key clashes) -- src/lib/export-utils.ts:30-196 (DOCX case: same omissions) -- src/lib/pdf-export-utils.ts:539+ and export-utils.ts:294+ (brainstorm title is 'Brainstorm Results' with no motion) -- src/components/features/ExportToolbar.tsx:60-67 (PDF/DOCX buttons have no busy state) - -**What the reviewer saw** - -Rendered and extracted both case exports (09-case-export.pdf.txt, 09-case-export.docx.txt): 0 matches for 'definition', 'weakness' and 'clash', while the on-screen case shows Key Definitions, Strategic Weaknesses and Key Clashes (09-case-loaded.aria.txt). The brainstorm PDF and DOCX titles say only 'Brainstorm Results' (09-brainstorm-export.pdf.txt). Filenames: 'DebateCase_pro.pdf' and 'DebateCase_pro.docx' (no motion or date, so a second export collides), 'BrainstormResults.pdf' vs 'Brainstorm_2026-10-08.docx' for the same result. Format prints as the raw 'CUSTOM'. The PDF took 1.1 s to download with no spinner or toast (09-history-export.json pdfMs 1120). - -**Why it happens** - -PDF, DOCX and the on-screen view each render the output schema independently, with separate filename helpers. - -**Suggested fix** - -Drive the PDF and DOCX from one ordered section list matching GenerateResults. Put the motion in every export title. Name files '<kind>-<motion-slug>-<YYYY-MM-DD>.<ext>' through one helper. Localise the format label. Show a spinner on the clicked export button while jsPDF/docx load. - -**Who it hits** - -Students who export to study or hand in: missing sections and files that are hard to tell apart. - -**Independent check** - -My seeded case had keyDefinitions, strategicWeaknesses ('ZZ weakness...') and keyClashes ('ZZ clash...'); none of them appear in the exported PDF or DOCX text (0 matches), while GenerateResults.tsx:300-361 renders them. Filenames 'DebateCase_pro.pdf' / 'DebateCase_pro.docx' (no motion/date); brainstorm 'BrainstormResults.pdf' vs 'Brainstorm_<date>.docx' (pdf-export-utils.ts:592, export-utils.ts:369). PDF subtitle prints raw 'CUSTOM'. PDF/DOCX buttons have no busy state (ExportToolbar.tsx:60-67). - -**Evidence files** - -[09-case-export.docx.render.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/09-case-export.docx.render.png), [09-brainstorm-export.pdf.render.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/09-brainstorm-export.pdf.render.png), [09-case-export.pdf.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/09-case-export.pdf.txt), [09-history-export.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/09-history-export.json), [09-case-loaded.aria.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/09-case-loaded.aria.txt), [s4-case.pdf.text.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s4-case.pdf.text.json), [s4-case.docx.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s4-case.docx.txt), [s4-case.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s4-case.json) - - -### journeys-20: History is hard to scan: always opens on Cases, no counts, every item titled with the motion, no Flowcharts tab, tabs hidden on mobile - -**Severity:** low · **Verdict:** partly · **Root cause:** `history-layout-predates-current-debate` - -**Where** - -- src/pages/HistoryPage.tsx:136 (Tabs defaultValue="cases") -- src/pages/HistoryPage.tsx:137-144 (tab labels without counts; no flowcharts or crossfire) -- src/pages/CurrentDebatePage.tsx:44 (crossfire items listed without Load) -- src/pages/CurrentDebatePage.tsx:52-55 vs HistoryPage ('Load' text, 'Open …' aria-label, 'Resume' for chats) - -**What the reviewer saw** - -With only a brainstorm saved, History opened on 'Saved Cases: No saved cases' (07/09 runs). Tabs show no counts. On Pixel 7 the tab list scrolls (635 px content in a 380 px box), with 'Brainsto…' cut off and Factoids/Sparring hidden (21-m-history.png, tabsScroll [635,380]). Every case and brainstorm is titled 'ZZ review This House would ban homework', so two cases differ only by timestamp (11-cases-two.png). Saved flowcharts and crossfires have no History tab. The Current Debate page lists crossfire items with no way to open them. Side labels mix 'PRO/CON' (History badge, Brainstorm) with 'In favor/Against' (Current Debate) and 'Pro/Con' (Sparring), next to a 'Pro' plan badge. - -**Why it happens** - -History predates Current Debate and still uses a fixed per-kind tab layout with titles taken from the motion. - -**Suggested fix** - -Default to the first non-empty tab (or one 'All' list sorted by date), with counts on the tabs. Title items '<Kind> · <side> · <time>', or use the case thesis or top idea. Add Flowcharts and Crossfire. Use one side vocabulary (In favor / Against). On mobile, use a Select or wrapping chips instead of a horizontally scrolling tab bar. - -**Who it hits** - -Anyone returning to find earlier work, which is the point of History. - -**Independent check** - -Pixel 7, pro: History tablist scrollWidth 635 vs clientWidth 380; Brainstorms tab spans 301-432, Factoids 432-540, Sparring 540-647 on a 412px viewport, so they are cut/hidden; opens on Cases (Tabs defaultValue="cases", HistoryPage.tsx:136). No counts and no Flowcharts/Crossfire tab confirmed in code (137-144). The 'every item titled with the motion' and label-mix points are true but are copy/design nits. - -**Evidence files** - -[09-history-default.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/09-history-default.png), [21-m-history.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/21-m-history.png), [11-cases-two.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/11-cases-two.png), [21-mobile-a.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/21-mobile-a.json), [s7-mobile.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s7-mobile.json), [s7-pro-history-mobile.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s7-pro-history-mobile.png) - - -### journeys-21: Copy and naming a 15-year-old will stumble on - -**Severity:** low · **Verdict:** partly · **Root cause:** `copy-glossary-inconsistent` - -**Where** - -- src/locales/en.json:1590,1638,1641,1656 ('Source Library', 'Your Knowledge Base', 'Synced folders', 'Grounding Disabled') -- src/pages/LibraryPage.tsx:750 ('{{n}} sources' not pluralised: '1 sources') -- src/pages/TimerPage.tsx speech labels ('Introductor', 'Conclusor') -- src/components/features/debate/CurrentDebateStrip.tsx ('Use once') -- src/pages/HomePage.tsx:156 (Home h1 is just 'DebateLab', duplicating the header h1) -- src/locales/en.json:2243 ('You have no other debates yet.') - -**What the reviewer saw** - -One place has four names: nav 'Library', h1 'Source Library', card 'Your Knowledge Base', and stats 'Synced folders' / 'Grounding Active/Disabled' (04-library-*.png). The badge reads '1 sources' (23-m-library-after-add.png). The Timer's English UI uses 'Introductor (4m)', 'Conclusor (3m)' and 'R1 - Rebuttal/Arg' (12-timer.aria.txt). The 'Working in:' strip offers 'Change' and 'Use once' with no explanation of one-off mode. Home's main heading is just 'DebateLab' (01-app-first.aria.txt), giving two identical h1s. The Library sheet's 'Use for AI grounding' needs Save, while the row toggle applies at once. - -**Why it happens** - -Copy written at different times by feature, with no glossary and some machine-like terms. - -**Suggested fix** - -Settle on 'Library' / 'Sources' everywhere. Replace 'Synced folders' with 'Folders' and 'Grounding' with 'Used by AI'. Use i18next plurals for the source count. Rename the speech labels for English (e.g. 'Opening', 'Rebuttal 1', 'Rebuttal 2', 'Closing'; keep the Spanish names in es). Explain 'Use once' with a tooltip ('Run without saving to this debate'). Give Home a real h1 such as 'What do you want to work on?'. Run the copy through the es and hu catalogs too. - -**Who it hits** - -All users; first impressions and comprehension. - -**Independent check** - -Confirmed: badge renders '1 sources' (admin mobile with a filter; LibraryPage.tsx:750 concatenates count + 'sources', not i18n plural). Library card 'Your Knowledge Base' vs nav 'Library' vs stat 'Synced folders' seen in screenshot; Timer uses 'Introductor (4m)' / 'Conclusor (3m)'; Home h1 'DebateLab' duplicates the header h1 (AppLayout.tsx:185 + HomePage.tsx:155). The rest is subjective copy taste. - -**Evidence files** - -[04-library-add-form.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/04-library-add-form.png), [23-m-library-after-add.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/23-m-library-after-add.png), [12-timer.aria.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/12-timer.aria.txt), [01-app-first.aria.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/01-app-first.aria.txt), [s7b.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s7b.json), [s7-admin-library-1-sources.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s7-admin-library-1-sources.png), [s7-pro-library-mobile-rows.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s7-pro-library-mobile-rows.png), [s3b-timer-after-escape.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s3b-timer-after-escape.png) - - -### journeys-22: Settings tells every user they 'Signed in with Google' and offers 'Set Up Billing' to Pro users who don't need it - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `settings-account-copy-assumes-google` - -**Where** - -- src/locales/en.json:456 (accountDesc: 'Signed in with Google. API keys are managed securely on the server.') -- src/pages/SettingsPage.tsx:297 (shown unconditionally) -- src/pages/SettingsPage.tsx:175-177 (Pro without Stripe gets the 'Set Up Billing' primary button) - -**What the reviewer saw** - -17-settings.png: the admin seed account (not a Google sign-in) and the expired account both show 'Signed in with Google. API keys are managed securely on the server.' (18-expired.json settingsSub). Demo-code and email-code users get the same false statement, plus jargon about API keys. A Pro account with no Stripe subscription (promo or demo-code lifetime Pro) sees a prominent blue 'Set Up Billing' button, inviting a student to pay for access they already have. - -**Why it happens** - -Static copy from when Google was the only sign-in method, and an upgrade CTA keyed only on tier. - -**Suggested fix** - -Read user.app_metadata.provider (google, email, or the demo marker) and say 'Signed in with Google/email/demo code'. Drop the API-key sentence. For Pro without Stripe billing, show 'Pro (no payment needed)' and no billing button. - -**Who it hits** - -Every signed-in user who opens Settings; confusing for demo-code event users in particular. - -**Independent check** - -Admin (email seed account, Pro without Stripe) /app/settings shows 'Signed in with Google...' and a 'Set Up Billing' button. en.json:456, SettingsPage.tsx:175-177,297. - -**Evidence files** - -[17-settings.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/17-settings.png), [18-expired-settings.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/18-expired-settings.png), [s2-empty.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s2-empty.json), [s2-settings-admin.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s2-settings-admin.png) - - -### journeys-23: Library toggles and adds wait on 2 to 3 sequential network round trips before anything changes on screen - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `getuser-network-fallback-local-only-writes` - -**Where** - -- src/lib/stores/session-store.ts:775-789 (toggleSource: getUser request, then PATCH, then set) -- src/lib/stores/session-store.ts:499-534 (addSource: getUser, folder upsert, insert) -- src/lib/stores/session-store.ts:288-291 (getUserId hits /auth/v1/user every time) - -**What the reviewer saw** - -In-page pointerdown-to-aria-checked timing for the source toggle: median about 80 ms on the VPS's fast link (72-105 ms, 10 samples, load avg 1.7; 05-library2.json). Under fast-4G emulation it was about 350 ms (339-379, 4 samples, load avg 4; 19-slow-toggle.json). The request log shows GET /auth/v1/user then PATCH /rest/v1/sources for every toggle, and GET user, POST source_folders, POST sources for every add (04-library.json reqs). On phone networks the switch visibly lags behind the finger. - -**Why it happens** - -Writes are pessimistic, and each one re-fetches the user over the network. - -**Suggested fix** - -Take the user id from the cached session (this also fixes journeys-1). Make toggleSource and removeSource optimistic, rolling back with an error toast on failure. Skip the source_folders upsert when the folder is already known locally. - -**Who it hits** - -Everyone using the Library on mobile networks; the lag is small but felt on every toggle. - -**Independent check** - -Every toggle issues GET /auth/v1/user then PATCH /rest/v1/sources before the switch changes. pointer-to-aria-checked: 88-125ms (median ~92) unthrottled, 333-347ms (median ~340) on emulated fast-4G (4 samples each, load avg 12.6). Code session-store.ts:775-789 (pessimistic, set() after the write), 288-291 getUserId hits the network every call. Same fix as journeys-1 (cached session user id) removes one round trip; optimistic update is the second part. - -**Evidence files** - -[05-library2.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/05-library2.json), [19-slow-toggle.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/19-slow-toggle.json), [04-library.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/04-library.json), [s8-toggle.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s8-toggle.json) - - -### journeys-24: Mobile Library rows cut titles to about 12 characters and the stat cards push the list below the fold - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `library-row-desktop-layout-on-phones` - -**Where** - -- src/pages/LibraryPage.tsx:72-77 (fixed h-[84px] row) and 81-93 (title truncate beside the badge) -- src/pages/LibraryPage.tsx:584-597 (three stat cards stack on mobile) - -**What the reviewer saw** - -On Pixel 7 (21-m-library-row.png) titles read 'Should Homewo', 'Homework Statis', 'ZZ review Home'' and 'ZZ review pas…': identical-looking rows cannot be told apart, including the duplicates from journeys-14. Three stacked stat cards ('Total sources', 'Active sources', 'Synced folders') plus the toolbar push the first source to roughly 800 CSS px down (23-m-library-after-add.png). - -**Why it happens** - -A desktop-first single-line row with a fixed height, and stats laid out as full-width cards on small screens. - -**Suggested fix** - -On mobile, let the title wrap to two lines (line-clamp-2) with the type badge on the meta line, and drop the fixed height. Collapse the stats into one compact line ('12 sources · 11 active') or hide them under sm. - -**Who it hits** - -Mobile Library users, the majority of students. - -**Independent check** - -Pixel 7 (412px), pro library: title spans are 114px visible vs 258-376px content ('The hidden cost', 'Air quality after t', 'Car-free cores: v'), hard-cut beside the URL badge; first row starts at 931px with an 839px viewport (stat cards + toolbar above). Code LibraryPage.tsx:72-93 fixed h-[84px] single-line row, 584-597 stacked stat cards. - -**Evidence files** - -[21-m-library-row.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/21-m-library-row.png), [21-m-library-full.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/21-m-library-full.png), [23-m-library-after-add.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/23-m-library-after-add.png), [s7-mobile.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s7-mobile.json), [s7-pro-library-mobile.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s7-pro-library-mobile.png), [s7-pro-library-mobile-rows.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s7-pro-library-mobile-rows.png) - - -### ai-surfaces-15: Web-search Ask warns 'Input was shortened' when only the server's research context was trimmed - -**Severity:** low · **Verdict:** partly · **Root cause:** `ask-research-trim-reported-as-input-truncation` - -**Where** - -- supabase/functions/ai/features/ask.ts:81-90 (markTruncated when web-search or URL context exceeds its budget) -- supabase/functions/ai/index.ts:223, 266 (meta.truncated) -- src/lib/ai/client.ts:644-649 (toast aiClient.inputShortened) - -**What the reviewer saw** - -Real run, Citations + Web search, with a one-sentence question. It waited 80.6 s with only typing dots and then showed the toast 'Input was shortened — The request was too large, so only the portion that fit was sent to the AI.' The trimming happens on research text the server fetched itself, not on anything the student wrote. - -**Why it happens** - -One `truncated` flag covers both user-input truncation and server research trimming. - -**Suggested fix** - -Only set meta.truncated for user input (sanitizeInput). Report research trimming as a separate field, or not at all. - -**Who it hits** - -Students using web search think their question was cut off. - -**Independent check** - -Code confirms the mislabel: ask.ts:81-90 calls ctx.markTruncated() when the server's own web-search or URL context exceeds MAX_ASK_CONTEXT_LENGTH (32,000) minus the base prompt, index.ts:223/266 turns that into meta.truncated, and client.ts:644-649 shows 'Input was shortened ... only the portion that fit was sent'. Not reproduced in my one real run (Ask + Web search, 1 AI request): HTTP 200, meta had no truncated flag, no toast. So it only fires when research is large (up to 4 pages x 4,000 chars plus ranked results); frequency unknown. - -**Evidence files** - -[ask-web-desktop-DeepSeekV4Flash.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/ask-web-desktop-DeepSeekV4Flash.json), [ask-web-desktop-DeepSeekV4Flash-final-full.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/ask-web-desktop-DeepSeekV4Flash-final-full.png), [websearch-real.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/websearch-real.json) - - -### ai-surfaces-16: Whitespace-only input sends a paid AI request (Attack, Factoids); Generate/Brainstorm offer to create a debate for a blank motion - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `blank-input-not-trimmed` - -**Where** - -- src/lib/ai/schemas/attack.ts:5 and factoids.ts:6 (min(1) without trim) -- src/lib/ai/schemas/generate.ts:5 and brainstorm.ts:5 -- supabase/functions/ai/features/attack.ts:19 (no server check for empty caseText) - -**What the reviewer saw** - -Simulated: the request was intercepted in the browser and never reached staging. Filling the field with 4 spaces and submitting: -- **Attack and Factoids:** an AI request was sent (requestSent true). -- **Generate and Brainstorm:** the motion gate opened 'This is a different motion' and offered 'Create a new debate for this motion' for the blank motion. - -Empty input is blocked correctly ('Case text is required'). - -**Why it happens** - -The zod schemas don't trim before min(1), and the server doesn't reject blank required fields. - -**Suggested fix** - -Use z.string().trim().min(1, t(...)) for motion, caseText and claim. Have the server return 400 for blank required fields before deep research. - -**Who it hits** - -Occasional wasted quota and a confusing dialog. - -**Independent check** - -Code: schemas use z.string().min(1) without trim (attack.ts:5, factoids.ts:6, generate.ts:5, brainstorm.ts:5); the server attack handler starts deep research on whatever caseText it gets (features/attack.ts:19-25). Reproduced (requests aborted in the browser): four spaces in Attack and in Factoids sent POST /functions/v1/ai with caseText/claim ' '; in Generate and Brainstorm four spaces opened 'This is a different motion ... Create a new debate for this motion' for the blank motion. - -**Evidence files** - -[sim-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/sim-desktop.json), [sim-generate-whitespace-desktop.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/sim-generate-whitespace-desktop.png), [sim-attack-whitespace-desktop.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/sim-attack-whitespace-desktop.png), [misc-tools.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/misc-tools.json), [ws-attack.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/raw/ws-attack.png), [ws-generate.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/raw/ws-generate.png) - - -### ai-surfaces-17: An oversized Ask message is rejected and the typed text is wiped - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `ask-optimistic-input-clear` · **Same fix as:** integrity-money-11 - -**Where** - -- src/components/features/ask/AskChat.tsx:402-409 (clears the input and files before onSend validates) -- src/pages/AskPage.tsx:150-158 (size check returns after the input was cleared) - -**What the reviewer saw** - -Free test: the size check ran in the browser and no request was sent. A 60,000-character message produced the toast 'Message is too large — …shorten your question before sending.', and the input value length was then 0. The text the student was told to shorten is gone, and attachments are cleared too. - -**Why it happens** - -Optimistic clearing in handleSubmit, while validation happens later in AskPage. - -**Suggested fix** - -Validate size in AskChat before clearing (export isAskMessageWithinBudget), or have onSend return a result and clear only on acceptance. - -**Who it hits** - -A student pasting a long text loses it. - -**Independent check** - -Code: AskChat.tsx:402-409 clears input and files right after onSend; the size check runs later in AskPage.tsx:145-151. Reproduced: filled 60,000 chars and pressed Enter; toast 'Message is too large' appeared, input length after = 0, no stream call. Same cause and fix as integrity-money-11 (which rated it medium; low is fair for this oversize-only slice). - -**Evidence files** - -[ask-sim.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/ask-sim.json), [ask-sim-oversize.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/ask-sim-oversize.png), [ask-sim-desktop-all.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/ask-sim-desktop-all.json), [ask-desktop-oversize.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/raw/ask-desktop-oversize.png) - - -### ai-surfaces-18: Ask's 'Save' button shows during streaming, then vanishes; clicking it saves a second, unsorted copy without the answer - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `ask-manual-save-duplicates-auto-record` - -**Where** - -- src/components/features/ask/AskChat.tsx:536-541 (Save shown while onSaveChat is set and messages exist) -- src/pages/AskPage.tsx:395 (onSaveChat cleared once askChatId is set) -- src/lib/stores/session-store.ts:955-983 (saveCurrentChat inserts a chat with no debate_id and doesn't set askChatId) - -**What the reviewer saw** - -Simulated via route fakes. Clicking Save 1 s into a streamed reply wrote: -- chats (debate_id null) plus messages [user] only; -- then auto-record wrote chats (debate 5eed0001…) plus messages [user, assistant]. - -Afterwards the Save button was gone (visibleDuring true, visibleAfter false), so the header changes layout mid-conversation. - -**Why it happens** - -There are two save paths (manual saveCurrentChat and the automatic syncAskChat) that don't know about each other. Save visibility depends on whether the async auto-record has completed. - -**Suggested fix** - -Remove the manual Save in debate scope, since auto-recording already covers it. Otherwise make it call syncAskChat and disable it while streaming. - -**Who it hits** - -Duplicate, partial chats in History, and a header that jumps. - -**Independent check** - -Code: AskChat.tsx:536-541 shows Save whenever onSaveChat is set and messages exist; AskPage passes onSaveChat only until askChatId is set (AskPage.tsx:395); saveCurrentChat inserts a chat without debate_id and never sets askChatId (session-store.ts:955-983). Reproduced with fake SSE and faked writes: Save was visible mid-stream and gone after the answer (header layout changes). Clicking Save 1 s into the stream wrote POST chats x1 + POST messages x1 (user only), then auto-record wrote POST chats x1 + POST messages x2: two chats for one exchange. - -**Evidence files** - -[ask-save-sim.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/ask-save-sim.json), [ask-save-during-stream.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/ask-save-during-stream.png), [ask-sim-desktop-all.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/ask-sim-desktop-all.json) - - -### ai-surfaces-19: Streaming bubble polish: the finished answer blinks and re-enters; the caret sits on its own line - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `ask-stream-bubble-remount` - -**Where** - -- src/pages/AskPage.tsx:169-176 and 246-256 (streaming message id stream_N replaced by a new message id) -- src/components/features/ask/AskChat.tsx:88 (message-enter on every bubble mount) and :114-123 (caret after the Markdown block) -- src/index.css:676-689 - -**What the reviewer saw** - -Real run. When the stream ended, the final bubble (865 px tall) restarted at opacity 0 with transform translateY(12px) scale(0.97) and faded back in over about 300 ms: the whole answer flashes once. - -Caret mid-stream: top 319 px against the last block's bottom at 310 px (tag UL), so the caret renders on a new line under the text rather than at its end. The layout-shift entries during streaming come from the caret jumping between lines. - -**Why it happens** - -Completing a stream replaces the bubble with a new React key, which replays the entry animation. The caret is a sibling of the block-level markdown. - -**Suggested fix** - -Give the final message the same id as the streaming message (or skip message-enter for streamed messages). Render the caret inside the last markdown block, or use a CSS ::after on the last child. - -**Who it hits** - -Every streamed Ask answer: a visible flicker at the end, and a caret that looks detached. - -**Independent check** - -Code: the streaming bubble has id stream_N (AskPage.tsx:169-176) and the final message gets a new generateId() (session-store.ts:941-946), so MessageBubble remounts and replays message-enter (AskChat.tsx:88, index.css:676-689). The caret span sits after the ReactMarkdown block wrapper (AskChat.tsx:114-123), so it wraps to its own line. Reproduced with fake SSE: when the stream ended the final bubble was at opacity 0, matrix(0.97,0,0,0.97,0,12) and faded back over about 200 ms; mid-stream the caret top was 411 px against the last block (UL) bottom at 401 px. - -**Evidence files** - -[ask-long-desktop-default.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/ask-long-desktop-default.json), [ask-stream-long-desktop.webm](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/ask-stream-long-desktop.webm), [ask-sim-desktop-all.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/ask-sim-desktop-all.json), [ask-desktop-midstream.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/raw/ask-desktop-midstream.png), [ask-desktop-after-stream.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/raw/ask-desktop-after-stream.png) - - -### ai-surfaces-20: Crossfire: composer collapses while the AI thinks, the clock runs during AI latency, and the empty-transcript copy is stale - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `crossfire-composer-unmounts-while-pending` - -**Where** - -- src/pages/CrossfirePage.tsx:961-970 (pending status replaces the composer card) -- src/pages/CrossfirePage.tsx:424-428 (timer keeps ticking while request.status is pending) -- src/pages/CrossfirePage.tsx transcript empty state (setup copy shown after the round started) - -**What the reviewer saw** - -Real round: -- Submitting a question shrank the page by 201 px (trackMovement: container resize -201.3), because the composer card is swapped for the 'The coach is thinking…' line and comes back after the answer. -- The 3:00 timer dropped to 2:50 while the AI answered and kept running during the (simulated) failed turn, so AI latency and errors eat the student's crossfire time. -- The empty transcript of an active round read 'Set a resolution and an argument to start a focused cross-examination.' - -**Why it happens** - -The pending state is rendered instead of the composer, not inside it. The round clock is wall time with no pause for AI or error states. - -**Suggested fix** - -Keep the composer mounted and disable it, with the thinking line above it. Pause or exclude AI-pending and error time from the round clock, as Sparring does for voice. Show 'Ask your first question' copy once the round is active. - -**Who it hits** - -Each turn jumps the layout; slow models or errors shorten the student's practice round. - -**Independent check** - -Code: expectedUserTurnKind returns null unless request.status is idle (crossfire/reducer.ts:216-221), so the composer Card unmounts while pending or failed (CrossfirePage.tsx:961-1000); endsAt is fixed at start (reducer.ts:101) and the clock ticks regardless (CrossfirePage.tsx:421-425); the empty transcript always shows crossfire.emptyDesc. Reproduced with a faked 4 s crossfire reply: the page container shrank 201.3 px on submit, the input was gone while pending (count 0) and came back after; timer 3:00 -> 2:58 at submit -> 2:55 after the answer; the active round's empty transcript read 'Set a resolution and an argument to start a focused cross-examination.' The clock running during AI latency is arguably a design choice, but it does eat student time. - -**Evidence files** - -[crossfire.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/crossfire.json), [crossfire-strip.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/crossfire-strip.png), [crossfire-sim.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/crossfire-sim.json), [crossfire-active-empty.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/raw/crossfire-active-empty.png), [crossfire-pending.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/raw/crossfire-pending.png) - - -### a11y-i18n-11: Global focus ring and input focus border are 1.89:1 in light mode - -**Severity:** low (reviewer said medium) · **Verdict:** confirmed · **Root cause:** `light-primary-token-too-light` - -**Where** - -- src/index.css:524-529 (:focus-visible box-shadow hsl(var(--primary)/0.5)) -- src/index.css:620-623 (input/textarea/select:focus border primary/0.5 !important, glow 0.1) - -**What the reviewer saw** - -Measured on staging (footer 'Terms' link, which has no ring classes of its own): box-shadow 0 0 0 2px rgb(241,245,249), 0 0 0 4px rgba(2,132,197,0.5). Blended ring rgb(122,188,223) against the #f1f5f9 background = 1.89:1. Input focus border rgba(2,132,197,0.5) is also 1.89:1, and its 3px glow at 10% is nearly invisible. Dark mode ring = 3.01:1 (borderline pass). Elements with their own solid ring-ring classes (header controls) are fine. - -**Why it happens** - -The focus ring uses a 50%-alpha primary for a soft glow look. - -**Suggested fix** - -Use solid hsl(var(--ring)) for the 2px ring (keep the glow as an extra layer), and a solid --ring border plus a 2px ring for focused inputs. With the darker primary from a11y-i18n-2 this reaches more than 3:1. - -**Who it hits** - -Keyboard users in light mode struggle to see where focus is on links, cards and text fields (WCAG 1.4.11). - -**Independent check** - -Staging computed (trial /app footer 'Terms', light, after transition): box-shadow 0 0 0 2px rgb(241,245,249), 0 0 0 4px rgba(2,132,197,0.5) -> blended ring vs background 1.89:1 (dark 3.02:1). Focused Generate input: border rgba(2,132,197,0.5), glow 10%. src/index.css:524-529 and :620-623. Downgraded to low: many controls carry their own focus-visible:ring-ring classes; the half-alpha global ring is the fallback. Fix: solid hsl(var(--ring)) for the ring plus the darker primary from a11y-i18n-2; input focus also overlaps global-important-input-focus-style. - -**Evidence files** - -[global-focus-ring-light.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/misc/global-focus-ring-light.png), [global-focus-ring-dark.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/misc/global-focus-ring-dark.png), [focus-input-light.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/misc/focus-input-light.png), [s2-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s2-light.json), [s2-focusring-terms.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s2-focusring-terms.png) - - -### a11y-i18n-12: Flowchart node colours fail contrast in both themes (white on emerald 2.53:1, chips around 2:1) - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `flowchart-500-fills-white-text` - -**Where** - -- src/pages/FlowchartPage.tsx nodeColors and CustomNode (bg-emerald-500/red-500/blue-500 with text-white, opacity-80 descriptions, strength chips) -- /app/flowchart - -**What the reviewer saw** - -axe color-contrast on /app/flowchart: 25 nodes in light and 18 in dark. Examples: #fff on #10b981 2.53:1 (node titles), #cff1e6 on #10b981 2.09:1 (descriptions, opacity-80 at 12px), #fff on #ef4444 3.76:1, #fff on #3b82f6 3.67:1, chips #f8fafc on #40c79a 2.03:1, legend counters (text-emerald-600 on #dae0e8 2.83:1, text-amber on dark 3.39:1). Node descriptions are 9-12px. - -**Why it happens** - -Tailwind 500-level fills are used with white text and an opacity-reduced description. - -**Suggested fix** - -Use the 700-level fills (emerald-700, red-700, blue-700) or dark text on 100-level fills. Drop opacity-80 on descriptions and raise them to at least 12px. - -**Who it hits** - -Node labels are hard to read for low-vision users and on classroom projectors. - -**Independent check** - -axe on staging /app/flowchart (trial, light): 19 color-contrast nodes, e.g. legend counters text-emerald-600 on #dae0e8 2.83:1, amber 2.39:1, red-600 3.63:1, blue-600 3.89:1; white on emerald-500 computes to 2.54:1, red-500 3.76:1. FlowchartPage.tsx:94-99 nodeColors, :178 opacity-80 description. - -**Evidence files** - -[summary.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/axe/summary.txt), [trial-desktop-dark_app_flowchart.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/raw/axe/trial-desktop-dark_app_flowchart.png), [s2-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s2-light.json) - - -### a11y-i18n-13: Focus is not returned to the opener after controlled dialogs and sheets close - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `controlled-dialog-no-focus-return` · **Known in plan:** next-15 #14 - -**Where** - -- src/components/features/debate/DebateContextControl.tsx:108-109 (Review sheet, controlled, no trigger) -- src/components/features/debate/DebateDialogs.tsx (Switch/Create debate dialogs opened from the Current debate popover) -- src/pages/LibraryPage.tsx (source viewer dialog opened from a row) - -**What the reviewer saw** - -Keyboard tests on staging (desktop and mobile). Generate → Review → Escape: focus BODY. Current debate → Switch debate → Escape: focus BODY. Library row Enter → viewer → Escape: focus BODY. Uncontrolled ones work: the Language dialog, Current debate popover and Flowchart Load dialog return focus to their trigger, and the mobile menu returns to 'Open menu'. - -**Why it happens** - -Dialogs opened through store state have no Radix Trigger, so Radix has nothing to restore focus to. - -**Suggested fix** - -Store the opener element (document.activeElement) when opening, and focus it in onCloseAutoFocus (event.preventDefault(); ref.focus()). For the popover → dialog hand-off, focus the Current debate trigger. - -**Who it hits** - -Keyboard and screen-reader users are thrown to the top of the page after every Review, switch or source view. - -**Independent check** - -Reproduced (trial, desktop): Generate -> Review (Enter) -> Escape: activeElement BODY. Current debate popover -> Switch debate (Enter) -> Escape: BODY. Library row viewer (opened by keyboard) -> Escape: BODY. DebateContextControl.tsx:105-108 opens a controlled Sheet with no Trigger; no onCloseAutoFocus anywhere in src. - -**Evidence files** - -[dialogs-desktop.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/keyboard/dialogs-desktop.txt), [kb-switch-dialog-mobile.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/keyboard/kb-switch-dialog-mobile.png), [s2-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s2-light.json), [s3.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s3.json) - - -### a11y-i18n-14: No skip link: 8 Tab stops before page content on every app route - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `no-skip-link` · **Known in plan:** next-15 #14 - -**Where** - -- src/components/layout/AppLayout.tsx:176-291 (no skip link before the header; <main> has no id) - -**What the reviewer saw** - -The keyboard walk on 9 routes starts logo → Home → Current debate → Library → History → Language → Theme → Settings before reaching content (8 stops). meta.skipLink was false on all 84 scanned pages. - -**Why it happens** - -Not implemented. - -**Suggested fix** - -Add a visually-hidden-until-focus 'Skip to content' link (translated) as the first child of AppLayout and the landing layout, targeting <main id='main' tabIndex={-1}>. - -**Who it hits** - -Keyboard users repeat 8 tabs on every page. - -**Independent check** - -Reproduced: Tab from the top of /app/generate visits DebateLab logo, Home, Current debate, Library, History, Language, Toggle theme, Settings (8 stops) before the page's first control; no skip link in the DOM. AppLayout.tsx:176-291, <main> at :291 has no id. - -**Evidence files** - -[walk-trial-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/keyboard/walk-trial-desktop.json), [s3.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s3.json) - - -### a11y-i18n-15: Placeholder text is the same colour as typed text, so empty fields look filled - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `theme-tokens-accent-muted-misassigned` - -**Where** - -- src/index.css:30 (light --muted-foreground 222 47% 11% equals --foreground) and :99 (dark --muted-foreground equals foreground) -- All inputs/textareas using placeholder:text-muted-foreground (login email, Attack textarea, Library fields) - -**What the reviewer saw** - -Computed on staging /app/generate: ::placeholder color rgb(15,23,41) equals the input text color rgb(15,23,41). In screenshots the login placeholder 'te@pelda.hu' and the Attack textarea placeholder look like entered values. - -**Why it happens** - ---muted-foreground was set equal to --foreground (likely to pass contrast), which removes all secondary-text hierarchy. - -**Suggested fix** - -Restore a distinct --muted-foreground of about 4.6:1 (e.g. light 215 19% 35%, dark 215 20% 70%), or at least give placeholders their own colour. - -**Who it hits** - -Students may skip a field that looks prefilled, or think an example motion is real input. - -**Independent check** - -Staging computed: /login email ::placeholder rgb(15,23,41) equals the input text colour rgb(15,23,41); same on the focused Generate input. src/index.css:30 light --muted-foreground 222 47% 11% = --foreground, dark :99 same. Screenshot: 'you@example.com' reads like a typed value. - -**Evidence files** - -[anon-desktop-hu_login.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/raw/i18n/anon-desktop-hu_login.png), [trial-laptop-hu_app_attack.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/raw/i18n/trial-laptop-hu_app_attack.png), [s1-signed-out.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s1-signed-out.json), [s1-login-light.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s1-login-light.png), [s2-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s2-light.json) - - -### a11y-i18n-16: Horizontal overflow at 320px: Current debate side cards (+69px) and Settings 'Upgrade to Pro' clipped - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `grid-items-min-width-auto-blowout` - -**Where** - -- src/pages/CurrentDebatePage.tsx:156,161 (grid gap-6 md:grid-cols-2 with no grid-cols-1/min-w-0; ItemRow truncate cannot shrink) -- src/pages/SettingsPage.tsx:204 (flex justify-between without wrap) -- /app/debate, /app/settings at 320px width - -**What the reviewer saw** - -At 320x640 (WCAG reflow width), /app/debate documentElement.scrollWidth = 389. The In favor, Against and Sources cards run off the right edge, with the '2 items' badge and 'Library' button clipped. /app/settings overflows by 14px and the 'Upgrade to Pro' button reads 'Upgrade to Pr'. Other routes checked (/app, generate, library, timer, flowchart, crossfire, sparring) do not overflow. - -**Why it happens** - -An implicit auto grid track grows to the min-content width of non-shrinking text, and the subscription row does not wrap. - -**Suggested fix** - -Use grid-cols-1 md:grid-cols-2 (minmax(0,1fr)) or add min-w-0 to the Cards. Add flex-wrap gap-3 to SettingsPage.tsx:204. - -**Who it hits** - -Users at 400% zoom or on very small phones get sideways scrolling and clipped controls. - -**Independent check** - -Staging at 320x640 (trial): /app/debate documentElement.scrollWidth 387 (cards In favor/Against/Sources/Recent run off the right edge), /app/settings 334. CurrentDebatePage.tsx:156,161 grid without grid-cols-1/min-w-0; SettingsPage.tsx:204 flex justify-between without wrap. 320px only (WCAG reflow); common 360+ phones were not measured here. - -**Evidence files** - -[reflow-320-debate-side-card.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/misc/reflow-320-debate-side-card.png), [reflow-320_app_settings.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/misc/reflow-320_app_settings.png), [s2-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s2-light.json), [s2-320-app-debate.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s2-320-app-debate.png), [s2-320-app-settings.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s2-320-app-settings.png) - - -### a11y-i18n-17: Heading and landmark structure: two h1s per app page, h1 skips to h3, no page h1 on Flowchart, no <main> on login/404/share - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `heading-landmark-structure` - -**Where** - -- src/components/layout/AppLayout.tsx:185 (h1 'DebateLab' in the header on every app page) -- src/components/ui/card.tsx:17-19 (CardTitle renders h3) -- src/pages/FlowchartPage.tsx (no h1) -- src/pages/LoginPage.tsx:149, src/pages/NotFound.tsx:7, src/pages/SharedPage.tsx:101-110 (no <main>; share error has no h1) - -**What the reviewer saw** - -Every app route has h1 'DebateLab' plus the page h1; /app has 'DebateLab' twice. axe heading-order (moderate) on all 14 app routes and '/' (CardTitle h3 directly under h1). /app/flowchart has only the header h1. axe landmark-one-main and region on /login, /zz-review-404 and /share/<bad>, plus page-has-heading-one on the share error state. AUD-001 fixed only the landing page. - -**Why it happens** - -The brand text is marked up as h1, and the card titles use a fixed h3. - -**Suggested fix** - -Change the header brand to a <span> or <p>. Make CardTitle accept an `as` prop (default h2) or render page section titles as h2. Give Flowchart an h1. Wrap Login, NotFound and the share states in <main>, and use an h1 in the share error card. - -**Who it hits** - -Screen-reader heading navigation is noisy and starts at the brand on every page. - -**Independent check** - -Staging: every app route has h1 'DebateLab' (AppLayout.tsx:185) plus the page h1; /app has two 'DebateLab' h1s; /app/flowchart has only the header h1 (no page h1 in FlowchartPage.tsx). axe heading-order on all 12 app routes scanned and on '/' (CardTitle renders h3, card.tsx:17-19). /login, /zz-review-404 and /share/<bad>: landmark-one-main + region; share error state page-has-heading-one (its only heading is h3 'Shared Content'). /login does have an h1 (AUD-003 fixed), so only the <main> part applies there. - -**Evidence files** - -[summary.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/axe/summary.txt), [s1-signed-out.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s1-signed-out.json), [s2-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s2-light.json) - - -### a11y-i18n-18: Client-side navigation gives no feedback to screen readers (focus stays on the link, title unchanged, nothing announced) - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `no-route-change-focus-announcement` - -**Where** - -- src/App.tsx:189-198 (RouteContent: no focus or announcement on pathname change) -- src/components/loading/RouteSkeleton.tsx:208 (announces only while a lazy chunk loads) - -**What the reviewer saw** - -On staging, focus the 'Library' nav link on /app and press Enter. The URL becomes /app/library, focus stays on the 'Library' link, document.title stays 'DebateLab', and a MutationObserver on all aria-live/status regions recorded 0 updates. The RouteSkeleton live text only appears the first time a chunk loads. - -**Why it happens** - -No route-change focus management. - -**Suggested fix** - -On pathname change in RouteContent, move focus to the page h1 (tabIndex=-1) or <main>, and/or announce the translated page name in a persistent sr-only polite region. Pair with the per-route title in a11y-i18n-8. - -**Who it hits** - -Screen-reader users cannot tell that a page changed. - -**Independent check** - -Reproduced: focus 'Library' nav link on /app, Enter -> URL /app/library, activeElement still 'A Library', title 'DebateLab', 0 mutations on aria-live/status/alert regions. App.tsx:185-198 RouteContent has no focus/announce logic. - -**Evidence files** - -[dialogs-desktop.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/keyboard/dialogs-desktop.txt), [s2-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s2-light.json) - - -### a11y-i18n-19: Brainstorm result cards show the raw enum 'strong / moderate / weak' in every language - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `brainstorm-strength-enum-untranslated` - -**Where** - -- src/components/features/brainstorm/BrainstormResults.tsx:50-53 ({strength} rendered directly) - -**What the reviewer saw** - -In the live Hungarian Brainstorm result (UI hu, output hu), each idea card shows a translated side badge ('MELLETTE', 'ELLENE', 'SEMLEGES') next to an English 'strong' or 'moderate' badge. Translations already exist for strength levels elsewhere (sourceQuality.levels.*). - -**Why it happens** - -An enum value is rendered without t(). - -**Suggested fix** - -Render t(`brainstormResults.strength.${strength}`) and add en/es/hu keys (Erős, Mérsékelt, Gyenge / Fuerte, Moderada, Débil). - -**Who it hits** - -Every es/hu Brainstorm result shows English words in its top-5 cards. - -**Independent check** - -Code: BrainstormResults.tsx:39,51-53 renders idea.strength raw; prompt schema fixes it to English enum strong|moderate|weak (supabase/functions/_shared/prompts/brainstorm.ts:32), so it is English in every UI language. Not reproduced live to save AI budget; the code path is deterministic. - -**Evidence files** - -[ai-hu-result.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/i18n/ai-hu-result.png) - - -### a11y-i18n-20: Hungarian and Spanish catalogs copy English Title Case (194 hu, 203 es strings) - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `hu-es-catalog-title-case` - -**Where** - -- src/locales/hu.json (e.g. nav.howItWorks 'Hogyan Működik', faq.title 'Gyakran Ismételt Kérdések', historyPage 'Mentett Esetek', attack.emptyTitle 'Kegyetlen Mód', libraryPage.addSource 'Forrás Hozzáadása', 'Összes Törlése') -- src/locales/es.json (e.g. 'Preguntas Frecuentes', 'Cómo Funciona', 'Tu Caso Te Espera') - -**What the reviewer saw** - -A catalog scan counted multi-word strings in which every word of 3+ letters is capitalized: 194 in hu and 203 in es, including landing CTAs and headings, tool titles and toasts. Both languages use sentence case, so these read as machine translations. They are visible in the hu screenshots (Attack form, Library, History). - -**Why it happens** - -Translations followed the English capitalization. - -**Suggested fix** - -Convert hu and es values to sentence case (proper nouns excepted) in one catalog pass. A small lint in the i18n catalog check could flag 2+ capitalized words. - -**Who it hits** - -Every hu/es user, including on the landing page, sees non-native capitalization. - -**Independent check** - -Own scan of src/locales: 211 hu and 228 es short strings where every 3+ letter word is capitalised (a few false positives like 'Google Docs'). Examples: hu 'Hogyan Működik' (hu.json:19), 'Generálás Sikertelen', 'Kegyetlen Mód'; es 'Cómo Funciona' (es.json:19), 'Tu Caso Te Espera'. - -**Evidence files** - -[trial-laptop-hu_app_attack.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/raw/i18n/trial-laptop-hu_app_attack.png), [hu-import-error-raw-english.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/i18n/hu-import-error-raw-english.png) - - -### a11y-i18n-21: 404 page: grey slate-400 slab, 1.6:1 link in light mode, full-reload link to the marketing home - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `theme-tokens-accent-muted-misassigned` - -**Where** - -- src/pages/NotFound.tsx:7-13 (bg-muted, text-primary link, <a href='/'>) -- src/index.css:29 (light --muted 215 20% 65% = #94a3b8) - -**What the reviewer saw** - -In light mode the whole page is #94a3b8. axe measures the 'Return to Home' link #0284c5 on #94a3b8 at 1.6:1, and in dark #35bdf8 on #637288 at 2.27:1. The link is a plain <a href='/'> (full reload) and sends signed-in users to the landing page instead of /app. There is no <main> and the h1 is just '404'. - -**Why it happens** - -The page uses the --muted token, which in this theme is a mid-grey, as a page background. - -**Suggested fix** - -Use bg-background, a <main>, an h1 carrying the message, and a router <Link> to /app when signed in (/ otherwise), styled as a button. - -**Who it hits** - -Anyone following a stale link sees an unpolished, hard-to-read page. - -**Independent check** - -Staging /zz-review-404 light: page background rgb(148,163,184) (bg-muted = 215 20% 65%), 'Return to Home' #0284c5 on #94a3b8 = 1.6:1 (axe), plain <a href='/'> (full reload to the landing page even when signed in), no <main>. NotFound.tsx:7-13. - -**Evidence files** - -[anon-mobile-light_zz-review-404.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/raw/axe/anon-mobile-light_zz-review-404.png), [s1-signed-out.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s1-signed-out.json), [s1-404-light.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s1-404-light.png) - - -### a11y-i18n-22: History tabs are clipped mid-word on phones with no scroll affordance (worse in hu/es) - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `history-tabs-overflow-no-affordance` - -**Where** - -- src/pages/HistoryPage.tsx:137 (TabsList w-full justify-start overflow-x-auto) - -**What the reviewer saw** - -Pixel 7 English: tabs read 'Cases, Chats, Attacks, Brainsto…' cut at the edge. iPhone SE Hungarian: 'Esetek, Chatek, Támadá…', with Ötletelések, Tények and Vitapárbaj up to 373px off-screen. Spanish on Pixel 7: 'Lluvias de Ideas', 'Datos' and 'Sparring' off-screen. There is no fade, arrow or wrap to show more tabs exist. - -**Why it happens** - -A horizontally scrolling tab list with no affordance and no responsive layout. - -**Suggested fix** - -Wrap the tabs (flex-wrap h-auto), switch to a Select below sm, or add edge fade masks and scroll the active tab into view. - -**Who it hits** - -Mobile users may never find the Brainstorm, Facts or Sparring history. - -**Independent check** - -Staging English: Pixel 7 tablist scrollWidth 635 vs clientWidth 380, iPhone SE 635 vs 288; 'Brainsto' cut at the edge, Factoids and Sparring off-screen, no fade/arrow. HistoryPage.tsx:137. Hungarian/Spanish not re-measured (they are longer, so worse). - -**Evidence files** - -[trial-small-mobile-hu_app_history.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/raw/i18n/trial-small-mobile-hu_app_history.png), [trial-mobile-light_app_history.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/raw/axe/trial-mobile-light_app_history.png), [s3.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s3.json), [s3-history-tabs-mobile.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s3-history-tabs-mobile.png), [s3-history-tabs-small-mobile.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s3-history-tabs-small-mobile.png) - - -### a11y-i18n-23: Accessible names do not contain the visible label (WCAG 2.5.3) - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `aria-label-diverges-from-visible-text` - -**Where** - -- src/pages/CurrentDebatePage.tsx:53-56 (visible 'Load', aria-label 'Open {{title}}') -- src/components/features/debate/CurrentDebateMenu.tsx:47-55 (mobile trigger aria-label 'Current debate: motion' vs visible 'Current debate' + motion) -- src/pages/TimerPage.tsx:372 (reset button named 'R to reset') - -**What the reviewer saw** - -axe label-content-name-mismatch (serious): 16 nodes on /app/debate, and the mobile Current debate trigger on all 14 app routes (mobile). The Timer reset icon button reuses the keyboard hint as its name ('R to reset' / 'R az újraindításhoz'). - -**Why it happens** - -aria-label strings were written independently of the visible text. - -**Suggested fix** - -Use 'Load {{title}}' (visible word first) or drop aria-label and add sr-only context. Name the mobile trigger by its visible text only. Give the reset button its own key (timer.reset 'Reset timer'). - -**Who it hits** - -Voice-control users saying 'click Load' fail; screen-reader users hear odd names. - -**Independent check** - -axe label-content-name-mismatch (an experimental axe rule): 4 nodes on /app/debate ('Load' buttons named 'Open {{title}}', CurrentDebatePage.tsx:53-56) and the mobile Current debate trigger on Pixel 7 and iPhone SE (aria-label 'Current debate: <motion>' vs visible text, CurrentDebateMenu.tsx:47-55). Timer reset icon button is named 'R to reset' (TimerPage.tsx:372 reuses timer.resetKey, en.json:1426), confirmed in DOM. Low practical impact except the reset name. - -**Evidence files** - -[summary.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/axe/summary.txt), [s2-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s2-light.json), [s3.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s3.json) - - -### a11y-i18n-24: Other contrast failures: destructive buttons, blue-500 badges and the active timer format tab - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `tailwind-500-fills-and-destructive-token` - -**Where** - -- src/index.css:35-36 (--destructive/-foreground) -- /app/settings ('Free Trial' badge, white on #3b82f6 3.67:1) -- /app/timer active format tab (bg-blue-500) -- /app/library Clear All (text-destructive) -- /app/flowchart React Flow attribution (target-size) - -**What the reviewer saw** - -axe color-contrast. Settings destructive buttons (Delete All My Data, Delete account): light #fef1f1 on #db2424 4.44:1, dark #fef1f1 on #ef4343 3.43:1. Settings trial badge 3.67:1. Timer active tab: light #f0f9ff on #3b82f6 3.45:1, dark #082c45 on #3b82f6 3.92:1. Library 'Clear All' #db2424 on #f1f5f9 4.46:1. axe target-size flags the React Flow attribution link. - -**Why it happens** - -Tailwind 500-level fills and a slightly light destructive token. - -**Suggested fix** - -Darken the destructive token slightly (light 0 72% 45%, dark 0 70% 45%) and use blue-700 or the primary token for the badge and timer tab. - -**Who it hits** - -Low-vision users; small margin misses. - -**Independent check** - -axe on staging (trial, light): Settings 'Free Trial' badge #fff on #3b82f6 3.67:1, Settings destructive buttons #fef1f1 on #db2424 4.44:1, Timer active format tab #f0f9ff on #3b82f6 3.45:1, Library 'Clear All' #db2424 on #f1f5f9 4.46:1, React Flow attribution 52x12 target-size. Light destructive is a near miss (4.44 vs 4.5); dark computed 3.43:1. - -**Evidence files** - -[summary.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/axe/summary.txt), [s2-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s2-light.json) - - -### a11y-i18n-25: Ask chat scroll area cannot be reached by keyboard - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `ask-scroll-viewport-not-focusable` - -**Where** - -- src/components/features/ask/AskChat.tsx (Radix ScrollArea viewport) -- /app/ask - -**What the reviewer saw** - -axe scrollable-region-focusable (serious) on the Radix ScrollArea viewport (overflow: hidden scroll) on all 4 combos. The keyboard walk shows the viewport is not a Tab stop, and its content has no focusable children when the chat holds plain text. - -**Why it happens** - -The ScrollArea viewport is not focusable. - -**Suggested fix** - -Add tabIndex={0}, role='log' (or 'region') and an aria-label to the chat viewport so keyboard users can scroll long answers. - -**Who it hits** - -Keyboard-only users cannot scroll back through long coaching answers without a mouse. - -**Independent check** - -axe scrollable-region-focusable 1 on staging /app/ask (trial, light). AskChat.tsx:336 and :546 use Radix ScrollArea without a focusable viewport. - -**Evidence files** - -[summary.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/axe/summary.txt), [s2-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s2-light.json) - - -### a11y-i18n-26: No language switcher on /login, and first visits ignore the browser language - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `login-no-language-control` - -**Where** - -- src/pages/LoginPage.tsx (no LanguageSwitcher) -- src/i18n.ts:10-15 (lng 'en', no detection) -- src/lib/stores/settings-store.ts:208-209 (default language 'en') - -**What the reviewer saw** - -/login renders a bare card with no language control and no way back to the landing page (screenshot). grep finds no navigator.language or detector, so a Hungarian browser (locale hu-HU) gets English on a first visit until the user finds the globe on the landing navbar. - -**Why it happens** - -Language comes only from localStorage or the server. - -**Suggested fix** - -Add the LanguageSwitcher (and a home link) to the login card. Default a first visit to navigator.languages when it matches hu or es (product decision). - -**Who it hits** - -Hungarian and Spanish students opening a shared /login link see English with no way to switch. - -**Independent check** - -Staging /login (signed out): buttons are only Continue with Google, Email, Demo code, Send code; 0 links (no home link, no language switcher). Code: LoginPage.tsx has no LanguageSwitcher; src/i18n.ts:10-15 lng 'en', no navigator.language use anywhere in src; settings-store.ts:207 default 'en'. Browser-language default is a product decision. - -**Evidence files** - -[anon-desktop-hu_login.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/raw/i18n/anon-desktop-hu_login.png), [s1-signed-out.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s1-signed-out.json), [s1-login-light.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-C/s1-login-light.png) - - -### a11y-i18n-27: 'One-off results appear here' copy does not match behaviour: one-off results are not saved (borderline, journeys lane) - -**Severity:** low · **Verdict:** partly · **Root cause:** `one-off-copy-promises-saving` - -**Where** - -- src/locales/en.json debate.unsortedEmptyDesc ('One-off results and older unsorted work appear here.') -- src/pages/BrainstormPage.tsx:44-47 (recordResult only when debateId is set) - -**What the reviewer saw** - -After a one-off Brainstorm on trial, History → 'One-off and unsorted work' still shows 'Nothing outside your debates. One-off results and older unsorted work appear here.' The result was only kept in local state. - -**Why it happens** - -Copy promises persistence that one-off mode does not provide. - -**Suggested fix** - -Change the empty-state copy (all three locales) to say one-off results are not saved, or persist one-off results with debate_id null. - -**Who it hits** - -Students expect to find a one-off result later and cannot. - -**Independent check** - -Code: Generate/Brainstorm/Attack/Factoids record a result only when debateId is set (GeneratePage.tsx:93, BrainstormPage.tsx:45, AttackPage.tsx:45, FactoidsPage.tsx:44), so their one-off results never reach History -> 'One-off and unsorted work', while UnsortedWork.tsx:105 says 'One-off results … appear here'. Partly: sparring sessions keep debateId null in one-off mode (sparring-store.ts:336), so some one-off work can appear; 'older unsorted work' is also accurate. Not reproduced live (would cost an AI call). Copy fix in en/es/hu is enough. - -**Evidence files** - -[leftover-check-history-oneoff.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/raw/leftover-check-history-oneoff.png) - - -### a11y-i18n-29: Small i18n strays: Sonner 'Notifications alt+T', hu 'AI' vs 'MI', English decimal point, fixed-width selects truncating Hungarian - -**Severity:** low (reviewer said nit) · **Verdict:** confirmed · **Root cause:** `i18n-strays-library-defaults-and-tight-select-widths` - -**Where** - -- src/components/ui/sonner.tsx:13 (no containerAriaLabel) -- src/locales/hu.json langSwitcher.description/outputLanguage use 'AI' while 58 other strings use 'MI' (Settings says 'MI Kimeneti Nyelv', the dialog says 'AI kimenet nyelve') -- src/components/features/generate/GenerateResults.tsx:76, src/components/features/ask/AskChat.tsx:292 (toFixed gives '2.5' in hu/es) -- src/pages/LibraryPage.tsx:496 (w-[160px] folder filter shows 'Minden…'); Brainstorm side/format selects show 'Mindkét Oldal…', 'Az 5 Legjobb…' - -**What the reviewer saw** - -The toaster region aria-label is 'Notifications alt+T' with html lang=hu. The hu Language dialog screenshot shows 'AI kimenet nyelve'. The hu Library screenshot shows the folder filter truncated to 'Minden…'. The hu Brainstorm screenshot shows truncated select values. - -**Why it happens** - -Library defaults, inconsistent terminology, locale-unaware number formatting and fixed widths. - -**Suggested fix** - -Pass containerAriaLabel={t('common.notifications')}. Use 'MI' in langSwitcher.*. Format durations with formatNumber/Intl (maximumFractionDigits). Use min-w or w-auto on the selects. - -**Who it hits** - -Polish for hu/es users. - -**Independent check** - -Adam, all four symptoms are real on 40738ea and on staging. I checked three of them in a fresh harness session and the toFixed one from code only (I spent no AI). (1) Sonner: src/components/ui/sonner.tsx passes no containerAriaLabel, and sonner 2.0.8 builds the label as `${containerAriaLabel} ${hotkeyLabel}`. Signed out at / with hu and es, the label reads 'Notifications alt+T' while html lang is hu/es (signedout-i18n.json). Correction: there is no `common.notifications` key in any locale, so the fix needs a new key in all three files. (2) AI vs MI: hu.json has 58 strings with 'MI' and 5 with 'AI'. Only langSwitcher.description and langSwitcher.outputLanguage are in scope; the three workspaces.* strings belong to the hidden Workspace. hu-language-dialog.png shows the dialog's 'AI kimenet nyelve' and the hero's 'MI-alapú' on one screen. Settings uses 'MI Kimeneti Nyelv' (Title Case), so match the casing too. (3) Decimals, from code: GenerateResults.tsx:76 formats with toFixed into '{{value}} mp', which gives '2.5 mp' where Hungarian expects '2,5 mp'. AskChat.tsx:292 also hardcodes the unit 's' (hu should be 'mp'), so that spot has an untranslated unit as well as the wrong decimal mark. formatNumber already exists in src/lib/date-format.ts:88. I saw no saved result with a duration on Generate or Ask, so this part is code-verified, not seen in the browser. (4) Selects, measured as scrollHeight/scrollWidth against client size on the SelectValue span. Library folder filter (LibraryPage.tsx:496, w-[160px]) is a real fixed width: 'Minden mappa' (hu) and 'Todas las carpetas' (es) are clipped at every width I tried (375, 412, 768, 1024, 1280); 'All folders' (en) fits at 65px. Brainstorm is NOT a fixed-width problem, so the suggested min-w/w-auto will not fix it. The triggers are w-full inside `sm:grid-cols-2` (BrainstormForm.tsx:93). At lg (1024px and wider) the page's two-column layout puts the form in a card about 375px wide, so each select is 167px. English fits ('Explore Both Sides' at 123px). hu ('Mindkét Oldal Vizsgálata', 'Az 5 Legjobb Szög') and es ('Explorar Ambos Lados', 'Top 5 Mejores Ángulos') wrap to a second line, are clamped with an ellipsis, and the clipped value sits about 19px further right than the English one, a side effect of `[&>span]:line-clamp-1`. From 375 to 900px nothing clips. Fix: drop to one column when the card is narrow (for example `sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2`, or a container query), or use shorter strings. Rated low rather than nit: the truncation hits the two main Brainstorm controls on every desktop viewport and the Library filter at every width, in two of three languages. Related, outside the claim: 'Szög' is a geometric 'angle' calque (also in the empty-state 'Kreatív Szögek Feloldása'), and the hu strings use English-style Title Case. - -**Evidence files** - -[switch-dialog-Magyar.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/i18n/switch-dialog-Magyar.png), [hu-import-error-raw-english.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/i18n/hu-import-error-raw-english.png), [ai-hu-result.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/i18n/ai-hu-result.png), [signedout-i18n.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-4/signedout-i18n.json), [hu-language-dialog.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-4/hu-language-dialog.png), [es-language-dialog.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-4/es-language-dialog.png), [selects-measure.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-4/selects-measure.json), [selects-measure-en.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-4/selects-measure-en.json), [hu-library-folder-filter-412.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-4/hu-library-folder-filter-412.png), [hu-library-folder-filter-1280.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-4/hu-library-folder-filter-1280.png), [es-library-folder-filter-1280.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-4/es-library-folder-filter-1280.png), [hu-brainstorm-selects-1280.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-4/hu-brainstorm-selects-1280.png), [es-brainstorm-selects-1280.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-4/es-brainstorm-selects-1280.png), [en-brainstorm-selects-1280.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-4/en-brainstorm-selects-1280.png), [hu-brainstorm-selects-768.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-4/hu-brainstorm-selects-768.png) - - -### journeys-25: Tour completion is stored per browser only - -**Severity:** nit · **Verdict:** confirmed · **Root cause:** `onboarding-state-device-scoped` - -**Where** - -- src/hooks/use-onboarding.ts:13-26 (persisted to localStorage 'debate-trainer-onboarding' only) -- src/App.tsx:201-210 - -**What the reviewer saw** - -The completion flag lives only in localStorage and survives sign-out (17-settings-signout.json lsKeysAfter still includes debate-trainer-onboarding). An experienced student gets the full tour again on every new device or browser. A new student on a shared school computer where someone else finished the tour never sees it. - -**Why it happens** - -The onboarding state is device-scoped, not account-scoped. - -**Suggested fix** - -Store hasCompletedOnboarding in user_settings (already hydrated on sign-in) and key the local cache by user id. - -**Who it hits** - -Shared school computers and multi-device students. - -**Independent check** - -Code: use-onboarding.ts persists hasCompletedOnboarding only in localStorage 'debate-trainer-onboarding', not keyed by user; nothing removes it on sign-out (no matches in sign-out paths). Not browser-tested. - -**Evidence files** - -[17-settings-signout.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/17-settings-signout.json) - - -### journeys-26: Login page has no way back to the site and uses a generic sparkle icon instead of the DebateLab logo - -**Severity:** nit · **Verdict:** confirmed · **Root cause:** `login-page-outside-public-layout` - -**Where** - -- src/pages/LoginPage.tsx (no logo link, no footer, no <main> landmark) - -**What the reviewer saw** - -After Sign out the app lands on /login (17-after-signout.png). The card shows a sparkle icon (the header uses the DebateLab tree logo), with no link to the home page, Terms or Privacy, and no <main> element (locator('main') timed out on /login in 17-settings-signout run). Signing out gives no confirmation; the page just changes. - -**Why it happens** - -The login page is a standalone card outside the public layout. - -**Suggested fix** - -Wrap it in <main>, use the real logo linked to '/', add a Terms and Privacy footer, and after sign-out show a 'You've signed out' line. - -**Who it hits** - -Everyone signing out on a shared device; minor brand inconsistency. - -**Independent check** - -Signed-out /login: 0 <main>, 0 links, 0 images; icon is lucide Sparkles (LoginPage.tsx:9,154), not the logo. #115 added only the h1. - -**Evidence files** - -[17-after-signout.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/journeys/raw/17-after-signout.png), [s7-mobile.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/s7-mobile.json), [s7-login.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-A/raw/s7-login.png) - - -### ai-surfaces-21: Export details: inconsistent file names, English-only and incomplete Generate clipboard, missing success toasts - -**Severity:** nit · **Verdict:** partly · **Root cause:** `export-naming-and-feedback-inconsistent` - -**Where** - -- src/lib/pdf-export-utils.ts:446, 527, 592, 653 -- src/components/features/generate/GenerateResults.tsx:89-107 (clipboard labels THESIS/FRAMEWORK/CONTENTION/Reasoning/Evidence/Impact; omits predicted opponent case, exordio and rebuttals) -- src/components/features/factoids/FactoidsResults.tsx:194-200 and GenerateResults handlePdfExport (no success toast) - -**What the reviewer saw** - -Downloads: -- PDF names: 'DebateCase_pro.pdf', 'AttackAnalysis_22.pdf' (22 is the critique score), 'BrainstormResults.pdf', 'ResearchFactoids.pdf'. -- DOCX names include the date ('Attack Analysis_2026-10-08.docx'). - -The Generate clipboard (simulated result) is 8,418 characters with English labels whatever the UI language. Attack and Brainstorm show 'exported to PDF!' toasts; Factoids and Generate PDF show none. - -**Why it happens** - -Each export was written separately, with no shared naming or feedback helper. - -**Suggested fix** - -Use one helper: `<Tool>_<motion-slug>_<YYYY-MM-DD>.<ext>`, and the same success or failure toast for every export. Use i18n labels in the clipboard text and include every section. - -**Who it hits** - -Minor confusion in students' Downloads folders, plus inconsistent feedback. - -**Independent check** - -Confirmed: PDF names DebateCase_<side>.pdf, AttackAnalysis_<score>.pdf, BrainstormResults.pdf, ResearchFactoids.pdf (pdf-export-utils.ts:446, 527, 592, 653); DOCX names differ (export-utils.ts:184 DebateCase_<suffix>.docx, 282/369/444 <Tool>_<date>.docx). On staging, Generate PDF downloaded 'DebateCase_pro.pdf' and DOCX 'DebateCase_pro.docx', neither with a success toast; the clipboard text uses hard-coded English labels and omits the predicted opponent case and exordio (GenerateResults.tsx:89-107). Correction: the DOCX name is 'AttackAnalysis_<date>.docx' (no space), and the Generate DOCX has no date. - -**Evidence files** - -[attack-default.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/attack-default.json), [factoids-default.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/factoids-default.json), [generate-export-sim.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/generate-export-sim.json), [misc-tools.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-B/misc-tools.json) - - -### a11y-i18n-28: debate.counts has no plural forms: '1 sources · 1 outputs' (en) and '1 fuentes · 1 resultados' (es) - -**Severity:** nit · **Verdict:** confirmed · **Root cause:** `debate-counts-no-plural` - -**Where** - -- src/locales/en.json and es.json debate.counts -- src/pages/HomePage.tsx:136, src/pages/CurrentDebatePage.tsx:118, src/components/features/debate/CurrentDebateMenu.tsx:66 - -**What the reviewer saw** - -Rendered through i18next with the repo catalogs: en '1 sources · 1 outputs', es '1 fuentes · 1 resultados'. hu '1 forrás · 1 eredmény' is correct (Hungarian does not inflect after numerals). Every other {{count}} key does have _one/_other variants (en 1 item, 1 message, 1 word all correct), so this is the only plural gap. - -**Why it happens** - -Two counts in one string, interpolated as plain variables. - -**Suggested fix** - -Split into debate.sourceCount and debate.outputCount with count-based _one/_other keys and join them. - -**Who it hits** - -Any debate with exactly one source or output. - -**Independent check** - -Catalog: en.json:2251 '{{sources}} sources · {{outputs}} outputs', es.json:2251 same shape, plain variables, no _one/_other; hu is fine. - -**Evidence files** - -[plural.mjs](/tmp/claude-1000/-home-codex-Projects-DebateLab/d2a5d372-2159-4dd7-985d-8ec353fdd260/scratchpad/review/a11y-i18n/plural.mjs) - - -### a11y-i18n-30: English startup shell and offline page; ~180 ms of English UI on a fresh device for hu users - -**Severity:** nit · **Verdict:** confirmed · **Root cause:** `language-unknown-until-js-and-server-settings` · **Known in plan:** next-15 #15 (offline page) - -**Where** - -- index.html:150-155 ('Loading DebateLab', aria-label 'DebateLab is loading', lang='en') -- public/theme-init.js:6-9 ('Unable to load DebateLab', 'Reload') -- public/sw.js:79 (English offline page) - -**What the reviewer saw** - -Flash timing on staging with trial set to hu server-side. With hu cached locally (returning device): html lang en→hu at 194 ms, the English 'Loading DebateLab' shell shows from 198 to 229 ms, and no English UI text paints. With nothing cached (fresh device): the English UI paints at 473 ms and switches to Hungarian at 655 ms (about 180 ms of English) after hydrateFromServer. Single measurements at load average about 2.3. - -**Why it happens** - -Static HTML is English-only, and on a new device the language is only known after the server settings load. - -**Suggested fix** - -Have theme-init.js also read debate-trainer-settings.state.language, set documentElement.lang and swap the shell text from a three-language map. Localize the sw.js offline page. - -**Who it hits** - -A brief English flash for hu/es users, mainly on new devices. - -**Independent check** - -Adam, the code lines are confirmed: index.html:152-153 is the English 'Loading DebateLab' shell under <html lang=en>, public/theme-init.js:7-9 is the English bootstrap-error card, and public/sw.js:79 is the English offline page. knownInPlan (next-15 #15, plan line 137) covers only the sw.js offline page, not the shell or theme-init. Measurement: pro on desktop /app, with GET user_settings rewritten to language hu in my own browser and every non-GET to user_settings aborted (none were attempted). 3 runs per scenario, load average 2.9-3.5. (A) Device with en cached, server says hu: English app UI ('Home Start debate Library History') painted, then switched to Hungarian; the English window was 607, 114 and 43 ms, median 114 ms (the reviewer's 180 ms is within that range). The flip trails the user_settings response by 25-50 ms, so the window is the user_settings round trip after React's first paint. This happens for real after an OAuth redirect to /app on a new device, when the language was changed on another device, or after cleared storage. (B) Returning device with hu cached: the English 'Loading DebateLab' shell shows for about 150-180 ms with lang=en (179-357, 250-408, 264-412 ms). lang then flips to hu about 30-50 ms before the shell is replaced, so English text briefly sits under a Hungarian lang. No English app UI paints. The reviewer's theme-init fix (read the cached language, set lang, swap the shell text) covers only case B and does nothing for the case A window. Related, not a new finding: nothing reads navigator.language (i18n.ts sets lng 'en' and the settings store defaults to 'en'), so a first-time hu visitor gets English everywhere until they choose Magyar. A navigator.language default is the only lever for fresh devices. Code-level only, not measurable on staging because seed.mjs:234 gives the seeded pro a grace period until 9999: for a real Stripe pro user (tier pro, no grace), settings-store.ts hydrateFromServer awaits canAccessProModels (getUser plus a subscriptions query, serially) before applyAppLanguage. That adds two round trips to the case A window, and a null result from either returns early, so that session never applies the server language, theme or feature configs. Apply the language as soon as the settings data arrives. - -**Evidence files** - -[flash-log.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/a11y-i18n/i18n/flash/flash-log.txt), [flash-pro.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-4/flash-pro.json), [flash-pro-log.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-ux-4/flash-pro-log.txt) - - -### ai-surfaces-13: Sparring 'Category balance' radar was drawn as a small misplaced shape right after judging (observed once) - -**Severity:** refuted (not a real problem) (reviewer said medium) · **Verdict:** refuted - -**Where** - -- src/components/features/sparring/SparringScorecard.tsx:89-99 (ResponsiveContainer > RadarChart with animated Radar) -- src/pages/SparringPage.tsx:52 (scorecard enters with motion-swap) - -**What the reviewer saw** - -Live round on desktop, screenshot about 3 s after the verdict: both data polygons appear as a ~50 px diamond to the left of the polar grid, near the 'Strategy' label, instead of over it. - -Reopening the same round from History rendered correctly twice: desktop grid x=888 w=182 with polygons at x=906–1048, and mobile likewise (`history-*.json`). So it is tied to the first render after judging. I observed it once and have no reproduction rate. - -**Why it happens** - -Suspected: Recharts Radar animation started while the ResponsiveContainer was being measured or resized during the round-to-scorecard swap, leaving the polygons at the first measured geometry. - -**Suggested fix** - -Set isAnimationActive={false} on both Radar series (or key the chart on the measured width), and/or mount the chart after the motion-swap entrance ends. - -**Who it hits** - -The payoff screen of a sparring round can show a broken chart. - -**Independent check** - -Not reproduced. The reviewer saw it once and reopening the round rendered correctly. Code (SparringScorecard.tsx:89-99) is a standard Recharts ResponsiveContainer/RadarChart inside a motion-swap entrance; a mid-animation or resize race is plausible but there is no deterministic repro, and getting a fresh judged scorecard needs a full real Sparring round (several AI calls) or a hand-built session plus faked judge response, which I did not build. Default to refuted per the brief; the suggested isAnimationActive={false} is a cheap hardening if someone sees it again. - -**Evidence files** - -[sparring-scorecard-top.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/sparring-scorecard-top.png), [history-desktop-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/history-desktop-light.json), [history-sparring-radar-desktop-light.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/ai-surfaces/raw/history-sparring-radar-desktop-light.png) - diff --git a/plans/2026-10-08-ultracode-review-report/visual.md b/plans/2026-10-08-ultracode-review-report/visual.md deleted file mode 100644 index 4781804f7..000000000 --- a/plans/2026-10-08-ultracode-review-report/visual.md +++ /dev/null @@ -1,1282 +0,0 @@ -# Motion, theming and mobile layout: findings - -**Status:** Review evidence from 2026-10-08/09, for [the ranked report](../2026-10-08-ultracode-review-report.md). Not started. - -35 findings: 2 high, 11 medium, 17 low, 5 nit. An independent verifier re-checked every one. - -Each entry has the final severity, where the problem lives, what the reviewer saw, why it happens, a suggested fix, the independent check and links to the screenshots and measurements in `.verify/ultracode-review-2026-10-08/`. Line numbers are against main at 40738ea. - -Not covered by the motion-polish reviewer: - -- AI-driven states (AI budget was 0): loading boxes while streaming (code shows the same dashed replay), message-enter, streaming caret and glow, Crossfire and Sparring phase swaps, Sparring Scorecard tooltips, regenerate overlay -- Mobile and tablet devices (other agents' lane). Swipe-to-dismiss toast breakage is inferred from code only -- A full dark-mode hover sweep. Dark was spot-checked with screenshots and computed colours on /app/generate only -- Spanish and Hungarian label widths in the header and tabs (the pro account is English, and the trial account belongs to the i18n agent) -- The onboarding tour (skipped by the harness) -- Scroll lock with other scrollbar widths or OS overlay settings: tested one classic width (10px) only -- Toasts with different texts or variants stacked together (tested three identical 'Copied to clipboard' toasts only) -- The toast exit animation being suppressed is code-derived: the identical toast behind hides it in the filmstrip -- Admin, workspace and iOS surfaces (out of scope) -- Hand-off for other areas: Settings shows 'Set Up Billing' next to the Pro badge for the Pro seed account. The staging banner covers the Review sheet header, but that is staging-only and was ignored - -Not covered by the mobile-visual reviewer: - -- Safari-only behaviour (iOS input zoom, safe-area insets, 100vh vs dvh, standalone PWA back gesture) can only be shown in WebKit on a real device. Reported from code evidence only. -- Live AI flows (streaming Ask, Crossfire or Sparring rounds, Generate output while streaming): AI budget was 0. Crossfire and Sparring were checked on their setup screens only, and results only through saved items loaded locally. -- Toast layout on phones: no safe way to trigger one without a write or AI call. -- PricingModal, Library source-detail sheet editing, Switch debate dialog and Settings delete dialogs were not opened, to avoid any risk to the shared pro account. -- Signed-in Spanish and Hungarian UI: language is server-side on shared accounts. Hungarian was checked only on signed-out public pages at 320. -- The 'Ask auto-scroll moves the window' hypothesis was not confirmed (an isolated reproduction did not scroll the window), so it is not reported. -- Real touch-gesture scroll chaining inside nested lists and the flowchart canvas. Reported from geometry, not from real swipes. - -### mobile-visual-1: Ask chat has no room for messages on phones: the composer overflows the card and covers the page footer - -**Severity:** high · **Verdict:** confirmed · **Root cause:** `ask-chat-fixed-height-card-on-phones` - -**Where** - -- src/components/features/ask/AskChat.tsx:517 (Card h-[calc(100vh-14rem)] min-h-[400px] max-h-[800px]) -- src/components/features/ask/AskChat.tsx:545 (messages CardContent flex-1 overflow-hidden) -- src/components/features/ask/AskChat.tsx:582 (footer div shrink-0 with mode tabs, 4 toggles, model, reasoning, context strip, composer) -- /app/ask on every phone - -**What the reviewer saw** - -Measured card / header / messages area / footer heights in px (dark, staging): iPhone SE 320x568 400/156/0/555; iPhone 14 390x664 440/136/0/463; Pixel 7 412x839 615/112/78/423; landscape 915x412 400/92/0/307; iPad 810x1080 800/92/353/353 (fine). Because the footer is shrink-0 and taller than the fixed-height card, it overflows the card's bottom border. On iPhone 14 the page footer ('DebateLab / Terms / Privacy') is drawn on top of the 'Using 3 sources…' box and the composer sits below the page footer. The empty state's 48 px MessageCircle icon is clipped to an arc that looks like a stuck spinner on Pixel 7. Any conversation would get 0–78 px of visible transcript. - -**Why it happens** - -A desktop chat layout (a fixed-height card sized from 100vh, with a non-shrinking control footer) is used unchanged on phones, where the footer controls wrap to 300–555 px. - -**Suggested fix** - -In AskChat.tsx, below lg let the card grow naturally or size it to 100dvh minus the header, and give the transcript a guaranteed min-height (for example min-h-[50dvh]). Collapse the four toggles, model and reasoning into a 'More options' disclosure or a sheet on phones, so the footer is only the mode switch plus the composer. Keep the composer sticky at the bottom of the card, and use dvh rather than vh. - -**Who it hits** - -Every phone user of Ask, a core tool; most phones show zero transcript. - -**Independent check** - -Measured on /app/ask as pro. The composer is an <input placeholder='Ask your debate coach...'>, not a textarea. iPhone 14 (390x664): card h 440 (420-860), header 136, messages area 0, controls footer 459 (557-1016, overflowing the card bottom by 156px), composer at 960-1000, page footer at 892-973, so the footer is drawn over the 'Using 10 sources…' box and the composer sits below it (full-page screenshot). iPhone SE (320x568): messages 0, controls 571. Pixel 7: messages 62px. Small differences from the reviewer's numbers come from staging content (the context strip text). The cause is AskChat.tsx:517 (h-[calc(100vh-14rem)] max-h-[800px]) plus the shrink-0 controls footer at :582. - -**Evidence files** - -[ask-measure.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/ask/ask-measure.json), [app_ask-full.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/sweep/pro-iphone-light/app_ask-full.png), [app_ask-full.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/sweep/pro-mobile-dark/app_ask-full.png), [ask-card-iphone.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/ask/ask-card-iphone.png), [ask-card-small-mobile.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/ask/ask-card-small-mobile.png), [s6-mobile.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/s6-mobile.json), [ask-iphone-light-full.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/ask-iphone-light-full.png), [ask-small-mobile-dark-full.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/ask-small-mobile-dark-full.png), [ask-mobile-dark-full.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/ask-mobile-dark-full.png) - - -### mobile-visual-2: Current Debate dashboard (and Factoids results) scroll sideways on phones: grid tracks blow out to the min-content width of nowrap titles - -**Severity:** high · **Verdict:** confirmed · **Root cause:** `grid-items-min-width-auto-blowout` - -**Where** - -- src/pages/CurrentDebatePage.tsx:156 and :161 (grid gap-6 md:grid-cols-2, no mobile column definition) -- src/pages/CurrentDebatePage.tsx:46-48 (li flex with truncate title) and :174 (li truncate source title) -- src/pages/FactoidsPage.tsx:136 (grid gap-8 lg:grid-cols-[400px_minmax(0,1fr)]) -- src/components/ui/button.tsx:9 (whitespace-nowrap default, which feeds min-content) -- Same pattern, at risk with longer content: AttackPage.tsx:130, BrainstormPage.tsx:118, CrossfirePage.tsx:228, HomePage.tsx:160, BrainstormResults.tsx:226 - -**What the reviewer saw** - -/app/debate as pro: document scrollWidth 403 px on the 320 px iPhone SE and 404 px on the 390 px iPhone 14 (fine at 412). Culprits are div[data-testid=debate-side-pro/-con] (right edge 399) plus the Sources and Recent work cards. Chromium's mobile layout viewport widens to match (innerWidth 403, innerHeight 568→716), so the page zooms out or pans sideways. With one long unbroken source title (simulated in the browser only) the page becomes 897 px wide on a 412 px Pixel. A saved Factoids result loaded at 320 is 331 px wide: every card from the form to 'Find More Evidence' is 315 px against 288 available, and the cards' right borders are clipped. - -**Why it happens** - -Implicit single-column grids size their track to `auto`, and grid items keep min-width:auto. A child with white-space:nowrap (truncate titles, nowrap buttons) contributes its full text width as min-content, so truncation never happens and the card grows past the viewport. - -**Suggested fix** - -Give these grids an explicit mobile track (`grid-cols-1`, which Tailwind emits as minmax(0,1fr)) or add `min-w-0` to the grid children (Card). Add `min-w-0` to the li flex children in CurrentDebatePage. Let suggestion and query buttons wrap (`whitespace-normal`) or truncate. A one-line lint or grep rule ('grid gap-* md:/lg:grid-cols-* without grid-cols-1') would stop this coming back. - -**Who it hits** - -Most iPhone users (375/390/393 px) on the Current Debate dashboard, the app's hub. Any debate with a long item or source title breaks every phone width. - -**Independent check** - -Confirmed, and worse than reported. On /app/debate the layout viewport stays at the phone width (clientWidth 320, 390 and 412; single-column grid), but the cards overflow it. debate-side-pro/-con reach right 504, and debate-sources and debate-recent reach right 800, because the longest source title, a truncate li in an implicit auto grid track, now sets min-content. Chromium mobile then zooms out to fit: innerWidth reports about 800 and scrollWidth is 800-801. The page renders at about 49% scale instead of panning 14px. It now also reproduces at 412 (Pixel 7), where the reviewer saw no problem, because other agents have added longer source and item titles to the shared debate since. The whole effect depends on content length, which is exactly the risk. Factoids: loading the saved factoids result from History at 320px gives scrollWidth 331, with the AnimatedSection form wrapper and the export row both 315px wide (right 331), and the card's right border clipped (screenshot). The fix is correct: grid-cols-1 (minmax(0,1fr)) or min-w-0 on the grid children, plus min-w-0 on the li flex children. - -**Evidence files** - -[app_debate-full.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/sweep/pro-small-mobile-light/app_debate-full.png), [measure.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/sweep/pro-iphone-light/measure.json), [longtext.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/longtext/longtext.json), [mobile-app_debate-full.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/longtext/mobile-app_debate-full.png), [results.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/results/results.json), [factoids-result-320-dark-crop.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/crops/factoids-result-320-dark-crop.png), [s6-mobile.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/s6-mobile.json), [debate-iphone-light-full.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/debate-iphone-light-full.png), [debate-small-mobile-dark-full.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/debate-small-mobile-dark-full.png), [factoids-result-320-dark-viewport.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/factoids-result-320-dark-viewport.png) - - -### motion-polish-1: Signed-in pages paint the 'No current debate' / empty / default-model state first, then flip when stores hydrate - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `stores-render-defaults-before-hydration` - -**Where** - -- src/pages/HomePage.tsx:113-125 (CurrentDebateSummary no-debate branch) -- src/components/features/debate/CurrentDebateStrip.tsx:66-73 -- src/components/features/debate/DebateContextControl.tsx:86-94 -- src/components/features/debate/CurrentDebateMenu.tsx:34 (header shows 'Start debate' before 'Current debate') -- src/pages/CurrentDebatePage.tsx (EmptyState, then zero counts) -- src/lib/stores/debate-store.ts:45,121,152 (hydrated flag exists) -- src/components/features/debate/DebateDialogs.tsx:171 (only reader of hydrated) -- src/lib/supabase/AuthProvider.tsx:249-256 (resetDebateStore on sign-out) -- routes /app, /app/generate (and every tool with the strip), /app/debate - -**What the reviewer saw** - -Measured on fresh contexts (same as the first load after any sign-in, because sign-out calls resetLocalData). Order of states: /app showed 'No current debate. Start a debate to keep one motion…' with a Start debate button from 3278 to 3634ms, then the debate card. React reused the DOM nodes, so the layout-shift entry shows p.text-lg going from h 20 to h 25 and the tools grid moving 311 to 336 (+25px, CLS 0.0156). The Library and History nav links shift in x at the same moment because the header label changes from 'Start debate' to 'Current debate ▾'. On /app/generate the strip read 'No current debate. Results stay one-off until you start one. [Start debate]', the context line read 'No Current Debate, so no debate material will be used', the Motion field was empty and the model showed 'GPT-5.6 Luna'. Then everything flipped to 'Working in: This House would ban…', a prefilled motion and 'Claude Sonnet 5'. The wrong-state window was 1135, 704 and 108ms over 3 runs (median 704ms; load averages 4.5, 3.6, 3.8). /app/debate showed the 'No current debate' EmptyState from 401 to 652ms, then the dashboard with '0 sources · 0 outputs' and 'No work for this side yet' next to an 'In favor 3 items' badge, and filled in at 739ms. CLS was 0.0686 + 0.0493 (debate container h 280 to 667; Sources grid y 553 to 691) = 0.118 in the cold run and 0.184 in the sweep run. Timings are single runs except /app/generate. - -**Why it happens** - -These components render straight from store defaults (debates: [], currentDebateId: null, default settings, empty session lists) and treat that as real 'no debate' or 'no work' state. The stores already track hydration (debate-store `hydrated`, session-store `serverHydration`, sparring `hydrated`), but only DebateDialogs reads it. Sign-out empties the persisted debate store, so every sign-in starts from the empty state. - -**Suggested fix** - -Add a selector such as `useDebateReady()` (debate-store.hydrated, or a persisted ownerUserId matching the current user). Until it is true, render a neutral placeholder of the same height instead of the no-debate branch in HomePage CurrentDebateSummary, CurrentDebateStrip, DebateContextControl, CurrentDebateMenu and CurrentDebatePage. Gate counts and 'No work for this side yet' on sessionStore.serverHydration === 'complete'. Hold ModelSelector's default until settings hydrate. - -**Who it hits** - -Every student on every sign-in, new device or cleared storage, on Home, every tool page and the Current Debate dashboard. A click on 'Start debate' during the window opens the create dialog even though a debate exists. - -**Independent check** - -Code check: debate-store persists debates and currentDebateId (debate-store.ts:249-256). resetLocalData on sign-out empties it (AuthProvider.tsx:249-256), and only DebateDialogs reads `hydrated`. Two groups of users see different things. With a fresh store (every sign-in, a new device, cleared storage), the app paints the no-debate state first. A returning reload with the persisted store does not flash: 2 returning runs went straight from skeleton to 'Working in' or the debate card. Strongest structural evidence: on a cold /app/debate the 'No current debate' EmptyState flips to the dashboard, and the container grows from h 280 to 667. That is a CLS of 0.2978 per cold load, the same in 2 runs and above the 0.25 'poor' threshold. Time windows, unthrottled (load average about 2.8): /app/generate 0, 67 and 112 ms over 3 runs (in run 1 the strip stayed hidden behind the skeleton), /app 15 ms, /app/debate 49 and 127 ms. With fast4g throttling, /app/generate showed 'No current debate' plus 'Start debate' in the header plus an empty Motion field for 505 ms (screenshot), and /app showed it for 409 ms. While the route skeleton shows, the header reads 'Start debate' in every cold run. Another wrong state appeared: in a fresh-cache cold load the Home card read '0 sources · 1 outputs' for about 550 ms before showing '43 sources · 16 outputs'. That frame was captured in coldseq-app-dark-2-spinner.png (the filename is wrong: the image shows content, not the spinner). Not verified: the 'GPT-5.6 Luna → Claude Sonnet 5' model sub-claim. My sampler read the Side select, and settings were already persisted in my context. The suggested fix (a hydration-ready selector gating the no-debate branches and the counts) is sound. - -**Evidence files** - -[filmstrip-app-cold-dark.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/filmstrip-app-cold-dark.png), [filmstrip-generate-cold-light.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/filmstrip-generate-cold-light.png), [filmstrip-debate-cold-light.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/filmstrip-debate-cold-light.png), [t3-coldload-app-cold-dark.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/t3-coldload-app-cold-dark.json), [t3-coldload-generate-cold-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/t3-coldload-generate-cold-light.json), [t3-coldload-generate-cold-light-run2.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/t3-coldload-generate-cold-light-run2.json), [t3-coldload-generate-cold-light-run3.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/t3-coldload-generate-cold-light-run3.json), [t3-coldload-debate-cold-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/t3-coldload-debate-cold-light.json), [sweep-tools1-desktop-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/sweep-tools1-desktop-light.json), [s1-coldload.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/s1-coldload.json), [s4-theme-throttle.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/s4-theme-throttle.json), [cold-generate-no-debate-state-fast4g.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/cold-generate-no-debate-state-fast4g.png), [coldseq-app-dark-2-spinner.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/coldseq-app-dark-2-spinner.png), [s7-final.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/s7-final.json) - - -### motion-polish-2: Toasts never stack, never expand on hover and never animate out: a global !important animation pins their transform - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `global-attribute-selector-animations` - -**Where** - -- src/index.css:644-646 `[data-sonner-toaster] [data-sonner-toast] { animation: toast-slide-in 0.3s … both !important }` -- src/index.css:648-657 (@keyframes toast-slide-in) -- src/components/ui/sonner.tsx (Sonner 2.0.8) -- every toast in the app, e.g. /app/generate results 'Clipboard' button - -**What the reviewer saw** - -Loaded a saved case from History, clicked 'Clipboard' 3 times and measured. Sonner set --y to translateY(0), translateY(-14px) scale(.95) and translateY(-28px) scale(.9) when collapsed, and translateY(0), -65px and -131px when hovered (expanded). The computed transform was matrix(1,0,0,1,0,0) for all three in both states, and all three sat at y≈723-725, perfectly overlapping. Hover-expand does nothing. Clicking X on the front toast removes it, and the identical toast behind takes its place, so the screen looks unchanged (filmstrip). From code: animation-fill-mode both also pins opacity at 1, so Sonner's removal fade and swipe offset can't apply. Exit and swipe were not observed separately. - -**Why it happens** - -A CSS animation with fill-mode `both` keeps its last keyframe (transform: translateY(0) scale(1); opacity: 1) applied after it ends. Animation values beat Sonner's normal `transform: var(--y)` declarations, and the `!important` on the animation stops Sonner from replacing it. - -**Suggested fix** - -Delete index.css:643-657 and let Sonner animate entry, stacking and exit itself. If a custom entry is wanted, drop `both` and `!important` and target only `[data-sonner-toast][data-mounted='false']`. - -**Who it hits** - -Anyone who gets more than one toast within the toast lifetime, e.g. save then error, or repeated copy. An older toast (possibly an error) is hidden behind the newest. Dismissing seems not to work. Swipe-to-dismiss is broken on touch (inferred). - -**Independent check** - -Repro: on /app/history, Load the first case and click Clipboard 3 times. All 3 toasts sat at y 723-725 with computed transform matrix(1,0,0,1,0,0), while Sonner's --y was translateY(0), translateY(-14px) scale(.95) and translateY(-28px) scale(.9). On hover data-expanded became true and --y became -65px and -131px, but transform stayed identity and y stayed 725. The screenshot shows a single toast. After clicking X on the front toast, the removed toast still had opacity 1 and identity transform. Sonner 2.0.8 injects its CSS (confirmed). Its own rules (transform: var(--y), the opacity transitions, the swipe-out keyframes) lose to index.css:644-646 `animation: toast-slide-in … both !important`, because the final keyframe values stay applied with fill-mode both. Swipe-out was not exercised (code only). Same mechanism as motion-polish-5, so they share a root cause key. Severity stays separate because this one hides real messages, such as an error behind a success toast. - -**Evidence files** - -[t14-toasts-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/t14-toasts-light.json), [toasts-collapsed-light.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/raw/toasts-collapsed-light.png), [toasts-hovered-light.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/raw/toasts-hovered-light.png), [filmstrip-toast-dismiss-light.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/filmstrip-toast-dismiss-light.png), [s2-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/s2-desktop.json), [toasts-collapsed.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/toasts-collapsed.png), [toasts-hovered.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/toasts-hovered.png) - - -### motion-polish-3: New pages open at the previous page's scroll position (no scroll-to-top on route change) - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `no-scroll-reset-on-route-change` - -**Where** - -- src/App.tsx:271-289 (BrowserRouter with declarative Routes; no ScrollRestoration or pathname scroll effect) -- src/components/layout/AppLayout.tsx (Outlet; no scroll reset) -- src/pages/SparringPage.tsx:118 (the only scrollTo in the app) -- src/index.css:153 (html scroll-behavior: smooth) - -**What the reviewer saw** - -Home scrolled to the bottom (scrollY 115), then clicked the Timer utility link. Scroll was first clamped to 36 while the short skeleton showed, then jumped back to 115 when the timer rendered, so the page visibly jumps. Timer scrolled to 298, then clicked header Library: Library opened at scrollY 298 of 508, with the page title, Add-source actions and folders scrolled out of view (screenshot). Browser Back kept 298. - -**Why it happens** - -A declarative BrowserRouter doesn't manage scroll, and nothing in AppRoutes or AppLayout resets it when the pathname changes. - -**Suggested fix** - -Add a small ScrollToTop in AppRoutes: a useLayoutEffect on location.pathname that calls window.scrollTo({ top: 0, behavior: 'instant' }) when navigationType !== 'POP'. Use 'instant' because html has scroll-behavior: smooth. Alternatively move to createBrowserRouter with <ScrollRestoration/>. - -**Who it hits** - -Any navigation from a scrolled page: footer links, Home utilities, header nav, tool tiles. It is worse on short laptop screens and phones. - -**Independent check** - -grep finds no scrollTo or ScrollRestoration except SparringPage.tsx:118. App.tsx uses a declarative BrowserRouter. Repro, desktop 1280x800: scrolled /app to the bottom (scrollY 115) and clicked the Timer utility. /app/timer opened at scrollY 115 (max 298). Then scrolled Timer to 298 and clicked header Library: /app/library opened at scrollY 298 (max 508) with the h1 at top -165, so the title and add-source controls are off-screen (screenshot). The fix is correct: reset in a useLayoutEffect on pathname for non-POP navigation with behavior 'instant', because html has scroll-behavior: smooth (index.css:153). - -**Evidence files** - -[t6-nav.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/t6-nav.json), [nav-library-after-click-from-scrolled-timer.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/raw/nav-library-after-click-from-scrolled-timer.png), [nav-timer-after-click-from-scrolled-home.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/raw/nav-timer-after-click-from-scrolled-home.png), [s2-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/s2-desktop.json), [scroll-library-after-click-from-scrolled-timer.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/scroll-library-after-click-from-scrolled-timer.png), [scroll-timer-after-click-from-scrolled-home.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/scroll-timer-after-click-from-scrolled-home.png) - - -### motion-polish-4: Adam's example: dashed empty-state boxes dip 10px, blur and fade back in whenever the pointer leaves them; global 'marching ants' redraws every dashed border on hover - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `dashed-hover-animation-restarts-entrance` - -**Where** - -- src/index.css:472-511 (.border-dashed:hover sets the animation shorthand to marching-ants at :505, plus @keyframes marching-ants) -- src/index.css:349-351 (.motion-swap animation) and :932-943 (@keyframes motion-swap: translateY(10px) scale(.99), opacity 0, blur(2px)) -- src/pages/GeneratePage.tsx:384,402 -- src/pages/AttackPage.tsx:139,152 -- src/pages/BrainstormPage.tsx:127,135 -- src/pages/FactoidsPage.tsx:145,159 -- src/components/ui/empty-state.tsx:14 (History tabs, CurrentDebatePage, UnsortedWork, BrainstormResults) -- static dashed elements that get ants on hover: DebateContextControl.tsx:88, CurrentDebateStrip.tsx:60, HomePage.tsx:115, LibraryPage.tsx:484,864,872,897, CrossfirePage.tsx:1012, AskChat.tsx:330, FactoidsResults.tsx:275, SparringRound.tsx:272 - -**What the reviewer saw** - -/app/generate, desktop. While hovered, animation-name is marching-ants. On mouseleave it becomes motion-swap again and restarts. Per-frame samples: t=12ms transform matrix(.99,…,0,10), opacity 0, blur(2px), top 291 versus 279 baseline; t=62ms 3.4px, opacity .66; settles by about 260ms. trackMovement shows a transient move of 12px and a resize of 6.8px. The sweep caught it on Generate, Attack, Brainstorm and Factoids, including the everyday path of moving from the box to the strip's 'Change' button. Clicking the Motion field after hovering the box does the same (video). The hover state itself replaces the 2px rounded dashed border with 1px square-cornered primary lines drawn inside the padding box (screenshots in light and dark). It does this even on plain text such as the 'No Current Debate, so no debate material…' line, and with reduced motion on. grep for onDrop/onDragOver in src finds nothing: there are no drop zones, so the ants falsely suggest a drop target. From code only: the loading variants (motion-shimmer + border-dashed + motion-swap) replay the same way while a generation streams. - -**Why it happens** - -Two rules set the `animation` shorthand on the same element. `.border-dashed:hover` (specificity 0,2,0, later in the file) beats `.motion-swap` (0,1,0) while hovered. On mouseleave the animation-name changes back to motion-swap, and a changed animation-name restarts the animation from frame 0. The ants are painted as a background-image with 1px background-size inside a now-transparent border, so the border changes thickness, position and corner shape. - -**Suggested fix** - -Delete the global `.border-dashed:hover` block and `@keyframes marching-ants` (index.css:472-511). Nothing in the app is a drop zone. If a future drop zone wants ants, add an opt-in `.dropzone` class that draws them on a pseudo-element and never sets the element's `animation`. Note that `.motion-shimmer` already uses ::after on the loading boxes, so a dropzone pseudo-element would need ::before. - -**Who it hits** - -The first screen of four Prepare and Practice tools, plus History and Current Debate empty states. The box blinks every time the pointer passes over it on the way to the form, which is the glitch Adam reported. - -**Independent check** - -This is Adam's example, confirmed in the compiled staging CSS. `.border-dashed:hover{…animation:marching-ants…}` sits at byte 79169 of index-BMQPADaz.css, after `.motion-swap{animation:motion-swap…both}` at byte 37079, and also has higher specificity. On /app/generate (desktop) the 'Ready to Generate' box reads animation-name marching-ants while hovered. The border turns transparent and is replaced by 1px square-cornered primary lines (hover crop). After the pointer moves to the Motion field, per-frame samples show the animation flip back to motion-swap and restart from frame 0. At t=38ms: transform matrix(.99,0,0,.99,0,10), opacity 0, blur(2px), top 291 against a 279 baseline. At 62ms 5px and opacity .49. It settles at about 280ms. trackMovement records a 12px transient move and a 6.8px resize. The debate-context line is dashed only in its no-debate or one-off branch, so it gets the ants only then. Touch behaviour is covered in mobile-visual-5. The fix is correct: delete index.css:472-511, since there are no drop zones. - -**Evidence files** - -[dashed-empty-state-dip-on-mouseleave.webm](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/raw/video/dashed-empty-state-dip-on-mouseleave.webm), [filmstrip-dashed-dip-dark.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/filmstrip-dashed-dip-dark.png), [t1-dashed-generate.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/t1-dashed-generate.json), [dashed-generate-hover.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/raw/dashed-generate-hover.png), [dashed-generate-after-mouseleave-40ms.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/raw/dashed-generate-after-mouseleave-40ms.png), [hover-dashed-empty-dark-desktop-b.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/raw/hover-dashed-empty-dark-desktop-b.png), [sweep-tools1-desktop-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/sweep-tools1-desktop-light.json), [s2-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/s2-desktop.json), [dashed-generate-hover.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/dashed-generate-hover.png) - - -### mobile-visual-3: Dialogs have no max-height or scroll: Start new debate and Flowchart Add Node are cut off at the top and bottom on landscape phones, and their Close/Cancel/Submit buttons cannot be reached - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `dialog-no-max-height` - -**Where** - -- src/components/ui/dialog.tsx:41 (DialogContent: fixed, top-50%, translate-y-[-50%], no max-h, no overflow-y) -- src/components/ui/alert-dialog.tsx (same pattern) -- src/components/features/debate/DebateDialogs.tsx:129 (Start new debate / DebateForm) -- src/pages/FlowchartPage.tsx:501 (Add Node dialog) - -**What the reviewer saw** - -Start new debate on 568x320: dialog top -161 px, bottom 481 px; Close (top -144), Cancel and Start debate are all off-screen. On 915x412: top -63, bottom 475, the same three buttons off-screen. Flowchart Add Node on 568x320: top -127, bottom 447, Add Node and Close off-screen. On 915x412: top -81, bottom 493. On SE portrait 320x568 it is 574 px tall in a 568 px viewport, so both edges are clipped by 3 px. Radix locks body scroll and no scroll container exists inside (scroller: null), so the cut-off parts cannot be reached at all. Sheets (Review, Saved chats) do scroll and are fine. The Motion textarea autofocuses, so on a phone the keyboard opens at once and shrinks the usable height further. - -**Why it happens** - -The stock shadcn DialogContent centres a fixed box with transforms and never limits its height to the viewport. - -**Suggested fix** - -In dialog.tsx and alert-dialog.tsx add `max-h-[calc(100dvh-2rem)] overflow-y-auto` (or a header/body/footer layout with a scrolling body and a sticky footer) and `w-[calc(100%-2rem)]` so there is a gutter on phones. On mobile, consider rendering long forms (DebateForm, Add Node) as a bottom Sheet. Drop autoFocus on phones, or scroll the focused field into view. - -**Who it hits** - -Landscape phone users and small phones with the keyboard open cannot start a debate or add a flowchart node. The only way out is tapping the overlay. - -**Independent check** - -These measurements were taken at the real viewport and match the reviewer exactly. Pixel 7 landscape 915x412, Start new debate: top -63, bottom 475; Close at -46, Cancel and Start debate at 410-450, all off-screen; computed max-height none, overflow-y visible, no scroll container; Motion textarea autofocused. Flowchart Add Node at 915x412: top -81, bottom 493, Add Node and Close off-screen. At 568x320: top -127, bottom 447. At 320x568: top -3, bottom 571 (574 tall), with buttons still reachable. Do not cite the Start new debate measurements from SE landscape and SE portrait in s6-mobile.json: they were taken on /app/debate while mobile-visual-2's zoom-out was active (viewport reported as 800x451 and 800x1420). Dialogs were dismissed with Escape, never submitted. dialog.tsx:41 has no max-h or overflow-y. - -**Evidence files** - -[dialogs.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/dialogs/dialogs.json), [start-new-debate-pixel-landscape.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/dialogs/start-new-debate-pixel-landscape.png), [start-new-debate-se-landscape.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/dialogs/start-new-debate-se-landscape.png), [flowchart-add-node-se-landscape.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/dialogs/flowchart-add-node-se-landscape.png), [flowchart-add-node-se-portrait.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/dialogs/flowchart-add-node-se-portrait.png), [s6-mobile.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/s6-mobile.json), [dialog-start-new-pixel-landscape.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/dialog-start-new-pixel-landscape.png), [dialog-add-node-se-landscape.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/dialog-add-node-se-landscape.png), [dialog-add-node-pixel-landscape.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/dialog-add-node-pixel-landscape.png), [dialog-add-node-se-portrait.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/dialog-add-node-se-portrait.png) - - -### mobile-visual-4: Flowchart has not been themed for dark mode: white MiniMap, white controls with invisible icons, white attribution box - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `reactflow-default-light-styles` - -**Where** - -- src/pages/FlowchartPage.tsx:23 (imports reactflow/dist/style.css with no dark overrides) -- src/pages/FlowchartPage.tsx:132-150 (LocalizedControls) -- src/pages/FlowchartPage.tsx:764-777 (MiniMap; thesis and rebuttal both use #3b82f6 while the legend shows thesis as sky) -- node_modules/reactflow/dist/style.css:279-311 (.react-flow__controls-button background #fefefe; .react-flow__minimap background #fff) - -**What the reviewer saw** - -Computed in dark mode: .react-flow__minimap background rgb(255,255,255), .react-flow__controls-button rgb(254,254,254), with near-invisible icons. In the screenshots the controls show as a blank white strip over the Rebuttal node and the MiniMap as a bright white 200x150 box. On a 320 px phone the MiniMap covers 24% of the canvas (19% at 390). reactflow 11 has no colorMode prop, and no CSS in src overrides these classes (grep 'react-flow' in src/*.css finds nothing). - -**Why it happens** - -React Flow's default light stylesheet is used as-is. Its hard-coded colours ignore the app's theme tokens. - -**Suggested fix** - -Add a small CSS block (index.css, or a module next to FlowchartPage) mapping .react-flow__controls-button, .react-flow__minimap, .react-flow__attribution and the background dots to hsl(var(--card)), hsl(var(--border)) and hsl(var(--foreground)), with an svg fill of currentColor. Pass MiniMap maskColor and bgColor from tokens, and give thesis its own colour. Hide the MiniMap below sm. - -**Who it hits** - -Every dark-mode user of Flowchart; the canvas looks broken. - -**Independent check** - -Computed in dark mode on desktop: .react-flow__minimap bg rgb(255,255,255) (200x150), .react-flow__controls-button rgb(254,254,254), .react-flow__attribution rgba(255,255,255,.5). The only CSS rules matching these classes come from reactflow's own stylesheet. iPhone SE dark: canvas 318x400, minimap 200x150, which is 23.6% of the canvas. Screenshots show a blank white controls strip over the Rebuttal node and a bright white minimap. The thesis/rebuttal minimap colour sub-claim was not checked. - -**Evidence files** - -[flowchart-dark-320-canvas-crop.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/crops/flowchart-dark-320-canvas-crop.png), [app_flowchart-full.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/sweep/pro-small-mobile-dark/app_flowchart-full.png), [app_flowchart-full.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/sweep/pro-tablet-dark/app_flowchart-full.png), [misc.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/misc/misc.json), [s3-dark.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/s3-dark.json), [s7-final.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/s7-final.json), [dark-flowchart-desktop.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/dark-flowchart-desktop.png), [flowchart-small-mobile-dark-canvas.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/flowchart-small-mobile-dark-canvas.png) - - -### mobile-visual-5: Hover styles stick after a tap on touch devices: buttons stay raised with a near-black background, and dashed boxes march and replay their entrance animation (the 'dashed box jumps down and back up' glitch) - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `hover-styles-not-gated-for-touch` - -**Where** - -- src/index.css:473-505 (.border-dashed:hover sets `animation: marching-ants …`, replacing motion-swap/motion-reveal/stagger entrance animations on the same element) -- src/index.css:303-311 (.motion-press:hover translateY(-1px) plus shadow, not gated by (hover:hover)) -- src/index.css card-hover-glow, motion-list-item, tilt-card, feature-card :hover rules -- tailwind.config.ts (no future.hoverOnlyWhenSupported, so every hover: utility applies after a tap) -- 18 border-dashed usages, e.g. src/components/ui/empty-state.tsx, GeneratePage.tsx, LibraryPage.tsx, CurrentDebateStrip.tsx, DebateContextControl.tsx - -**What the reviewer saw** - -Pixel 7 emulation (matchMedia hover:none and pointer:coarse are both true). After tapping 'More options' on /app/generate and waiting 1.2 s, the button still matches :hover with transform matrix(1,0,0,1,0,-1), a shadow, and background rgb(2,5,19), which is the dark --accent. The text dims to accent-foreground, so it looks pressed or disabled. A Timer speaker chip shows the same after a tap. Tapping the dashed 'Ready to Generate' empty state sets border-color transparent and an infinite 'marching-ants' animation that keeps running until the next tap elsewhere. trackMovement records the box moving 12 px and resizing 4 px, then returning (transient-move), because the hover rule swaps out the element's motion-swap animation and the browser restarts it when the hover state changes. - -**Why it happens** - -Decorative hover effects are written as bare :hover rules. Touch browsers apply :hover on tap and keep it until the next tap. The marching-ants rule uses the `animation` shorthand, which also clobbers each element's entrance animation. - -**Suggested fix** - -Wrap the custom :hover rules in index.css in `@media (hover: hover) and (pointer: fine)` and set `future: { hoverOnlyWhenSupported: true }` in tailwind.config.ts. Remove the .border-dashed:hover marching ants, or move them to a pseudo-element (::after) so they never replace the element's own animation. Use :active for touch press feedback. This probably overlaps the motion-polish agent's report of the same root cause. - -**Who it hits** - -Every phone and tablet user, on every tap of a button, card or dashed box. Very visible in dark mode. - -**Independent check** - -Pixel 7 emulation (hover:none and pointer:coarse both true). 1.2s after tapping 'More options' on /app/generate, the button still matches :hover: transform matrix(1,0,0,1,0,-1), the 0 8px 20px shadow, bg rgb(2,5,19), and text dimmed to rgb(148,163,184) (crop). After tapping the dashed 'Ready to Generate' box: :hover true, animation marching-ants (infinite), border transparent. Tapping elsewhere (the strip) then replays its entrance: trackMovement records an 8.8px transient move and a 2.9px resize. In my run the jump came on the tap away, not on the tap itself. tailwind.config.ts has no future.hoverOnlyWhenSupported. The dashed-box half is motion-polish-4's cause (fixed by deleting marching-ants). The near-black colour is mobile-visual-6's token problem. What is unique here is gating hover with (hover:hover). - -**Evidence files** - -[hover.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/sticky-hover/hover.json), [dashed.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/sticky-hover/dashed.json), [generate-more-options-after-tap.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/sticky-hover/generate-more-options-after-tap.png), [generate-dashed-empty-state-after-tap.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/sticky-hover/generate-dashed-empty-state-after-tap.png), [dashed-tap-jiggle-pixel7.webm](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/sticky-hover/video/dashed-tap-jiggle-pixel7.webm), [sticky-hover-taps-pixel7-dark.webm](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/sticky-hover/video/sticky-hover-taps-pixel7-dark.webm), [s6-mobile.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/s6-mobile.json), [sticky-more-options-after-tap-pixel7-dark.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/sticky-more-options-after-tap-pixel7-dark.png) - - -### mobile-visual-6: Dark theme surface tokens are off: --muted is a mid-grey slab (404 page, every TabsList, badges) and --accent is near-black (every hover:bg-accent) - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `theme-tokens-accent-muted-misassigned` - -**Where** - -- src/index.css:98 (.dark --muted: 215 16% 46% = rgb(99,114,136)) -- src/index.css:101 (.dark --accent: 228 84% 4% = rgb(2,5,19)) -- src/pages/NotFound.tsx:7 (bg-muted full page) -- src/components/ui/tabs.tsx (TabsList bg-muted): History tabs, Login Email/Demo code tabs -- src/components/layout/AppLayout.tsx:45 and many buttons/menus (hover:bg-accent); src/components/ui/dialog.tsx:48 (close button data-[state=open]:bg-accent) - -**What the reviewer saw** - -In dark mode the 404 page is a flat mid-grey slab, and its 'Return to Home' link (primary on muted) is 2.28:1 contrast. History and Login tab bars are bright grey bars with a darker selected tab, the inverse of expected. Every ghost or outline button's hover/active state turns rgb(2,5,19), darker than the page background rgb(15,23,41), with the label dimmed to accent-foreground. This shows on 'Edit settings', 'More options' and the Current debate trigger after a tap. The step between accent and card is 1.37:1, so it reads as a hole rather than a highlight. In light mode, --muted (slate-400) is likewise used as a heavy grey surface behind tabs. - -**Why it happens** - -The dark palette inverts shadcn's intended roles: muted is a mid-tone meant for borders or text, and accent is darker than the background. Yet components use both as surfaces. --muted-foreground also equals --foreground in both themes, so 'muted' text is not muted. - -**Suggested fix** - -Change the dark tokens in index.css to surface steps just above the card (for example --muted ≈ 217 33% 20%, --accent ≈ 217 33% 22% with accent-foreground = foreground), keep slate-400 as a separate text-muted token, and recheck TabsList, NotFound, badges and Switch tracks. Make NotFound use bg-background. - -**Who it hits** - -Every dark-mode user, on every hover or tap and every tab bar. - -**Independent check** - -Dark: the 404 page wrapper is bg rgb(99,114,136) (bg-muted, NotFound.tsx:7), and 'Return to Home' rgb(53,189,248) on it is 2.28:1 (screenshot). The /app/history tablist is bg rgb(99,114,136) with the active tab at rgb(15,23,41), darker than the bar. Light history tabs also sit on a heavy slate-400 bar (history-tabs.png). Every ghost or outline hover becomes rgb(2,5,19), darker than the page rgb(15,23,42) and the card rgb(29,40,57) (see motion-polish-6, which is marked a duplicate of this). Extra confirmed sub-claim: --muted-foreground equals --foreground in both themes (222 47% 11% light, 210 40% 98% dark), so the 377 text-muted-foreground usages render as full-strength body text. One token retune covers this and motion-polish-6. Light-mode --accent equals --card (210 40% 98%), so light hovers are invisible. - -**Evidence files** - -[zz-review-404-viewport.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/sweep/anon-mobile-dark/zz-review-404-viewport.png), [app_history-viewport.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/sweep/pro-tablet-dark/app_history-viewport.png), [edit-debate-settings-se-portrait.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/dialogs/edit-debate-settings-se-portrait.png), [current-debate-dropdown-320-dark.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/misc/current-debate-dropdown-320-dark.png), [contrast.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/contrast.txt), [s3-dark.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/s3-dark.json), [dark-404.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/dark-404.png), [dark-history-viewport.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/dark-history-viewport.png), [history-tabs.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/history-tabs.png) - - -### mobile-visual-7: Library source rows on phones: titles clipped to 22–114 px, cut mid-letter with no ellipsis; delete icon (28 px) sits beside the toggle - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `library-row-desktop-layout-on-phones` - -**Where** - -- src/pages/LibraryPage.tsx:79 (row flex h-[84px]) -- src/pages/LibraryPage.tsx:88 (title <a> is inline-flex + truncate, so text-overflow:ellipsis is never drawn) -- src/pages/LibraryPage.tsx:94-96 (URL badge shrink-0 steals width from the title) -- src/pages/LibraryPage.tsx:129 (delete Button h-7 w-7, 8 px from the Switch) - -**What the reviewer saw** - -Visible title width against full width: 22/258 px at 320 ('The', 'Air', 'Car'), 92/258 at 390, 114/258 at 412. Screenshots show 'Air quality after t' and 'Car-free cores: \' hard-clipped mid-glyph with no ellipsis. The date/URL line is truncated to 68 px at 320. Each row packs the icon, title, URL badge, date, folder badge, a 28x28 delete button and a 44x24 switch into 288 px. On iPad the same rows read fine. - -**Why it happens** - -A desktop single-row layout with a fixed 84 px height. Ellipsis on an inline-flex anchor does nothing, because text-overflow needs a block or inline-block container around the text. - -**Suggested fix** - -Wrap the title text in a `<span className="truncate">` inside the link, or make the link `block truncate`. Below sm, let titles take two lines (line-clamp-2), drop the fixed h-[84px], move the URL badge to the meta line, and put delete into an overflow menu or the detail sheet, keeping the switch as the only trailing control. - -**Who it hits** - -Every phone user of the Library: sources cannot be told apart. - -**Independent check** - -Reproduced as pro on staging. All 43 current sources are links. Visible title width out of the full title width: 22 px of 185-683 px at 320x568 (row is 226 px wide), 92 px at 390x664, 114 px at 412x839, and up to 512 px on iPad portrait. At 320, 43 of 43 titles are clipped; at 390 and 412, 42 of 43. Screenshots show 'Dat', 'AM', 'Data and Feedba' and 'AMRON CENTRE' cut mid-letter with no ellipsis, while the meta line (p.truncate) below does get its ellipsis. The meta line is 68 px wide at 320. Location correction: the anchor at LibraryPage.tsx:88 computes as display:flex, not inline-flex, because flex items are blockified. The conclusion holds: text-overflow cannot apply to the anonymous flex item holding the text, so the suggested `<span className="truncate">` inside the link is the right fix. The span branch (non-http origin) already truncates correctly. Lines 79, 88, 95 and 132-141 are verified. The delete button sits 8 px from the Switch (28x28 vs 44x24). That button also appears in mobile-visual-14, where I found it deletes with no confirmation. Moving delete into the detail sheet, as suggested here, would also fix that part of 14. - -**Evidence files** - -[library-rows-320-crop.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/crops/library-rows-320-crop.png), [app_library-full.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/sweep/pro-mobile-dark/app_library-full.png), [measure.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/sweep/pro-small-mobile-light/measure.json), [library-rows-small-mobile-light.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/library-rows-small-mobile-light.png), [library-rows-mobile-dark.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/library-rows-mobile-dark.png), [library-rows-iphone-light.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/library-rows-iphone-light.png), [library-measure.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/library-measure.json) - - -### mobile-visual-8: Mobile app menu: no scroll lock, no outside-tap close, and its bottom is cut off in landscape; the landing menu also ignores Escape - -**Severity:** medium · **Verdict:** confirmed · **Root cause:** `mobile-menu-inline-disclosure` - -**Where** - -- src/components/layout/AppLayout.tsx:244-266 (mobile nav inside the sticky header, in flow) -- src/index.css:423-425 (.native-mobile-menu max-height calc(100dvh - 4rem - safe-top), which ignores the ~46 px Current debate row) -- src/components/landing/Navbar.tsx:59-104 (no Escape handler, no scroll lock) - -**What the reviewer saw** - -App menu at 320x568, 568x320, 915x412 and 810x1080: wheel-scrolling with the menu open moves the page 400 px underneath (body/html overflow visible), and tapping the page outside the menu does not close it. In landscape the nav box ends at 365 px in a 320 px viewport and 457 px in a 412 px viewport. On 915x412 the Settings item sits at 381–441 px, half below the screen, and stays there because the sticky header never moves. Landing menu (Pixel 7 and SE in Hungarian): the page scrolls 500 px while it is open, and it stays open after Escape. Opening the app menu while scrolled is compensated by scroll anchoring, so there is no jump. - -**Why it happens** - -The menus are plain disclosures inside the sticky or fixed header, not overlay sheets. The height cap was written for a one-row header. - -**Suggested fix** - -Render both mobile menus as a Radix Sheet or Dialog (this gives scroll lock, outside-tap and Escape close, and a focus trap), or at least lock body scroll and add a backdrop that closes on tap. Compute the max-height from the header's real bottom (for example `max-h-[calc(100dvh-var(--header-h))]`, set from a ResizeObserver), or take the Current debate row into account. - -**Who it hits** - -All phone users of the main navigation; landscape users cannot fully see Settings. - -**Independent check** - -Measured as pro with the staging banner hidden by page-local CSS, so geometry matches production. App menu: body and html overflow stay visible. At 320x568 and 390x664, a wheel over the page moved it 400 px under the open menu. A tap outside the menu at (20,468) reached the Generate form underneath, and the menu stayed open. Escape does close the app menu (AppLayout.tsx:145-153). Landscape: the nav box ends 45 px below the viewport at both 915x412 (bottom 457) and 568x320 (bottom 365). Computed max-height is 348 px and 256 px. Even with the nav scrolled to its end, the Settings link (381-441 at 915x412, 289-349 at 568x320) stays 29 px below the screen edge. The landscape cut-off comes from index.css:424, `100dvh - 4rem`, which ignores the roughly 46 px Current debate row. That shares a cause with mobile-visual-9 (header height hard-coded), but turning the menu into a Radix Sheet removes it, so the primary key stays the disclosure one. Landing menu signed out (Pixel 7 and iPhone SE): the page scrolled 500 px with the menu open, and the menu stayed open after Escape. Navbar.tsx has no keydown handler, scroll lock or outside-tap handler. - -**Evidence files** - -[menu.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/menu/menu.json), [menu2.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/menu/menu2.json), [menu-open-mobile-land915x412-dark.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/menu/menu-open-mobile-land915x412-dark.png), [menu-open-after-scroll-small-mobile-light.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/menu/menu-open-after-scroll-small-mobile-light.png), [landing.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/landing/landing.json), [menu-open-land915x412.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/raw/menu-open-land915x412.png), [menu-open-land568x320.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/raw/menu-open-land568x320.png), [landing-menu-open-after-wheel-mobile.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/raw/landing-menu-open-after-wheel-mobile.png), [menu-header-measure.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/menu-header-measure.json) - - -### mobile-visual-14: Tap targets below 24 px or far below 44 px on touch: 16 px dialog and sheet close buttons, 28 px Library delete, 20 px footer and nav links - -**Severity:** medium (reviewer said low) · **Verdict:** confirmed · **Root cause:** `touch-targets-mouse-sized` - -**Where** - -- src/components/ui/dialog.tsx:48 and src/components/ui/sheet.tsx:63 (close X h-4 w-4, no padding: 16x16) -- src/pages/LibraryPage.tsx:129 (delete 28x28 beside the 44x24 switch) -- src/components/layout/AppLayout.tsx:300-301 (footer Terms/Privacy 41x20 and 49x20) -- src/components/landing/Navbar.tsx:46-53 (tablet nav links 20 px tall) -- Library subtitle 'Change' button 55x20; header icon buttons 40x40 - -**What the reviewer saw** - -Measured on phones: every Dialog and Sheet close button is 16x16 (dialogs.json, all cases). Library delete is 28x28 with an 8 px gap to the toggle switch, which raises the risk of accidental deletes even though deletion asks for confirmation. Footer links are 20 px tall. Most pass WCAG 2.5.8 only through the spacing exception, and all are well under the 44 px platform guideline. Touch users are the main audience. - -**Why it happens** - -shadcn defaults sized for mouse use. - -**Suggested fix** - -Give the close buttons `p-2 -m-2` (or h-10 w-10) with the icon centred. Use h-10 w-10 for the Library delete button or move it into a menu. Give footer and nav links `py-2.5` or min-h-11 on touch. - -**Who it hits** - -Phone and tablet users; mis-taps. - -**Independent check** - -Measured: the Library detail Sheet close button is 16x16. The Dialog close (dialog.tsx:48) uses the same h-4 w-4 icon with no padding, so it is also 16x16 by code. The Library row delete is 28x28, 8 px from the 44x24 Switch. The app footer Terms and Privacy links are 41x20 and 49x20. On iPad the landing nav links are 20 px tall (61x20 to 91x20). I did not measure the 'Change' button. Correction that raises the risk: the reviewer says 'deletion asks for confirmation', but the row's trash button does not. SourceRow onRemove calls handleRemoveSource (LibraryPage.tsx:410-416), which calls removeSource directly (session-store.ts:767-774) and deletes the Supabase row at once. There is no AlertDialog, and the 'Deleted' toast has no undo. The confirm dialog exists only inside the detail Sheet (LibraryPage.tsx around :1040). I did not press it, because the data is shared and seeded, so this is code evidence. A 28 px target 8 px from a toggle students tap often is therefore an irreversible mis-tap, so medium is honest rather than inflated. Fix: a confirm dialog or undo toast for the row delete, plus larger hit areas. The Library delete also appears in mobile-visual-7, whose move-delete-into-the-sheet fix would cover that part. - -**Evidence files** - -[dialogs.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/dialogs/dialogs.json), [measure.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/sweep/pro-small-mobile-light/measure.json), [landing.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/landing/landing.json), [library-measure.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/library-measure.json), [library-sheet-open-small-mobile.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/raw/library-sheet-open-small-mobile.png), [menu-header-measure.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/menu-header-measure.json), [library-rows-small-mobile-light.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/library-rows-small-mobile-light.png) - - -### motion-polish-5: Global attribute-selector micro-interactions in index.css restyle every Radix widget and replay on mount (switch bounce, checkmark redraw, sliding tab triggers, double tab indicator, dead dialog-enter) - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `global-attribute-selector-animations` - -**Where** - -- src/index.css:704-713 [role=switch][data-state=checked] > span (switch-bounce) -- src/index.css:716-723 [role=checkbox][data-state=checked] svg (check-draw) -- src/index.css:745-758 [role=menu] [role=menuitem], [data-radix-collection-item] (menu-item-in, fill both) -- src/index.css:781-800 [role=tablist] [data-state=active] ::after underline -- src/index.css:660-672 [data-state=open][role=dialog] dialog-enter (overridden, leaks fill-mode both) -- src/index.css:726-742 tooltip-in/out on [data-side] (also hits popover and select content) -- routes /app/library, /login, /app/history, Review sheet on tool pages - -**What the reviewer saw** - -/app/library: right after the switches mount, getAnimations() shows 'switch-bounce' running on every checked source toggle (at about 100ms the transform is scale(.85,1.15) translateX(20px)). So every Library visit slides each thumb from off to on with a squash, though nothing was toggled. /login and /app/history: every TabsTrigger has animationName 'menu-item-in' (fill both), and the active trigger has a 2px primary ::after underline beneath the white active pill: two indicators (screenshots). Select options also carry menu-item-in. Dialog, Sheet and Popover compute animation-name 'enter' (tailwindcss-animate wins because variants are emitted last), but fill-mode is 'both' and the timing function comes from the dead dialog-enter rule. Checked checkboxes in the Review sheet replay check-draw each time the sheet opens (code plus the sheet filmstrip). - -**Why it happens** - -The 'APPLE-LEVEL MICRO-INTERACTIONS' blocks style components through global attribute selectors (data-state, role, data-radix-collection-item). A CSS animation runs whenever a matching element is inserted, not only when state changes, and fill-mode both pins transforms afterwards. The toast rule in motion-polish-2 is the most severe case of the same pattern. - -**Suggested fix** - -Delete these blocks (index.css ~660-672, 700-800). Keep component motion in the component class lists, e.g. the switch thumb already has transition-transform, and TabsTrigger already marks the active tab with bg-background and shadow-sm. If any effect stays, scope it to a class and trigger it on state change, not on insertion. - -**Who it hits** - -Every visit to Library (all toggles bounce), every tabbed page (triggers slide in, double indicator), every Review sheet open. A small, repeated 'something moved' feeling. - -**Independent check** - -/app/library: right after the switches mount, every checked switch thumb has getAnimations() equal to ['switch-bounce'] (6 of 6 sampled), with no toggle. /app/history and /login: every tab trigger computes animation-name menu-item-in with fill both, and the active trigger has a 2px primary ::after underline (rgb(2,132,197) light, rgb(53,189,248) dark) under the white pill. The screenshot shows both indicators. Review sheet on /app/generate: the checked checkbox svgs run check-draw on open (3 of 3), and the sheet computes animation 'enter' with fill both and timing cubic-bezier(.22,1,.36,1), which leaks from the dead dialog-enter rule. Tooltip-in/out hitting popover and select content was not measured (code only). - -**Evidence files** - -[t8-misc-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/t8-misc-light.json), [login-tabs-light.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/raw/login-tabs-light.png), [history-tabs-light.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/raw/history-tabs-light.png), [t2-login-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/t2-login-light.json), [t4-overlays-light-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/t4-overlays-light-desktop.json), [s2-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/s2-desktop.json), [s5-signedout.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/s5-signedout.json), [history-tabs.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/history-tabs.png), [login-tabs-light.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/login-tabs-light.png) - - -### motion-polish-6: Ghost and outline buttons get dimmer on hover (near-black hole in dark mode, barely visible change in light) - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `theme-tokens-accent-muted-misassigned` · **Same fix as:** mobile-visual-6 - -**Where** - -- src/components/ui/button.tsx:15,17 (outline/ghost: hover:bg-accent hover:text-accent-foreground) -- src/index.css:32-33 (light --accent 210 40% 98%, --accent-foreground 215 19% 34%) -- src/index.css:101-102 (dark --accent 228 84% 4%, --accent-foreground 215 20% 65%) -- examples: strip 'Change' and 'Use once', 'More options', mic button, header Language and theme, 'Load', 'Review' - -**What the reviewer saw** - -Computed colours before and after hovering on /app/generate. Dark 'Change': text rgb(248,250,252) → rgb(148,163,184), background transparent → rgb(2,5,19), darker than the card behind (screenshot pair). Dark 'More options' and mic: bg rgb(15,23,41) → rgb(2,5,19), text → rgb(148,163,184). Light mic and 'More options': bg rgb(241,245,249) → rgb(248,250,252) (2% lightness), text rgb(15,23,41) → rgb(70,84,103). The hovered control looks less active than at rest. - -**Why it happens** - -The accent tokens are tuned as a background near the page colour with a muted foreground. shadcn uses them as the hover pair, so hover lowers text contrast instead of raising emphasis. - -**Suggested fix** - -Retune the tokens. Light: --accent around 210 40% 92% with --accent-foreground equal to --foreground. Dark: --accent lighter than --card (around 217 32% 24%) with --accent-foreground 210 40% 98%. Check the Select and Command item hovers that share the tokens. - -**Who it hits** - -Every ghost and outline button and nav hover, on every page. Most noticeable in dark mode. - -**Independent check** - -Dark mode, /app/generate, hover. 'Change': bg transparent → rgb(2,5,19), text rgb(248,250,252) → rgb(148,163,184). 'More options': bg rgb(15,23,41) → rgb(2,5,19), text dimmed the same way. The card behind is rgb(29,40,57) and the page rgb(15,23,42), so the hover is darker than both. The crop shows a black box with grey text. Header nav links (hover:text-foreground) keep their text colour but get the same near-black background. Light-mode values were not re-measured. They follow from tokens I read in code: --accent 210 40% 98% against --card 210 40% 98% (identical), and --accent-foreground 215 19% 34%. Same token fix as mobile-visual-6. - -**Evidence files** - -[hover-ghost-change-dark-desktop-a.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/raw/hover-ghost-change-dark-desktop-a.png), [hover-ghost-change-dark-desktop-b.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/raw/hover-ghost-change-dark-desktop-b.png), [hover-mic-light-desktop-a.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/raw/hover-mic-light-desktop-a.png), [hover-mic-light-desktop-b.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/raw/hover-mic-light-desktop-b.png), [t9-hover-dark-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/t9-hover-dark-desktop.json), [t9-hover-light-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/t9-hover-light-desktop.json), [s3-dark.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/s3-dark.json), [dark-hover-change-a.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/dark-hover-change-a.png), [dark-hover-change-b.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/dark-hover-change-b.png), [dark-hover-moreOptions-b.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/dark-hover-moreOptions-b.png) - - -### motion-polish-7: Cold load cycles through four different loading visuals and skeletons that don't match the page, then content pops; Settings 'Loading…' line pushes cards 52px - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `fragmented-loading-states` - -**Where** - -- index.html:144-149 (startup shell card) -- src/components/auth/RequireAuth.tsx:10-15 (full-screen Loader2 without header while auth loads) -- src/App.tsx:186-198 and src/components/loading/RouteSkeleton.tsx (home-grid and tool-form skeletons lack the Current Debate card and strip rows) -- src/pages/SettingsPage.tsx Subscription card placeholder -- routes /app, /app/generate, /app/debate, /app/settings - -**What the reviewer saw** - -Cold /app (dark): startup card 'Loading DebateLab' (2952ms) → blank page with a centred spinner and no header (3124-3191ms) → header + skeleton with a broken logo square (3269-3334ms) → content (3421ms), whose Current Debate card isn't in the skeleton, so the tool grid lands about 120px lower than its skeleton. /app/generate and /app/debate show the same sequence in light. /app/settings: the Subscription card shows a one-line 'Loading…' for about 230ms, then grows (Pro badge, Set Up Billing), pushing Global Settings and Account down 52px (CLS 0.0161; y 351 to 403, 553 to 605). - -**Why it happens** - -Each layer has its own loading UI (static shell, auth spinner, route skeleton, per-card placeholder) with different layouts, and the placeholders don't reserve the final size. - -**Suggested fix** - -While auth loads, render the same markup as the index.html startup shell, or the AppLayout chrome with the route skeleton, instead of a bare spinner. Add the strip and Current Debate card rows to the home-grid and tool-form skeletons. Give the Subscription placeholder the loaded card's height (badge row plus text line). - -**Who it hits** - -Every cold open of the app. Several flashes in about 500ms make startup feel busier and slower than it is. - -**Independent check** - -The __states timeline shows the sequence on every cold run. Unthrottled /app: startup shell (42ms) → bare spinner with no header (83ms, RequireAuth.tsx:10-15) → header plus skeleton (114ms) → content. With fresh cache and fast4g: shell 878ms → spinner 2606ms → header plus skeleton 2686ms → content 3042ms. The spinner and skeleton frames were too short for my screenshot loop, so the timeline is the proof (do not use coldseq-app-dark-2-spinner.png as spinner evidence: it shows content). /app/settings reproduced exactly: one shift at about 337ms, CLS 0.0161, with the Global Settings and Account cards moving y 351→403 and 553→605 when the Subscription 'Loading…' line (SettingsPage.tsx:200-201) expands. Not measured: the '~120px skeleton vs content' offset, because the Layout Shift API does not record node replacement. The grid y311→336 shift seen in these runs is motion-polish-1's no-debate → debate flip, not a skeleton mismatch. The broken logo square in the skeleton was not checked. - -**Evidence files** - -[filmstrip-app-cold-dark.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/filmstrip-app-cold-dark.png), [filmstrip-generate-cold-light.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/filmstrip-generate-cold-light.png), [filmstrip-settings-cold-light.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/filmstrip-settings-cold-light.png), [t3-coldload-settings-cold-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/t3-coldload-settings-cold-light.json), [s1-coldload.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/s1-coldload.json), [s7-final.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/s7-final.json), [settings-final.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/settings-final.png) - - -### motion-polish-8: Tool pages fade and slide in over ~350ms on every visit, in staggered pieces - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `route-content-entrance-animation` - -**Where** - -- src/components/ui/animated-section.tsx:10-28 (opacity-0 translate-y-2 until setTimeout, then transition-all duration-300) -- src/pages/GeneratePage.tsx:368,377 (delay 0 and 75) -- AttackPage, BrainstormPage, FactoidsPage, AskPage and the *Results components (same component) -- motion-swap on the empty state (index.css:349) - -**What the reviewer saw** - -Repeat Home → Generate navigation with the chunk already cached. The AnimatedSection wrapper has opacity 0 at 81ms after the click, 0.44 at 173ms, 0.84 at 275ms and 1.0 at about 416ms, while the empty state animates separately (motion-swap, 0 to 1 by about 366ms). The cold-load filmstrip shows the page assembling in parts: strip (619ms) → faded heading (671ms) → empty state (746ms) → form (807-993ms). - -**Why it happens** - -Entrance choreography is applied to route content itself. Content mounts hidden and becomes visible only after a timer and a 300ms transition, on every visit, not just the first. - -**Suggested fix** - -Remove AnimatedSection from route-level content, or make it a CSS-only animation of 150ms or less with no initial hidden state and no extra delay. Keep the empty state static. - -**Who it hits** - -Every tool open. It adds about 0.3-0.4s before the form is fully readable and works against 'feels instant'. - -**Independent check** - -Measured Home → Generate 3 times with the chunk cached (load average about 3). The AnimatedSection wrapper first appears at opacity 0 with translateY(8px), at 276, 186 and 143 ms after the click. It reaches opacity 1 at 626, 502 and 465 ms, a median of 502ms after the click. So route content stays hidden or fading for a median of about 320ms after it has mounted. Code: animated-section.tsx sets state in setTimeout and then applies transition-all duration-300, on every mount. Under reduced motion it shows at once (motion-reduce classes). - -**Evidence files** - -[t6-nav.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/t6-nav.json), [filmstrip-nav-home-to-generate-repeat.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/filmstrip-nav-home-to-generate-repeat.png), [filmstrip-generate-cold-light.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/filmstrip-generate-cold-light.png), [s2-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/s2-desktop.json) - - -### motion-polish-9: Hover lift, shadow and icon scale applied to nearly every clickable thing (and to non-clickable result cards); its svg rule overrides component-specific icon motion - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `motion-press-global-hover-lift` - -**Where** - -- src/index.css:296-321 (.motion-press hover translateY(-1px) plus 0 8px 20px shadow; active scale(.985); `> svg` translateY(-.5px) scale(1.04)) -- src/components/ui/button.tsx:8 (every Button) -- src/components/layout/AppLayout.tsx:44,211,300-301 (nav links, settings icon, footer text links) -- src/pages/HomePage.tsx:60,72 (tool row; arrow group-hover:translate-x-1 never applies) -- src/components/ui/section-card.tsx:33,50 + src/index.css:804-812,1087-1090 (regenerate icon: icon-spin vs hover-icon-spin vs motion-press svg) -- src/components/features/generate/GenerateResults.tsx:35, attack/AttackResults.tsx:42, factoids/FactoidsResults.tsx:45 (non-clickable cards with motion-list-item card-hover-glow inside a SectionCard that also lifts) - -**What the reviewer saw** - -Hovering a Home tool row: the row has transform matrix(1,0,0,1,0,-1) and shadow 0 8px 20px. Its ArrowRight has matrix(1.04,0,0,1.04,0,-0.5), so the intended 4px nudge right (group-hover:translate-x-1) is overridden (specificity 0,4,1 beats 0,3,0). Press on wide rows and buttons shrinks them 5.8-7.1px (sweep). Regenerate icon on a SectionCard: 'icon-spin' runs for 0.5s, then the icon ends at scale(1.04) translateY(-.5px). The rotate(90deg) from .hover-icon-spin never wins. Result argument cards: hover gives translateY(-1px) and a primary glow, but cursor:auto and no click handler. Footer 'Terms'/'Privacy' text links get the same lift and shadow block. - -**Why it happens** - -One global motion class is baked into the Button base and copied onto links, rows and icons. Its high-specificity `> svg` hover transform wins over per-component utilities. Decorative hover-lift classes are also used on read-only cards. - -**Suggested fix** - -Limit the lift and shadow to primary or filled buttons (or drop the lift and keep colour plus press). Delete the `.motion-press … > svg` transforms so components control their own icons (the HomePage arrow, hover-icon-spin). Remove motion-list-item and card-hover-glow from non-interactive result cards and SectionCard. Delete the conflicting refresh-icon rules, keeping one. - -**Who it hits** - -Constant small movement under the cursor across the whole app. Icons go slightly blurry on hover, reading content jiggles, and the arrow affordance is lost. - -**Independent check** - -Home 'Generate' row on hover: transform matrix(1,0,0,1,0,-1) and shadow 0 8px 20px. Its ArrowRight, a direct child svg of the Link, computes matrix(1.04,0,0,1.04,0,-0.5), so group-hover:translate-x-1 is overridden by `.motion-press:…:hover > svg` (index.css:318). The SectionCard regenerate button (section-card.tsx:44-53, which has hover-icon-spin) runs icon-spin and then rests at matrix(1.04,…,-0.5), so the rotate(90deg) from .hover-icon-spin never wins. Correction: the result card I hovered was the outer SectionCard. It got the card-hover-glow shadow (0 0 0 1px primary/.1, 0 8px 24px primary/.08) with cursor auto and no click handler, but it did not lift (transform identity). The 1px lift belongs to the inner motion-list-item argument cards, which I did not hover. The press-shrink widths were not re-measured. - -**Evidence files** - -[t8-misc-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/t8-misc-light.json), [home-toolrow-hover-light.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/raw/home-toolrow-hover-light.png), [results-item-hover-light.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/raw/results-item-hover-light.png), [sweep-app-desktop-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/sweep-app-desktop-light.json), [s2-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/s2-desktop.json), [home-toolrow-hover.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/home-toolrow-hover.png), [results-item-hover.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/results-item-hover.png) - - -### motion-polish-11: Keyboard focus on text inputs is a faint ~2:1 glow: a global !important rule replaces the components' focus ring - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `global-important-input-focus-style` · **Known in plan:** related to next-15 #14 (accessibility pass), not listed there specifically - -**Where** - -- src/index.css:620-623 (input:focus, textarea:focus, select:focus { border-color …/.5 !important; box-shadow … .1 !important }) -- src/components/ui/input.tsx, src/components/ui/textarea.tsx (focus-visible:ring-2 ring-ring ring-offset-2 overridden) -- /login email field, every form input or textarea - -**What the reviewer saw** - -/login, Tab to the email input: box-shadow 'rgba(2,132,197,.1) 0 0 0 3px, rgba(2,132,197,.08) 0 0 8px', border rgba(2,132,197,.5). Computed contrast of the focused border against the card: 1.97:1 light, 2.47:1 dark (below the 3:1 non-text guideline). The intended solid ring would be 3.93:1 light and 6.83:1 dark, and Select triggers and buttons in the same form do get it, so focus looks different from field to field. - -**Why it happens** - -An unscoped `!important` focus style in index.css beats the component-level focus-visible ring utilities. - -**Suggested fix** - -Delete index.css:619-623 so the input and textarea focus-visible ring applies, or keep a subtle :focus border without !important and let :focus-visible draw the ring. - -**Who it hits** - -Keyboard users on every form, e.g. motion and argument fields. The focused field is hard to spot. - -**Independent check** - -/login, Tab to the email input (:focus-visible true). Light: border rgba(2,132,197,.5) and box-shadow 'rgba(2,132,197,.1) 0 0 0 3px, rgba(2,132,197,.08) 0 0 8px'. Dark: the same with rgb(53,189,248). The blended light border against the card rgb(248,250,252) works out to about 1.9:1, matching the reviewer's 1.97:1. The 'Continue with Google' button visibly gets the solid offset ring (screenshot pair), so focus looks different from field to field. Cause: the !important rule at index.css:620-623. - -**Evidence files** - -[t2-login-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/t2-login-light.json), [login-input-focus-click-light.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/raw/login-input-focus-click-light.png), [s5-signedout.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/s5-signedout.json), [login-email-focus-light.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/login-email-focus-light.png), [login-google-focus-light.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/login-google-focus-light.png), [login-email-focus-dark.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/login-email-focus-dark.png) - - -### motion-polish-12: Focus rings are clipped inside the 'More options' panels - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `motion-expand-overflow-clips-focus` - -**Where** - -- src/index.css:394-397 (.motion-expand > * { overflow: hidden }) -- src/components/features/generate/GenerateForm.tsx:227 -- src/components/features/attack/AttackForm.tsx:128 - -**What the reviewer saw** - -/app/generate → More options → Tab to 'Case Length'. The trigger's 2px ring plus 2px offset is cut off on the left edge by the grid wrapper (overflow: hidden). 4x crop attached. Measured clip ancestor: div.grid.gap-4.pt-1.sm:grid-cols-3, overflow hidden. - -**Why it happens** - -The grid-rows expand animation needs overflow hidden on the child, but it stays hidden after the panel is open. - -**Suggested fix** - -Apply overflow: hidden only while closed or animating (e.g. `.motion-expand:not([data-open='true']) > *`), or pad the inner wrapper (p-1 -m-1), or use overflow: clip with overflow-clip-margin: 4px. - -**Who it hits** - -Keyboard users in Generate and Attack advanced options. - -**Independent check** - -/app/generate → More options → Tab. The focused 'Standard' (Case Length) trigger is at left 89, with ring box-shadow '… 0 0 0 2px, rgb(2,132,197) 0 0 0 4px'. Its first clipping ancestor is div.grid.gap-4.pt-1.sm:grid-cols-3 at left 89 with overflow hidden (index.css:394-397). The crop shows the ring missing on the left edge. - -**Evidence files** - -[more-options-focus-ring-clipped.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/raw/more-options-focus-ring-clipped.png), [more-options-focus-ring-clipped-4x.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/raw/more-options-focus-ring-clipped-4x.png), [t8-misc-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/t8-misc-light.json), [s2-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/s2-desktop.json), [more-options-focus.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/raw/more-options-focus.png) - - -### motion-polish-13: Theme switch is staggered: page snaps, cards fade over 260ms, buttons over 160ms, body over 420ms - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `theme-switch-staggered-transitions` - -**Where** - -- src/index.css:156-159 (html, body transition background-color/color 420ms) -- src/index.css:323-327 (.motion-surface, every Card, transitions background-color 260ms) -- src/index.css:517-521 (global button/a/input transition incl. background-color, color 160ms) -- src/components/layout/AppLayout.tsx:179 (bg-background wrapper with no transition) -- src/components/ThemeProvider.tsx (class swap) - -**What the reviewer saw** - -Switching to dark on /app, filmstrip and per-frame samples: at 102ms the page wrapper is already dark while the Prepare/Practice cards are light-grey slabs and the utility chips are white. The cards are still mid-grey at 238ms and finish around 300-430ms. The body colour reaches its final value at 429ms. - -**Why it happens** - -Colour transitions with different durations live on different layers, and the topmost page wrapper has none. - -**Suggested fix** - -In ThemeProvider, add a temporary class (e.g. `theme-switching`) that sets `* { transition: none !important }` across the class swap and removes it on the next frame. Also drop background-color and color from the global element transition and from html/body. - -**Who it hits** - -Anyone who toggles the theme or whose OS switches at sunset. It's a brief but visible patchwork. - -**Independent check** - -Repro: flipped the OS preference with theme 'system' (page.emulateMedia light → dark) on /app. I did not use the theme toggle, which would change the shared pro account's setting. Per-frame settle times over 3 runs: the page wrapper and header snap at 54, 26 and 53 ms. The utility chip settles at 222, 197 and 197 ms. The Prepare card settles at 337, 283 and 298 ms (median 298). The body settles at 403, 363 and 383 ms. At about 69ms the wrapper is already rgb(15,23,41) while the Prepare card is still rgba(248,250,252,.6) and the chip is white: the patchwork is real. The theme-switch-100ms.png screenshot landed after settling, so the per-frame data is the evidence. - -**Evidence files** - -[filmstrip-theme-switch-_app.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/filmstrip-theme-switch-_app.png), [t5-theme-_app.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/t5-theme-_app.json), [s4-theme-throttle.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/s4-theme-throttle.json), [s3-dark.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/s3-dark.json) - - -### motion-polish-14: Text reflows when the Inter web font arrives (FOUT) on slower networks - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `webfont-import-fout` - -**Where** - -- src/index.css:1-3 (three Google Fonts @imports with display=swap: Inter, Lora, Space Mono) -- index.html (no preload or metric-matched fallback) -- /login (also any first page) - -**What the reviewer saw** - -/login cold load with fast-4G throttling: h1 painted at 2814ms, Inter loadingdone at 3341ms, and the login card resized 516 → 496px and moved y 179 → 189 at 3323ms (CLS 0.0041). The same shift appeared unthrottled while the box was busy (t2-login loadShifts at 1266ms). On fast unthrottled runs the font arrived within about 10-80ms of first render with no shift, so this depends on the network. - -**Why it happens** - -Web fonts load through a CSS @import chain and are only requested once text renders. The system fallback has different metrics, so lines rewrap when Inter swaps in. - -**Suggested fix** - -Self-host Inter (400-700 woff2), add <link rel=preload> for the main weight, and declare a fallback @font-face (local Arial/Segoe) with size-adjust and ascent/descent overrides that match Inter. Don't block on Lora or Space Mono. - -**Who it hits** - -First visits on school wifi or mobile data. The card and its headings jump about 10-20px half a second after appearing. - -**Independent check** - -Cold /login, fresh browser and cache, fast4g, 3 runs. FCP 880, 804 and 832 ms. Inter finished loading (loadingdone) at 3387, 3247 and 3295 ms. Each time the sign-in card shifted y 179→189 and h 516→496 within a few ms of that event, CLS 0.0041. An unthrottled run had the font at 442ms and no shift. The Google Fonts CSS arrives by about 850ms, but the woff2 files only start once text renders (an @import chain, index.css:1-3). Low is right: one 10px jump on slow first visits. - -**Evidence files** - -[t10-login-cold-fast4g.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/t10-login-cold-fast4g.json), [filmstrip-login-cold-fast4g.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/filmstrip-login-cold-fast4g.png), [t2-login-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/t2-login-light.json), [s5-signedout.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/s5-signedout.json) - - -### motion-polish-16: Loading spinners freeze under prefers-reduced-motion - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `reduced-motion-blanket-reset` - -**Where** - -- src/index.css:1093-1105 (*, *::before, *::after { animation: none !important; transition: none !important }) -- src/components/auth/RequireAuth.tsx:13, src/components/ui/loading-spinner.tsx, Loader2 in buttons, .motion-loading-bar, typing dots - -**What the reviewer saw** - -With reducedMotion 'reduce', an element with class animate-spin computes animationName 'none'. Everything else checked honours reduced motion correctly: no dashed replay, motion-press transform none, AnimatedSection visible at once, landing aurora, orbs and hero static. - -**Why it happens** - -The blanket reduced-motion reset also stops essential progress indicators. - -**Suggested fix** - -Exempt spinners and progress (e.g. `.animate-spin { animation-duration: 1.5s !important }` inside the media query, or swap them for a non-moving 'Loading…' label). - -**Who it hits** - -Reduced-motion users see a static spinner during auth and generation, which reads as a hang. - -**Independent check** - -With reducedMotion 'reduce' (matchMedia true), an element with class animate-spin computes animationName 'none'. The cause is the blanket rule `*{animation:none !important}` at index.css:1093-1105. It applies to RequireAuth's Loader2 and every button spinner. - -**Evidence files** - -[t7-reduced-motion.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/t7-reduced-motion.json), [filmstrip-landing-reduced-motion-load.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/filmstrip-landing-reduced-motion-load.png), [s5-signedout.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/s5-signedout.json) - - -### motion-polish-18: Crossfire and Sparring apply motion-swap (transform + blur) to the whole page container for exactly one render on phase change - -**Severity:** low · **Verdict:** cannot-verify · **Root cause:** `phase-change-one-render-motion-class` - -**Where** - -- src/pages/CrossfirePage.tsx:300-305 (phaseChanged computed from a ref) → 637,861,873,912 -- src/pages/SparringPage.tsx:48-52 (viewChanged) → 311,327,336 - -**What the reviewer saw** - -Code-only, not reproduced (needs AI calls). phaseChanged is true only on the render where the phase changes. The effect updates the ref, so the next re-render (Crossfire re-renders on its 'now' clock and on typing) removes the class mid-animation, cutting the 260ms entrance short with a snap. While it runs, filter: blur(2px) and transform sit on the whole page container, blurring the page and making it the containing block for fixed or sticky descendants. - -**Why it happens** - -An entrance animation is toggled by a one-render flag instead of being keyed to the phase. - -**Suggested fix** - -Key the inner content by phase (key={state.phase}) and use a light CSS entrance without filter on the content block only, or drop it. - -**Who it hits** - -Students moving between Crossfire and Sparring phases may see a blur flash or a cut-off transition. - -**Independent check** - -I could not reproduce this at runtime. Reaching a Crossfire or Sparring phase change needs AI calls, and my budget was 0. The code supports the mechanism. CrossfirePage.tsx:300-305 computes phaseChanged from a ref that an effect updates, so 'motion-swap' is present for exactly one render. On entering 'active', the effect at :421-426 calls setNow(Date.now()) right away, so the class is probably removed one render after it was added: either a one-frame blank (motion-swap frame 0 is opacity 0) or a cut-short entrance. SparringPage.tsx:46-52 uses the same pattern on the page container (:311, :327, :336). The claim that a transform or filter makes the container the containing block for fixed descendants is correct CSS, but I did not check whether those pages have fixed children. To verify with AI budget: start a Crossfire round and record setup → active at 60fps. - - -### mobile-visual-9: Two-row 110 px sticky header on phones and tablets: 27–35% of a landscape or keyboard-open viewport, and anchors land 30 px underneath it (scroll-margin 80 px) - -**Severity:** low (reviewer said medium) · **Verdict:** confirmed · **Root cause:** `mobile-header-height-hardcoded` - -**Where** - -- src/components/layout/AppLayout.tsx:178-238 (64 px header row plus the Current debate row below lg, sticky) -- src/index.css:468-470 ([id] { scroll-margin-top: 5rem }) -- src/pages/GeneratePage.tsx:53-59 and :388 (results anchor id=generate-results scrolled to block:start after each generation) - -**What the reviewer saw** - -The header is 110 px on every width below 1024. That is 27% of a 915x412 landscape viewport and 34% of 568x320. With a simulated keyboard (viewport 568→312) it takes 35% of the visible area. Crossfire's textarea and Generate's motion input, when revealed from below, end up at top 79–80 px, under the header bottom at 110 (coveredByHeader true). An [id] element scrolled to block:start lands at top 80 px while the header ends at 110 px, so Generate's results heading will sit 30 px under the header after every generation on phones and tablets. - -**Why it happens** - -The scroll-margin constant assumes the desktop one-row header. The Current debate row is permanently sticky on small screens. - -**Suggested fix** - -Expose the header height as a CSS variable (set from a ResizeObserver in AppLayout) and use `scroll-margin-top: calc(var(--header-h) + 0.5rem)` and `scroll-padding-top` on html. On phones, let the Current debate row scroll away (only the 64 px row sticky), or hide it while scrolling down. In landscape (max-height: 500px), make the header non-sticky. - -**Who it hits** - -All phone and tablet users while typing, in landscape, and when Generate jumps to results. - -**Independent check** - -Geometry reproduced with the banner hidden, so it matches production. The header is 110 px at 320x568, 390x664, 568x320 and 915x412. That is 19.4%, 16.6%, 34.4% and 26.7% of the viewport. [id] elements have scroll-margin-top 80px (index.css:468-470). After #generate-more-options.scrollIntoView({block:'start'}), its top is at 80 while the header ends at 110: 30 px covered at every size. #generate-results (GeneratePage.tsx:53-59, :388) is the only [id] the app itself scrolls to with block:'start'. So the defect a user sees is the results' top 30 px sitting under the translucent header after each generation on screens below lg. I could not trigger a real generation (AI budget 0). The keyboard-reveal claim is inference, not proven in a browser: headless Chromium has no virtual keyboard, and the reviewer's 79-80 px figures also came from scrollIntoView on [id] elements. Downgraded from medium: in portrait the header takes 17-19% of the screen, and the landscape share is real but a niche case. - -**Evidence files** - -[keyboard.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/keyboard/keyboard.json), [crossfire-argument-keyboard-reveal-from-below.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/keyboard/crossfire-argument-keyboard-reveal-from-below.png), [anchor-under-sticky-header-iphone.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/keyboard/anchor-under-sticky-header-iphone.png), [app_generate-viewport.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/sweep/pro-mobile-land915x412-dark/app_generate-viewport.png), [anchor-under-header-port390x664.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/raw/anchor-under-header-port390x664.png), [menu-header-measure.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/menu-header-measure.json) - - -### mobile-visual-10: Fixed-height nested scroll lists (Library 600 px, History 500 px) waste space when short and trap swipes on phones when long; History hides half its tabs off-screen - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `fixed-height-inner-scroll-panels` - -**Where** - -- src/pages/LibraryPage.tsx:39 and :880 (SOURCE_LIST_HEIGHT = 600, overflow-y-auto) -- src/pages/HistoryPage.tsx:155, 219, 249, 279, 315 (ScrollArea h-[500px]) -- src/pages/HistoryPage.tsx:137 (TabsList w-full justify-start overflow-x-auto, no scroll affordance) - -**What the reviewer saw** - -With 1–3 items both cards show 300–450 px of empty card on phones (screenshots). The 600 px Library list is taller than the visible area below the header on an SE (458 px) and an iPhone 14 (554 px). Once the shared pro account had 18 sources, the list became an inner scroller filling the whole screen, so page swipes start inside it. History tabs at 320: scrollWidth 635 versus clientWidth 288, with Brainstorms, Factoids and Sparring fully hidden; 'Brainsto' is cut at the edge and there is no fade or arrow hint. This is geometry and code evidence; real swipe chaining was not driven. - -**Why it happens** - -Desktop-style fixed-height panels inside a page that itself scrolls. - -**Suggested fix** - -Use `max-h-[600px]` (desktop only) and no inner scroll below md, so the list grows with the page, or paginate or virtualise. Let History tabs wrap into two rows on phones, or use a Select, or add edge fade masks plus scrollIntoView on the active tab. - -**Who it hits** - -Phone users of Library and History, especially heavy users with many items. - -**Independent check** - -Code: LibraryPage.tsx:880 uses style={{height: 600}}, a fixed height rather than a max, and HistoryPage.tsx:155/219/249/279/315 use ScrollArea h-[500px]. Library today has 43 sources (scrollHeight 3956), so the list is a 600 px inner scroller. Only 458 px are visible below the header at 320x568 and 554 px at 390x664. History tabs: scrollWidth 635 vs clientWidth 288 at 320, 358 at 390 and 380 at 412. At 320, Brainstorms, Factoids and Sparring are hidden, with no mask (mask-image none) and no arrow hint. The empty-space claim holds in code (fixed heights), but I could not reproduce it today: the account now has 43 sources, about 554 px of cases and 698 px of attacks, and Chats shows an empty state without a ScrollArea. New, related observation: at 320 the History cards overflow the Radix ScrollArea viewport (scrollWidth 301 vs 222). The card right borders are cut 79 px and the Load buttons 14 px (visible in history-small-mobile-dark.png). The overflow is 8 px at 390 and none at 412. Fix it together with this finding (min-w-0 or w-full on the cards, or no ScrollArea below md). Swipe trapping was not driven, so that part is geometry only. - -**Evidence files** - -[app_history-full.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/sweep/pro-mobile-dark/app_history-full.png), [app_library-full.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/sweep/pro-small-mobile-light/app_library-full.png), [app_library-viewport.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/sweep/pro-tablet-land1080x810-dark/app_library-viewport.png), [misc.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/misc/misc.json), [history-small-mobile-dark.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/raw/history-small-mobile-dark.png), [pages-measure.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/pages-measure.json), [misc-measure.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/misc-measure.json), [history2-measure.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/history2-measure.json), [library-measure.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/library-measure.json) - - -### mobile-visual-11: Theme switch is out of sync: html/body fade for 420 ms while text and cards switch instantly (white text on a light page for about 100 ms) - -**Severity:** low · **Verdict:** partly · **Root cause:** `theme-swap-mixed-transitions` - -**Where** - -- src/index.css:156-158 (html, body transition background-color 420ms) -- src/components/ThemeProvider.tsx:10-24 (class swap) -- src/lib/stores/settings-store.ts:308-323 (server theme applied after sign-in hydration) - -**What the reviewer saw** - -Emulated an OS switch light→dark on /app (theme 'system'). At 54 ms the h1 colour is already rgb(248,250,252) while the body is still rgb(241,245,249). It is rgb(89,96,110) at 141 ms and does not reach rgb(15,23,42) until about 500 ms. Text that sits directly on the page background (page h1s, the Utilities heading) is unreadable for the first frames. The same fade runs when a user picks a theme, and once per new device when the server-saved theme differs from the local default (code evidence). - -**Why it happens** - -Only html and body have a colour transition; everything else switches instantly. - -**Suggested fix** - -Remove the html/body background transition, or disable all transitions for one frame while swapping theme classes (add a `.theme-switching * { transition: none !important }` class around the swap in ThemeProvider). - -**Who it hits** - -Anyone switching theme or following the OS auto dark mode at sunset. - -**Independent check** - -The symptom is real, but the reviewer's mechanism is wrong. I emulated an OS light-to-dark switch with theme 'system' and sampled every animation frame, 3 runs each (identical), on /login signed out and on /app/library as pro (server theme is 'system'). The html/body 420 ms fade is not visible, because an opaque bg-background element covers the body. On /app the h1's opaque ancestor is the AppLayout root div (native-safe-screen bg-background), which switches instantly, and h1 contrast stays at 17.08 the whole time. What actually goes out of sync is the per-component transitions. .motion-surface (every Card, index.css:323-327) fades background-color over 260 ms. The global `button, a, input, select, textarea, [role=button]` rule (index.css:516-520) fades color and background over 160 ms. .motion-field and .motion-list-item do the same. Text inside those surfaces snaps. On /login the card h1 contrast was 1.0 at frame 0, 1.73 at 15 ms, 2.96 at 31 ms and 4.78 at 48 ms, and the 'Send code' button hit 1.19. The frame strip shows the heading and placeholder white-on-white on a still-white card while the page is already dark. On /app, buttons and the stat numbers lag for about 100-200 ms (theme-app-frames.png). Fix correction: removing the html/body transition (the reviewer's first option) changes nothing visible. Only the second option works: suppress all transitions for one frame around the class swap in ThemeProvider.tsx:10-24 and :31-35. `where` should point at index.css:323-327 and :516-520, not only :156-158. - -**Evidence files** - -[theme.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/theme/theme.json), [theme-switch-150ms.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/theme/theme-switch-150ms.png), [theme-login-frames-25-107ms.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/theme-login-frames-25-107ms.png), [theme-app-frames.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/theme-app-frames.png), [theme-measure.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/theme-measure.json), [theme-frames-login](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/theme-frames-login) - - -### mobile-visual-12: Flowchart is cramped on phones: 4 of 6 nodes clipped at 320 (minZoom 0.5), a 400 px canvas fills landscape screens and traps swipes, and the controls are 26 px - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `flowchart-desktop-canvas-defaults` - -**Where** - -- src/pages/FlowchartPage.tsx:749 (h-[50vh] min-h-[400px]) -- src/pages/FlowchartPage.tsx:750-763 (ReactFlow fitView, default minZoom 0.5) -- src/pages/FlowchartPage.tsx:132 (Controls buttons 26x27) - -**What the reviewer saw** - -At 320x568 the zoom sits at 0.5, the default minimum, and 4 of 6 nodes extend past the canvas edges (fitView cannot fit them). In 568x320 landscape the canvas is 400 px tall while only 210 px is visible below the header, so one-finger drags pan the canvas and the page can only be scrolled by dragging the header. Control buttons measure 26x27 px. - -**Why it happens** - -Desktop canvas sizing and React Flow defaults. - -**Suggested fix** - -Set minZoom to about 0.2 and fitViewOptions padding. Size the canvas `h-[min(60dvh,520px)]`, with no 400 px minimum in landscape. Set panOnDrag={[1,2]} or preventScrolling={false} on touch so page scrolling still works, with a 'tap to interact' overlay. Make control buttons at least 40 px on touch. Hide the MiniMap below sm (see mobile-visual-4). - -**Who it hits** - -Phone users of Flowchart. - -**Independent check** - -At 320x568 (pro, 6 nodes) the viewport transform is matrix(0.5,...), which is the default minZoom. 4 of 6 nodes extend past the canvas edge, but only by about 4 px each (left -4, right 322 on a 318 px canvas): the outer node borders are cut, the content is not. All four control buttons are 26x27. At 568x320 the canvas is 400 px tall with 210 px visible below the 110 px header. ReactFlow is mounted with no panOnDrag or preventScrolling props (FlowchartPage.tsx:750-763), so dragging on the canvas pans it; swipe trapping is inferred from code, not driven. The dark screenshot shows something the reviewer did not list: the Controls render in React Flow's light theme on the dark canvas and overlap the Rebuttal node at 320. The 200x150 white MiniMap on a 318 px wide canvas belongs to mobile-visual-4 and is not re-rated here. - -**Evidence files** - -[misc.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/misc/misc.json), [flowchart-canvas-568x320.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/misc/flowchart-canvas-568x320.png), [flowchart-canvas-320x568.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/misc/flowchart-canvas-320x568.png), [flowchart-canvas-small-mobile-dark.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/raw/flowchart-canvas-small-mobile-dark.png), [flowchart-landscape-small-mobile-light-568x320.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/raw/flowchart-landscape-small-mobile-light-568x320.png), [pages-measure.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/pages-measure.json) - - -### mobile-visual-13: Low-contrast hard-coded colours: Flowchart legend and nodes, Settings tier badge - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `hardcoded-palette-low-contrast` - -**Where** - -- src/pages/FlowchartPage.tsx:695-707 (legend text-emerald-600 / red-600 / blue-600 / amber-600, no dark: variants) -- src/pages/FlowchartPage.tsx:96-99 and :166-190 (white text on emerald-500 / amber-500 nodes; text-green-100 / yellow-100 strength badges) -- src/pages/SettingsPage.tsx:38-41 and :206 (text-white on bg-amber-500 Pro badge, bg-green-500 legacy badge) - -**What the reviewer saw** - -Calculated (contrast.txt). Dark legend on bg-muted/30 over card: blue-600 2.09:1, red-600 2.24:1, emerald-600 2.87:1, amber-600 3.39:1; light emerald-600 is 2.84:1. Node text: white on emerald-500 2.54:1, white on amber-500 2.15:1, and node descriptions (white/80 on emerald) 2.1:1. Settings 'Pro' badge, white on amber-500: 2.15:1. All are visible in the dark Flowchart and Settings screenshots. - -**Why it happens** - -Hard-coded Tailwind palette shades chosen for light backgrounds, with white text on mid-tone fills. - -**Suggested fix** - -Use 700/800 shades in light and 300/400 in dark for legend text (add dark: variants). Use dark text (slate-950) on emerald, amber and sky node fills, or darken the fills to 600/700. Use text-amber-950 on the amber badge. - -**Who it hits** - -Readability for all users, worst in dark mode and in sunlight on phones. - -**Independent check** - -Computed from live computed styles, compositing bg-muted/30 over the card. Dark legend: blue-600 2.09, red-600 2.23, emerald-600 2.86, amber-600 3.39. Light legend: emerald-600 2.83, amber-600 2.40, red-600 3.63, blue-600 3.89. Nodes in both themes: white on emerald-500 2.54; description at opacity-80 2.10; strength badges green-100, yellow-100 and red-100 on emerald 2.31, 2.36 and 2.08. White on red-500 is 3.76 and on blue-500 3.68. In light mode the Thesis node text (primary-foreground on primary) is 2.93. The Settings 'Pro' badge (bg-amber-500 text-white, SettingsPage.tsx:38-41 and :206) is 2.15 in both themes. No 'dropped' (amber) node is on pro's canvas, but white on amber-500 is the same 2.15. Line correction: the legend is at FlowchartPage.tsx:700-709, not 695-707. - -**Evidence files** - -[contrast.txt](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/contrast.txt), [flowchart-dark-320-canvas-crop.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/crops/flowchart-dark-320-canvas-crop.png), [app_settings-full.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/sweep/pro-small-mobile-dark/app_settings-full.png), [contrast-measure.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/contrast-measure.json), [flowchart-legend-dark.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/raw/flowchart-legend-dark.png), [flowchart-canvas-small-mobile-dark.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/raw/flowchart-canvas-small-mobile-dark.png), [settings-pro-badge-dark.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/raw/settings-pro-badge-dark.png) - - -### mobile-visual-15: Safe areas, browser chrome and PWA navigation are not phone-ready (code evidence, not browser-proven) - -**Severity:** low · **Verdict:** confirmed · **Root cause:** `web-safe-area-and-pwa-chrome-unset` - -**Where** - -- index.html:5 (viewport-fit=cover) -- src/index.css:161-166 (env(safe-area-inset-*) defined only under .capacitor-native, so all --native-safe-* are 0 on the web) -- src/components/OfflineIndicator.tsx:12 (fixed bottom-4, no safe-area); src/components/landing/Navbar.tsx:27 (fixed full-width nav) -- index.html:16 (theme-color fixed #0f172a, no media variants); public/manifest.webmanifest:7-9 (standalone, background_color and theme_color #0f172a); no `color-scheme` declared anywhere -- src/pages/TermsPage.tsx, PrivacyPage.tsx, LoginPage.tsx, SourcesPage.tsx (no link back to the app or home) - -**What the reviewer saw** - -Code evidence, not browser-proven (headless Chromium has no notch, Safari toolbar or standalone mode). With viewport-fit=cover, Safari on notched iPhones stops letterboxing in landscape and env(safe-area-inset-left/right) is about 47 px. On the web no element pads for it, so the header logo, px-4 content and the fixed landing nav sit under the sensor housing, and the fixed offline pill and footer can sit under the home indicator. theme-color is always dark navy, so the Android and iOS browser bar is dark on the light theme, and the PWA splash is dark for light users. Without `color-scheme: light dark`, native controls, autofill and scrollbars stay light in dark mode. The installed PWA is standalone (no back button on iOS), yet the in-app footer links to /terms and /privacy, and those pages, like /login and /expocit/sources, have no in-page way back (grep finds no Link or href). - -**Why it happens** - -Safe-area handling was built only for the Capacitor shell; the web build shares the cover viewport without the padding. - -**Suggested fix** - -Define the --native-safe-* variables from env() on :root for the web too (or drop viewport-fit=cover), and pad the fixed bottom elements with env(safe-area-inset-bottom). Add `<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f1f5f9">` plus a dark variant, and update it from ThemeProvider. Set `color-scheme: light` and `.dark { color-scheme: dark }`. Add a small header or back link to Terms, Privacy, Login and Sources. - -**Who it hits** - -iPhone landscape users, PWA users and dark-mode users; cosmetic to blocking (dead ends in a standalone PWA). - -**Independent check** - -Confirmed by code only, by design: headless Chromium has no notch, Safari toolbar or standalone mode. index.html:5 sets viewport-fit=cover. --native-safe-* are defined only under .capacitor-native (index.css:161-166), and every use falls back to 0px (index.css:399-424), so the web build never pads for env(safe-area-inset-*). OfflineIndicator.tsx:12 is fixed bottom-4 with no safe-area padding. The landing Navbar.tsx:27 is fixed full width. There is one theme-color, #0f172a (index.html:16), and the manifest sets background and theme colour #0f172a with display standalone. color-scheme is declared nowhere (grep of src and index.html finds only matchMedia). TermsPage and PrivacyPage (25 lines each) and LoginPage have no Link or href back. SourcesPage's only href is the external source URL. RouteContent (App.tsx:185-199) adds no header. next-15 #15 covers manifest icons and the offline page, not theme-color, safe areas, color-scheme or back links, so this is not knownInPlan. Practical impact on iOS standalone (no back control) is plausible, but I could not prove it. - -**Evidence files** - -[terms-viewport.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/sweep/anon-small-mobile-dark/terms-viewport.png), [offline-indicator-320-dark.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/misc/offline-indicator-320-dark.png) - - -### motion-polish-10: transition-colors cancels the lift easing (transform snaps in one frame) and stagger-enter pins History rows - -**Severity:** nit · **Verdict:** confirmed · **Root cause:** `tailwind-transition-overrides-motion-classes` - -**Where** - -- src/pages/LibraryPage.tsx:79 (transition-colors + motion-list-item) -- src/pages/HistoryPage.tsx:158 (transition-colors + motion-list-item inside .stagger-enter) -- src/components/layout/AppLayout.tsx:300-301 (motion-press + transition-colors footer links; code-derived) -- src/components/features/sparring/SparringSetup.tsx:129,160 (motion-press + transition-colors cards; code-derived) -- src/index.css:546-557 (.stagger-enter > * animation fill both) - -**What the reviewer saw** - -Library source row hovered: transition-property is 'color, background-color, border-color, text-decoration-color, fill, stroke', and transform goes from none to matrix(…,-1) within one frame (13ms), so it jumps with no easing. History row: computed animation 'stagger-item' fill both, and the transform stays matrix(1,0,0,1,0,0) while hovered, so the intended lift never shows. Footer links and SparringSetup cards have the same class pairing (not measured; the footer sample was off-screen). - -**Why it happens** - -Tailwind transition-* utilities come later in the cascade and replace the motion classes' transition-property, so transform is no longer transitioned. A fill-both entrance animation overrides any later transform. - -**Suggested fix** - -Where a motion-* class is present, drop transition-colors (motion classes already transition colours), or use transition-[color,background-color,border-color,transform]. Give stagger-item fill-mode backwards instead of both. - -**Who it hits** - -A 1px jump instead of a glide on Library and History rows, footer links and Sparring setup cards. - -**Independent check** - -Library source row: transition-property is 'color, background-color, border-color, text-decoration-color, fill, stroke' (transition-colors wins over .motion-list-item). Sampled per frame on hover, transform goes from none at 36ms straight to matrix(…,-1) at 57ms with no in-between values. History case row: animation stagger-item with fill both, and transform stays matrix(1,0,0,1,0,0) while :hover is true, so the lift never appears. Footer and SparringSetup were not measured (code only). - -**Evidence files** - -[t8-misc-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/t8-misc-light.json), [s2-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/s2-desktop.json) - - -### motion-polish-15: Generate button shimmer animates forever; under reduced motion a static white gradient snaps over it on hover - -**Severity:** nit · **Verdict:** confirmed · **Root cause:** `generate-button-infinite-shimmer` - -**Where** - -- src/components/features/generate/GenerateForm.tsx:286 (span … -translate-x-full group-hover:translate-x-0 animate-[shimmer_3s_infinite]) - -**What the reviewer saw** - -In every trackMovement sample on /app/generate, the span moves 274-775px with no interaction (an infinite 3s loop). With reducedMotion 'reduce' the animation is none, so group-hover:translate-x-0 snaps the white/10 gradient 390px across the button on hover. - -**Why it happens** - -An infinite decorative animation sits on an idle control, and its fallback hover transform is unguarded. - -**Suggested fix** - -Remove the shimmer span, or play one iteration on hover and add motion-reduce:hidden. - -**Who it hits** - -Constant compositing on the Generate page and a distracting glint, plus a hover artifact for reduced-motion users. - -**Independent check** - -The span with `animate-[shimmer_3s_infinite]` on /app/generate computes animation shimmer, 3s, infinite, and is mid-translate while idle. Not re-verified: the reduced-motion hover snap. It follows from the code, since the blanket reset sets animation none and leaves `group-hover:translate-x-0` unguarded. - -**Evidence files** - -[t7-reduced-motion.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/t7-reduced-motion.json), [sweep-tools1-desktop-light.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/sweep-tools1-desktop-light.json), [s2-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/s2-desktop.json) - - -### motion-polish-17: Review sheet takes 500ms to slide in (dialogs and popovers take 150ms) - -**Severity:** nit · **Verdict:** confirmed · **Root cause:** `sheet-default-500ms-duration` - -**Where** - -- src/components/ui/sheet.tsx:33 (data-[state=open]:duration-500, closed 300) - -**What the reviewer saw** - -Computed animation on the open Review sheet: name 'enter', duration 0.5s. The filmstrip shows it still sliding at 270ms. The Dialog, Popover and Select open in 0.15s. - -**Why it happens** - -shadcn's default sheet timings were never tuned. - -**Suggested fix** - -Use data-[state=open]:duration-200 or 250 and data-[state=closed]:duration-150. - -**Who it hits** - -Every Review sheet open on tool pages feels sluggish compared with the rest of the UI. - -**Independent check** - -The open Review sheet on /app/generate computes animation 'enter' at 0.5s, from sheet.tsx data-[state=open]:duration-500 (closed 300). Dialog and popover timings were not re-measured. Their code uses duration-200 in dialog.tsx. - -**Evidence files** - -[t4-overlays-light-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/t4-overlays-light-desktop.json), [filmstrip-sheet-open-close-light.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/motion-polish/filmstrip-sheet-open-close-light.png), [s2-desktop.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-1/s2-desktop.json) - - -### motion-polish-19: framer-motion's MotionConfig is imported eagerly in App.tsx, so framer-motion ships in the entry chunk for every route - -**Severity:** nit (reviewer said low) · **Verdict:** partly · **Root cause:** `motionconfig-in-app-shell` - -**Where** - -- src/App.tsx:7,276 (import { MotionConfig } from 'framer-motion'; <MotionConfig reducedMotion='user'>) -- staging entry /assets/index-B7gkXSCc.js (1,204,196 bytes uncompressed) - -**What the reviewer saw** - -The staging entry chunk contains framer-motion's MotionConfig context code (createContext({transformPagePoint…, isStatic:!1, reducedMotion:'never'}), the motion-prop set 'animate','exit','variants'…, and the getVelocity MotionValue check), and wraps the app in it with reducedMotion:'user'. framer-motion is otherwise used only by landing sections and CrossfirePage (motion), plus useReducedMotion in GeneratePage and AskChat. I didn't measure how many bytes it adds (the speed agent's lane). - -**Why it happens** - -A provider from an animation library sits in the app shell. - -**Suggested fix** - -Remove MotionConfig from App.tsx (the CSS reduced-motion block plus useReducedMotion already cover the user preference), or move it inside LandingPage and CrossfirePage so framer-motion stays in those lazy chunks. - -**Who it hits** - -Extra JavaScript to parse on every first load, including signed-in tool pages that never use framer-motion. - -**Independent check** - -MotionConfig is in the entry chunk: staging index-B7gkXSCc.js contains 'transformPagePoint' and reducedMotion:'user'. The impact claim is overstated. A scratch build with sourcemaps (vite build --outDir scratch), plus the repo's bundle-graph plugin, shows only 7 tiny framer-motion and motion-dom modules in the entry (MotionConfig, MotionConfigContext, use-constant, filter-props, valid-prop, resolve-transition, is-motion-value). They total about 1,591 bytes of the 1,193,129-byte entry. The rest of framer-motion stays in lazy chunks. Removing or moving it is a trivial cleanup, not a load-time win. - - -### mobile-visual-16: Small phone polish: model picker label wraps ('Claude Sonnet / 5'), Timer shows keyboard shortcuts on touch, landscape login card is flush with the screen edges - -**Severity:** nit · **Verdict:** confirmed · **Root cause:** `phone-polish-fixed-widths-desktop-hints` - -**Where** - -- src/components/features/ask/AskChat.tsx:636 (ModelSelector className="w-44") -- src/pages/TimerPage.tsx:248 onward (Space / R / Arrows hints rendered on every device) -- src/pages/LoginPage.tsx:150 (min-h-screen flex items-center, px-4 only, no vertical padding) - -**What the reviewer saw** - -The Ask model select shows 'Claude Sonnet' on one line and '5' on a second inside a fixed 176 px trigger on every device, including iPad landscape. The Timer card on phones lists 'Space to start/stop, R to reset, Arrows to change speaker', which do nothing on touch. At 568x320 and 915x412 the login card's top and bottom borders touch the screen edges. - -**Why it happens** - -Fixed widths and desktop-only hints. - -**Suggested fix** - -Use `min-w-44 w-auto` or truncate the model label. Hide the shortcut hints under `@media (hover: none)` (or `hidden [@media(hover:hover)]:block`). Add `py-6` to the login wrapper. - -**Who it hits** - -Small but visible on every phone. - -**Independent check** - -Ask: the model trigger is 176x40 (ModelSelector className w-44, AskChat.tsx:636), and the screenshot shows 'Claude Sonnet' on one line and '5' on the next. SelectTrigger's `[&>span]:line-clamp-1` (select.tsx:19-21) does not clamp it, because the rendered item contains nested flex spans that wrap. Timer: with (hover: none) and (pointer: coarse) both true on the phone emulation, all three hints ('Space to start/stop', 'R to reset', 'Arrows to change speaker', TimerPage.tsx:378-382) render with no media gating. Login: at 568x320 and 915x412 the card runs from y=0 to 486 and the document is 486 px tall, so the card borders touch the top and bottom edges (LoginPage.tsx:150 has px-4 and no vertical padding). Screenshot taken with the staging banner hidden, matching production. - -**Evidence files** - -[app_ask-viewport.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/sweep/pro-tablet-land1080x810-dark/app_ask-viewport.png), [app_timer-full.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/sweep/pro-mobile-dark/app_timer-full.png), [login-full.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/mobile-visual/sweep/anon-small-mobile-land568x320-dark/login-full.png), [ask-model-trigger-small-mobile-dark.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/raw/ask-model-trigger-small-mobile-dark.png), [login-568x320-full.png](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/raw/login-568x320-full.png), [pages-measure.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/pages-measure.json), [misc-measure.json](/home/codex/Projects/DebateLab/.verify/ultracode-review-2026-10-08/verify-visual-2/misc-measure.json) - diff --git a/plans/2026-10-09-design-audit.md b/plans/2026-10-09-design-audit.md deleted file mode 100644 index b2dfb5bc5..000000000 --- a/plans/2026-10-09-design-audit.md +++ /dev/null @@ -1,223 +0,0 @@ -# Design audit: Apple design and Emil Kowalski standards - -**Status:** Shipped. Audit of `main` at `56a94d7` on 2026-10-09, fixed in the four-PR stack in [Suggested order of work](#suggested-order-of-work). Rows marked ✅ are done, with the PR that did them; no rows were skipped. - -## What this is - -A full UI audit of DebateLab against Emil Kowalski's design-engineering skills (`apple-design`, `emil-design-eng` and `review-animations` from [emilkowalski/skills](https://github.com/emilkowalski/skills) at `e8a175d`). `apple-design` is Apple's WWDC guidance on fluid interfaces, materials, typography and the eight design principles, translated for the web. The other two supply the motion rules and values: easing curves, duration budgets, press feedback, transform origins and interruptibility. - -Four Opus reviewers read every page, every component in `components/ui/` and elsewhere, and the whole of `src/index.css`. Then the real app ran locally (`verify-debatelab`, fake provider). A Playwright sweep covered 20 routes on desktop (1440×900) and iPhone 13, in light and dark. A probe opened every overlay type and read real durations, easings and transform origins from `document.getAnimations()`. Further checks covered reduced motion, `prefers-contrast: more`, and Hungarian at 375px. Evidence is in `.verify/artifacts/audit-20261009T052123-design/` (gitignored): 118 screenshots, `metrics.json`, `probe.json`, and five screen recordings. - -Verdict under `review-animations`: **Block.** Two of the feel-breaking issues are system-wide bugs, not taste: toasts can't stack or leave, and secondary text has no colour hierarchy. Both are cheap to fix. - -## The short version - -The product structure is sound. The motion tokens are good (`--motion-ease-fluid` is a strong ease-out), `MotionConfig reducedMotion="user"` wraps the app, routes prefetch on intent, and the voice, Sparring and Crossfire work is careful. The weak spots are five systemic problems: - -1. **A global CSS layer overrides the shared UI components.** About 600 unlayered lines in `src/index.css` (roughly 491–847) target ARIA roles, Radix `data-state` attributes and Lucide icon class names. They override Sonner, Select, Progress, Tabs, Switch, focus rings and Tailwind's own `.tabular-nums`. Most of the motion bugs below come from this layer. Deleting most of it and moving what's worth keeping into each component's `cva` classes fixes the largest share of findings for the least work. -2. **The colour tokens are miswired.** `--muted-foreground` equals `--foreground` in both themes, so 379 uses of `text-muted-foreground` render at full body contrast. Light `--muted` is a mid grey, `--accent` (the ghost-button hover) is 1.05:1 against the page, the light popover is darker than the page, dark shadows are invisible, and the light primary button label is 3.85:1. -3. **Nothing is built for touch.** Hover motion isn't gated, there's no tap-highlight reset, nothing reads the safe-area insets despite `viewport-fit=cover`, and most controls on every mobile page are under 44px. -4. **Wayfinding and agency.** On all eight tool routes, nothing in the header shows as active. There are five different delete patterns and no undo anywhere, even though the toast adapter supports an action button. Streaming tools can't be stopped. -5. **Too much decoration.** The landing page loops an aurora, orbs, a drifting grid and CTA shimmer forever across five sections. The app replays staggers on tab switches, animates the nav underline from `scale(0)` on every navigation, and stacks five looping indicators on the AI loading panel. - -## Confirmed in the browser - -These rows were observed in the running app, not only inferred from code. - -| Finding | What the browser showed | -| --- | --- | -| Toasts can't stack, expand or exit | With two toasts open, both sat at the same position (top 823 and 825px) with an identity transform. Hovering didn't expand them, and only the newest was visible. 150ms after `toast.dismiss()` both were marked removed but still fully opaque and unmoved. Cause: `[data-sonner-toast]{animation: toast-slide-in .3s … both !important}` at `src/index.css:622-624` holds the transform Sonner uses for stacking, swipe and exit. Screenshots: `probe-toast-stacked.png`, `probe-toast-expanded.png`, `probe-toast-exit-150ms.png`. | -| Landing card hovers never render | `.feature-card`, `.tilt-card` and every `hover:-translate-y-1` card keep an inline `transform: none` from framer's `whileInView` after the reveal, so hovering changes nothing. | -| Select closes with `ease-in` | Measured: the Select exit is 150ms `ease-in`, leaked from `src/index.css:709-711`. | -| Popovers scale from their centre | Measured: Select content origin is `94.5px 37px` and the debate menu popover is `176px 151px`, the centres of their boxes, not the trigger. | -| `duration-200` on Dialog is dead | Measured: the dialog enters in 150ms and exits in 150ms with `ease`, so enter and exit are symmetric. | -| Press feedback is too faint | Measured: hover lifts 1px, and `:active` scales to 0.985 over 160ms in both directions. | -| Timer ring eases every second | Measured: `clip-path 1000ms cubic-bezier(0.4,0,0.2,1)` per tick, so the ring surges and stops each second. Digits are Space Mono at 72px with −3.6px tracking. | -| Mobile menu is slow and has no scrim | Measured: a 420ms `motion-reveal` on a plain `<nav>`, not a dialog. The page stays visible and interactive underneath (`probe-m-menu-settled.png`). | -| Select items slide in on every open | Measured: opening a Select fires `menu-item-in 150ms` on every option, from `index.css:724-738`. | -| The Generate CTA shimmers at rest | Measured: one infinite `shimmer 3000ms` runs on the Generate page with nobody touching it. | -| Sending in Ask animates the user's own bubble | Measured: pressing Enter fires `message-enter 300ms` on the user's bubble and `streaming-glow-pulse 1500ms` on the reply. | -| Reduced motion removes everything | With `reducedMotion: 'reduce'`, opening a dialog or Select produced no animations at all, not even an opacity fade. Spinners freeze. | -| `prefers-contrast: more` is ignored | The landing nav keeps `rgba(241,245,249,.9)` and `blur(24px)`. | -| Onboarding tour covers the form on mobile | On Generate at 390px the tour popover covers the Motion field and points at nothing (first sweep, before the tour was dismissed). | -| Two `<h1>` on every app page | The header brand (`AppLayout.tsx:185`) is an `<h1>`, and on Home the page `<h1>` also says "DebateLab". | -| Language is a modal | The globe opens a full Dialog with two Selects, so changing language takes four taps (`probe-d-language.png`). | -| Layout holds | No horizontal overflow on any of the 80 page states, no console errors, and no overflow in Hungarian at 375px. | - -## Fix first - -These are ordered by impact per line changed. - -1. ✅ PR1. Delete the global toast rule at `src/index.css:622-635`. Sonner's own 400ms transitions take over again. -2. ✅ PR1. Fix the tokens: light `--muted-foreground: 215 16% 40%`, dark `215 20% 70%`. Light `--primary: 200 98% 33%`, light `--popover: 0 0% 100%`, light `--accent: 214 32% 89%`, dark `--accent: 217 32% 20%`. -3. ✅ PR1 (nav-active-pill goes with the nav in PR2). Delete the global micro-interaction layer: tooltip in/out, menu-item-in, the tab underline, nav-active-pill, the icon-name hijacks, `.border-dashed:hover` marching ants, `.tabular-nums` transition, the progressbar width transition, switch-bounce, check-draw, the slider pulse, and the input `!important` focus. Then give each overlay component in `components/ui/` one explicit curve. Line numbers are in the foundations table. -4. ✅ PR1. Gate hover: `future: { hoverOnlyWhenSupported: true }` in `tailwind.config.ts`, and wrap the hover rules in `src/index.css` in `@media (hover:hover) and (pointer:fine)`. -5. ✅ PR1. Replace the blanket reduced-motion kill (`src/index.css:1071-1098`) with one that keeps opacity and colour transitions and turns spinners into an opacity pulse. -6. ✅ PR1. Add `origin-[--radix-…-content-transform-origin]` to Popover, Select and Tooltip, and set exits shorter than enters. -7. ✅ PR2. Wayfinding: make "Current debate" a link that is active on `/app/debate` and every tool route. Rename "Home". Make the brand a `<span>`. -8. ✅ PR2–3. One delete rule: reversible deletes act at once and show an Undo toast; irreversible bulk deletes use an AlertDialog with a destructive-styled confirm. -9. ✅ PR3. Ask: scroll only when the user is at the bottom, keep the input editable while streaming, add Stop. Give the four form tools a Cancel. -10. ✅ PR4. Landing: remove the infinite loops and shimmer, split the framer reveal from the hover onto separate elements, and switch the `x`/`y` shorthands to full `transform` strings. - -## Findings - -Rows follow Emil Kowalski's required Before / After / Why format. The tiers follow `review-animations`, highest impact first: feel-breaking, missed simplifications, performance, interruptibility and timing, origin and cohesion, accessibility. A seventh group covers Apple's foundations (feedback, wayfinding, agency, typography, materials). Paths are under `src/` unless stated. - -### 1. Feel-breaking - -| Where | Before | After | Why | -| --- | --- | --- | --- | -| ✅ PR1 · `index.css:622-635` | Global `[data-sonner-toast]` keyframe with `both !important` | Delete it | Confirmed above. Stacking, expand, swipe and exit are broken for all 157 `toast()` calls. | -| ✅ PR1 · `index.css:30`, `:99` | `--muted-foreground` equals `--foreground` | Light `215 16% 40%` (5.55:1 on the page), dark `215 20% 70%` | Secondary text has no lower tier, so hierarchy comes from size alone. | -| ✅ PR1 · `index.css:709-711` | `[data-state="closed"][data-side]{animation: tooltip-out .1s ease-in both}` | Delete it; set exits per primitive | `ease-in` leaks into every Select, Popover and Tooltip close (measured). | -| ✅ PR1 · `index.css:638-651`, `dialog.tsx:42`, `alert-dialog.tsx:37`, `popover.tsx:20`, `select.tsx:69`, `sheet.tsx:33` | Curves depend on cascade accidents. AlertDialog and Select get `ease`, Dialog closes with `ease`, and `duration-200` is dead | Add `transitionTimingFunction: { 'out-strong': 'cubic-bezier(0.23,1,0.32,1)', drawer: 'cubic-bezier(0.32,0.72,0,1)' }` to the Tailwind config. Per primitive: `data-[state=open]:duration-200 data-[state=closed]:duration-150 ease-out-strong` | Five primitives use four curves today. Exit should be faster than enter. | -| ✅ PR1 · `index.css:149`, `:281-289` (`.motion-press` on every Button) | Ungated hover `translateY(-1px)` plus a shadow; `:active` scale 0.985 over 160ms both ways | Hover inside `@media (hover:hover) and (pointer:fine)`, no lift on ghost and link buttons; `--motion-press-scale: 0.97`, 100ms on press, 160ms on release | Sticky lift after a tap on touch. 0.985 barely registers (the rubric range is 0.95–0.98). | -| ✅ PR1 · `index.css:846-849` | Unlayered `.tabular-nums { transition: transform .2s }` | Delete it; Tailwind already ships `.tabular-nums` | It resets `transition-property` on 19 elements. The Timer's red warning snaps instead of easing. | -| ✅ PR1 · `index.css:451-489` | `.border-dashed:hover` marching-ants infinite animation | Delete it; use an explicit `.dropzone[data-drag-over]` class if a drop zone needs it | It takes over a utility name, so every dashed border (22 uses, including non-interactive empty states) animates and changes shape on hover. | -| ✅ PR1 · `index.css:604-607` | `[role=progressbar] > div { transition: width .5s }` | Delete it; `progress.tsx:16` `transition-transform duration-300 ease-out-strong`; clocks `transform 1000ms linear` | Radix moves the bar with `transform`, so every progress bar jumps (Timer, Sparring, Attack, Factoids). | -| ✅ PR2 · `AppLayout.tsx:196`, `:209`, `index.css:569-595` | Active nav underline animates `scaleX(0)→1` with a glow on every navigation, on top of `bg-primary/10` | Static `bg-primary/10 text-primary` only | Starts from scale 0, fires tens of times a day including from the keyboard, and doubles the active signal. | -| ✅ PR2 · `AppLayout.tsx:249-250` | Mobile menu `motion-reveal` 420ms, rising upward while it drops down below the header, no exit | No animation, or opacity plus `translateY(-4px)` at 180ms with a matching exit | Over budget, wrong direction, asymmetric path (Apple §7). | -| ✅ PR3 · `AskChat.tsx:380-382`, `AskPage.tsx:334-343` | `scrollIntoView({behavior:'smooth'})` on every 30ms stream flush; it also scrolls the window | Track `atBottom` (within 80px); scroll instantly only then; otherwise show a "Jump to latest" pill | It pulls the user down when they scroll up to read (Apple §3: never lock out input). | -| ✅ PR3 · `AskPage.tsx:176`, `session-store.ts:944` | The streaming bubble is keyed by request id; the stored message gets a new id, so it remounts and `message-enter` replays | Reuse the streaming id for the stored message | The answer jumps and fades in again as the user reads it. | -| ✅ PR3 · `AskChat.tsx:88`, `index.css:654-667` | 300ms `message-enter` keyframe on every bubble, including the user's own message sent with Enter | No entry animation on user bubbles; assistant bubbles get opacity plus `translateY(4px)` at 150ms | Keyboard-initiated and frequent. | -| ✅ PR3 · `TimerPage.tsx:330` | Ring `transition-all duration-1000` with the default ease | `transition-[clip-path] duration-1000 ease-linear` | Constant motion is linear (measured surge-and-stop). | -| ✅ PR2 · `HistoryPage.tsx:156`, `tabs.tsx:45` | Each tab switch replays a slide-in plus a 350ms `stagger-enter` plus the empty state's blur swap | 150ms fade on the tab content only; no stagger | Tens of times a day, and Radix tabs switch on arrow keys. | -| ✅ PR2 · `HomePage.tsx:61`, `CurrentDebatePage.tsx:198`, `AppLayout.tsx:44`, `:259`, `:300-301`, `CurrentDebateMenu.tsx:27` | `.motion-press` button lift on list rows, nav links and footer text links | `hover:bg-accent` only on rows and links | Hairline rows lift and cast shadows like cards. | -| ✅ PR4 · `landing/WhatsNew.tsx:41-47`, `Pricing.tsx:48-53`, `:81-86`, `Features.tsx:56-62`, `UseCases.tsx:49-55` | Framer `whileInView` `y` on the same node as `hover:-translate-y-1` and `transition-all duration-300/500` | Reveal on an outer `motion.div`, hover on an inner div; drop the lift and tilt; transition only `box-shadow` and `border-color` | Confirmed above: the hovers never render, and `transition-all` drags out framer's own reveal. | -| ✅ PR4 · `index.css:174-201` (`Hero.tsx:74`, `:81`, `Navbar.tsx:57`, `:98`, `Pricing.tsx:75`, `:113`) | `.btn-primary-gradient` and `.btn-secondary-outline` have no `:active`; they are Links, so `.motion-press` never applies | `:active { transform: scale(0.97) }` with `transition: transform 160ms var(--motion-ease-fluid)` | The main conversion buttons give no feedback on press. | -| ✅ PR4 · `landing/Footer.tsx:11`, `:24`, `:27` | `<a href>` | `<Link to>` | Every footer click reloads the whole SPA through the startup shell. | - -### 2. Missed simplifications - -| Where | Before | After | Why | -| --- | --- | --- | --- | -| ✅ PR4 · `index.css:203-254`; `Hero.tsx:26-29`, `Features.tsx:37-38`, `Pricing.tsx:29`, `FinalCTA.tsx:14-17` | Infinite aurora (14s), orbs (8s and 10s), full-bleed grid drift (20s) on five sections | Static backgrounds | Apple §14 flags full-viewport moving backgrounds and slow oscillations (0.07–0.125 Hz). | -| ✅ PR3–4 · `index.css:207-213`; `Navbar.tsx:57`, `:98`, `Hero.tsx:74`, `Pricing.tsx:113`, `FinalCTA.tsx:45`, `GenerateForm.tsx:286` | `shimmer 3s infinite` on CTAs, including the app's Generate button | Delete it, or run once on gated hover at 700ms | Perpetual decoration on the most-watched element. | -| ✅ PR3 · `streaming-indicator.tsx:25-63`; wrappers at `AttackPage.tsx:139`, `BrainstormPage.tsx:127`, `GeneratePage.tsx:384`, `FactoidsPage.tsx:145` | Five simultaneous loops: wrapper shimmer, inner swap, ping ring, shimmer bar, and a fake progress bar that fills to 100% every 3s via a 50ms `setInterval` | One spinner (`spin 600ms linear infinite`) plus one honest status line | Decorative loops, a dishonest determinate bar, and 20 renders a second. | -| ✅ PR3 · `loading-spinner.tsx:18-20` | Ping ring plus static ring plus a 1s spin | One ring at `spin 700ms linear` | A faster linear spin reads as a faster load. | -| ✅ PR1 · `index.css:724-738` | `[data-radix-collection-item]` menu-item-in keyframe | Delete it | Every SelectItem, TabsTrigger and AccordionTrigger slides 4px on every open. | -| ✅ PR1 · `index.css:760-779` | Second active-tab indicator: an `::after` underline from `scaleX(0)` | Delete it; the TabsTrigger pill already marks active | Two indicators, grows from zero, keyboard-triggered (also visible on Login). | -| ✅ PR1 · `index.css:782-827` | Icon-name hijacks: refresh spins and trash shakes on hover, copy and heart scale on `:active` | Delete them | Global Lucide class-name selectors; a shaking trash makes deletion look playful. | -| ✅ PR1 · `index.css:296-299`, `:510-520`, `section-card.tsx:33`, `badge.tsx:8` | Button icon `scale(1.04)` on hover; non-clickable SectionCards lift 2px; Badge `hover:scale-105 transition-all` | Delete all three | Lift implies clickability that isn't there (Apple Familiarity). | -| ✅ PR1 · `index.css:682-701`, `:829-842` | Switch squash-and-stretch keyframe; a `check-draw` that's a no-op (no dasharray); infinite slider pulse while dragging | Switch `transition-transform duration-200 ease-out-strong`; delete the rest | Replays on mount, not interruptible, noise under the finger. | -| ✅ PR3 · `animated-section.tsx:14`, `:21`; used on Ask, Attack, Brainstorm, Generate, Factoids | `transition-all duration-300` fade-up on a timeout every time a tool opens | Delete it, or opacity only at 150ms with no delay | Tens of times a day. | -| ✅ PR3 · `TimerPage.tsx:334` | `animate-pulse` on the digits in the last 30s | Static `text-destructive` | Pulses the number the speaker is watching. | -| ✅ PR3 · `FlowchartPage.tsx:251-253`, `:305` | `animated: true` marching edges | Static edges; a dashed stroke for rebuttals | Constant motion on a work canvas. | -| ✅ PR3 · `CrossfirePage.tsx:865-1021`, `SparringRound.tsx:192`, `:249-254` | The transcript remounts per phase and all turns replay; the AI wait shows a pulsing background plus bouncing dots plus text | Hoist the transcript and animate only the newest turn; one indicator | A wall of cards fades in on each phase. | -| ✅ PR2 · `AppLayout.tsx:44`, `:218-219` | `nav-glow` radial hover on top of `hover:bg-accent`; theme toggle in the mobile header and again in Settings | Remove `nav-glow`; drop ThemeToggle from the mobile header | Doubled hover effects; a crowded sticky header. | -| ✅ PR2 · `LibraryPage.tsx:495-506` vs `:777-790`; `:675-684` vs `:723-733`; `:583-596` | Two folder filters; two Folder selects in one form; three stat tiles repeating a badge count | One chip row; one Folder select; one meta line under the h1 | Duplicate controls bound to the same state. | -| ✅ PR3 · `LibraryPage.tsx:248`, `:404-407`, `:435-438` and success toasts at `AttackPage.tsx:49`, `BrainstormPage.tsx:48`, `GeneratePage.tsx:97`, `FactoidsPage.tsx:47` | Toasts confirming things already visible | Drop them; keep toasts for off-screen or irreversible results | Over-feedback trains people to ignore feedback (Apple §13). | -| ✅ PR3 · `ExportToolbar.tsx:15`, `:54`, `AutoSavedSourcesUndo.tsx` | `showUndo` is never passed by any caller | Delete both | Dead code with a misleading label. | -| ✅ PR4 · `landing/WhatsNew.tsx:27-33`, `SocialProof.tsx:7-11` | Pill and h2 both say "What's New"; "2 Plans / 1 Unified Workspace" styled as metrics | Remove the pill; remove the section or use real facts | Repetition; pseudo social proof (Apple Responsibility). | - -### 3. Performance - -| Where | Before | After | Why | -| --- | --- | --- | --- | -| ✅ PR1 · `index.css:278` | `will-change: transform` on every `.motion-press` | Remove it | A compositor layer for every button in the app. | -| ✅ PR4 · `index.css:1-3`, `:57` | Three render-blocking Google Fonts `@import`s, including Lora (0 uses) | Drop Lora and Space Mono, or self-host Inter variable with a metric-matched fallback; or use `system-ui` (Apple §15) | A chained blocking request and a visible font swap: the startup shell renders in `system-ui`. | -| ✅ PR4 · `Hero.tsx:36-161`, `Navbar.tsx:24`, `DeepDive.tsx:20`, `:55`, every `whileInView y` | Framer `x`/`y`/`scale` shorthands at 0.5–0.8s, delays to 0.5s, during page load | `transform: "translateY(12px)"` strings, `duration: 0.35, ease: [0.23,1,0.32,1]`, hero delays 0–0.15s, `viewport: { once: true, margin: "0px 0px -15% 0px" }` | Shorthands run on the main thread and drop frames while loading; the CTA is held back about a second. | -| ✅ PR4 · `Hero.tsx:167-178`, `WhatsNew.tsx:47`, `Pricing.tsx:53`, `:86`, `index.css:165`, `:220`, `:271` | Nested translucent blur layers over animated backgrounds; grid animates `background-position` | Solid cards; blur only on the nav; static grid | Never stack light translucent surfaces (Apple §12); full-section repaints every frame. | -| ✅ PR3 · `AskPage.tsx:25-42`, `:334-340`, `AskChat.tsx:82`; `AttackPage.tsx:26`, `BrainstormPage.tsx:24`, `GeneratePage.tsx:35`, `FactoidsPage.tsx:24` | `useSessionStore()` with no selector; Ask remaps messages so `React.memo` never holds | `useShallow` selectors; pass messages through; a separate `StreamingBubble` | Every bubble re-parses Markdown on every 30ms flush. | -| ✅ PR3 · `index.css:984-1001`, `:1052-1061`; `CrossfirePage.tsx:421-426` | Caret blink animates `box-shadow`; dead `max-height`/`padding` transition; `setNow` every 500ms re-renders the whole Crossfire page | Opacity-only caret with `steps(2)`; delete the dead rule; a `<CrossfireClock>` with its own interval | Repaints while streaming; full renders during a live round. | -| ✅ PR2 · `SettingsPage.tsx:324-326`, `AppLayout.tsx:276-285` | A 160px pulsing block for every user while the admin check runs; the announcement banner pushes content down after a fetch | Render nothing while pending; overlay the banner or render it from cache | Layout shift for 100% of users. | - -### 4. Interruptibility and timing - -| Where | Before | After | Why | -| --- | --- | --- | --- | -| ✅ PR3 · `AskChat.tsx:703`, `:706`; `CrossfirePage.tsx:968`; `AttackPage.tsx:36-62`, `BrainstormPage.tsx:37-61`, `GeneratePage.tsx:78-116`, `FactoidsPage.tsx:34-60` | The input is disabled while the AI works; no Stop; the form tools have no AbortController | Keep composers editable and disable only submit; a Stop button that aborts and returns focus; Cancel under the tool status | Apple §3: thought and gesture run in parallel. | -| ✅ PR2 · `sheet.tsx:33`, `:37-42` | Open 500ms, close 300ms, bare `transition ease-in-out`; full-width right sheet on phones; no drag | Open 300ms, close 200ms, `ease-drawer`; on mobile a bottom sheet (Vaul) with velocity dismiss above about 0.11 px/ms and rubber-banding | Over budget; a sheet you can't grab doesn't feel physical (Apple §2–6, §9). | -| ✅ PR1 · `dialog.tsx:42`, `alert-dialog.tsx:37`, `popover.tsx:20`, `select.tsx:69`, `tooltip.tsx:20` | Symmetric 150ms keyframes, `zoom-out-95` on exit | Enter 200ms, exit 120ms fade only | A reopen during the close doesn't visibly jump; exit faster than enter. | -| ✅ PR1 · `index.css:741-743` | `[role=option]` background fades 100ms | Instant highlight; keep only the `:active` transform | Arrow-key list navigation shouldn't fade. | -| ✅ PR1 · `accordion.tsx:31` vs `tailwind.config.ts:77-78`; `index.css:355-370`, `AttackForm.tsx:121`, `GenerateForm.tsx:220` | Chevron 300ms against content 200ms; options expand 260ms plus translateY with a 300ms chevron | Everything at 200ms on one curve; chevron 150ms | Coordinated motions finish out of sync. | -| ✅ PR3 · `TimerPage.tsx:183` | Resume uses whole seconds rounded up | Store the remaining milliseconds on pause | Each pause gives the speaker up to a second. | -| ✅ PR3 · `FlowchartPage.tsx:442-444`, `:749-762`, `:343` | Auto layout clicks a fit-view button that doesn't exist; wheel zoom captures page scroll; new nodes at random positions | `rf.fitView({ duration: 250, padding: 0.2 })` with a temporary node transform transition; `zoomOnScroll={false} preventScrolling={false}`; place at the pane centre | Teleporting nodes; trapped scroll; things appearing from nowhere. | -| ✅ PR2 · `AppLayout.tsx:155-173`; `SettingsPage.tsx:89-104`, `:143-150`; `DebateDialogs.tsx:53-70`; `LibraryPage.tsx:260-283` | Announcement dismiss waits for the server; no pending state on delete-all, switch or delete debate, sign out, or file import | Optimistic dismiss; per-action busy labels and disabled state; reset the file input | A tap with no visible response (Apple §1). | -| ✅ PR2 · `OnboardingTour.tsx:105-111` | A fixed 500ms start delay | Start once the target exists (rAF or MutationObserver, 500ms cap) | An artificial delay. | -| ✅ PR1 · `index.css:1065-1068` | The transition is declared only inside `:hover`, so leaving snaps back | Transition on the base rule; gate the hover | An asymmetric snap. PR1 deleted the rule instead, with the other icon hijacks. | - -### 5. Origin, physicality and cohesion - -| Where | Before | After | Why | -| --- | --- | --- | --- | -| ✅ PR1 · `popover.tsx:20`, `select.tsx:69`, `tooltip.tsx:20` | `zoom-in-95` from the centre (measured) | `origin-[--radix-popover-content-transform-origin]`, and the same for Select and Tooltip | Trigger-anchored content scales from its trigger; modals stay centred. | -| ✅ PR2 · `index.css:153`, `:271`, `AppLayout.tsx:178` | Sticky translucent header with a hard 1px border and a shadow | A scroll-edge fade, or a border only when `scrollY > 0` | Apple §12. | -| ✅ PR1 · `index.css:491-847` overall | About 20 unlayered global selectors keyed on Radix internals | Move what's worth keeping into the primitives and delete the layer | They break silently on upgrades and override primitive classes. | -| ✅ PR1 · `index.css:156-159`, `ThemeProvider.tsx:8-23` | `html`/`body` ease over 420ms, components at 160 or 260ms, everything else snaps | `document.startViewTransition` with a 200ms crossfade, or suppress transitions for one frame | The theme change ripples at three speeds. | -| ✅ PR3 · `AskChat.tsx:693`, `GenerateForm.tsx:142-154`, `VoiceMicControl.tsx:41`; `TimerPage.tsx:334`, `SparringRound.tsx:150`, `:157`, `CrossfirePage.tsx:924-926` | Three listening indicators and three countdown urgency treatments; the Timer goes calm again at 0:00 | One `<ListeningIndicator>` and one `<RoundClock>`: amber at 30s or less, destructive at 0:00 | Things that look the same should behave the same. | -| ✅ PR3 · `TimerPage.tsx:334` | `font-mono` (Space Mono) with `tracking-tighter` on the digits | Inter with `tabular-nums` and `tracking-[-0.02em]` | The wide colon reads "4 : 00"; negative tracking on a monospace face fights its design. | -| ✅ PR4 · `landing/*` section h2s; `Features.tsx:37` | Different sizes and tracking per section; the orb's `-translate-y-1/2` is overridden by its keyframe | One `.section-heading`; position the orb with `top` | Cohesion; the orb jumps under reduced motion. | -| ✅ PR2 · Login (`LoginPage.tsx:157-161`), app header logo | The login card shows a generic sparkle tile; the app shows the tree-and-scales logo in a white tile in both themes | The real mark on Login; a dark-mode variant of the logo tile | Brand consistency across surfaces. PR2 puts the real mark on Login and the public pages. The dark-mode tile stays as it is, by Adam's decision on 2026-10-11; no new asset. | - -### 6. Accessibility - -| Where | Before | After | Why | -| --- | --- | --- | --- | -| ✅ PR1 · `index.css:1071-1098` | `* { animation: none !important; transition: none !important }` under reduced motion | Keep `opacity, color, background-color, border-color, box-shadow` transitions; drop transforms; spinners become a 1.2s opacity pulse (the `motion-fade-in` keyframe at `:923` is unused) | Gentler, not zero. Frozen spinners and carets look hung. | -| ✅ PR1 · `tailwind.config.ts:5-7`, `index.css` hover rules | No `hoverOnlyWhenSupported`; ungated `:hover` transforms | Turn on the flag; wrap the CSS hover rules | Sticky hover after taps. | -| ✅ PR1 · `index.css` (none) | No `prefers-reduced-transparency`, `prefers-contrast` or `forced-colors` handling for the eleven `backdrop-blur` surfaces | Solid `--card` background with no blur under reduced transparency or more contrast, plus a defined border | Apple §14 lists three signals; only one is handled (confirmed above). | -| ✅ PR1 · `index.css:131-133`, `index.html:5`, `index.css:377-403` | No `-webkit-tap-highlight-color`; `viewport-fit=cover` but `--native-safe-*` never set and nothing reads `env(safe-area-inset-*)` | `html { -webkit-tap-highlight-color: transparent }`; `--native-safe-top: env(safe-area-inset-top, 0px)` and so on; pad the bottom sheet, OfflineIndicator and Toaster | A grey flash over the press scale; UI under the home indicator in the PWA. | -| ✅ PR1–4 · `button.tsx:22`, `:24`, `section-card.tsx:50`, `checkbox.tsx:14`, `dialog.tsx:48-49`, `sheet.tsx:63-64`, `LibraryPage.tsx:132`, `:769`, `DebateDialogs.tsx:96`, `FlowchartPage.tsx:169`, `:193`, landing `Navbar.tsx` toggle, `Hero.tsx:179-191` | `sm` 36px, `icon` 40px, 28–32px icon buttons (one a delete next to a Switch), a 16px dialog close, a 16px checkbox, 12px flowchart handles | `[@media(pointer:coarse)]:min-h-11 min-w-11` on the cva sizes; invisible hit-area pseudo-elements on Checkbox, the close button and handles | Seen on every mobile page: `sm` buttons are 36px tall and icon buttons 40px. | -| ✅ PR1 · `index.css:598-601`, `:502-507`; `dialog.tsx:48`, `sheet.tsx:63`, `select.tsx:20` | `!important` input focus is a 10%-alpha glow (about 1.1:1); global focus changes `border-radius`; some primitives use `focus:` not `focus-visible:` | One system: `outline: 2px solid hsl(var(--ring)); outline-offset: 2px` on `:focus-visible` | Fails WCAG 2.4.11; three focus systems compete. PR1: the global outline is the default, and primitives that draw `focus-visible:ring-*` turn it off, so nothing gets two rings. | -| ✅ PR1 · `index.css:23-24`, `:35`, `:39`, `:104-108` | Light primary label 3.85:1; destructive labels 4.43:1 (light) and 3.43:1 (dark); `--input` borders 1.38:1 and 2.32:1 | `--primary: 200 98% 33%`; light `--destructive: 0 72% 47%`; dark `--destructive-foreground: 0 86% 10%`; light `--input: 215 16% 55%` | AA text and 3:1 for control boundaries. | -| ✅ PR1–4 · `SettingsPage.tsx:37-42`, `:206`; `DeepDive.tsx:63`, `:78`; `LoginPage.tsx:268`, `:273` | White on amber or green-500 tier badges (about 2:1); near-white `text-accent` on a light card (about 2.5:1); hard-coded `text-green-600` (3.15:1) | Tinted badges (`bg-amber-500/15 text-amber-800 dark:text-amber-300`); `text-foreground`; a `--success` token | Contrast failures. | -| ✅ PR2 · `LibraryPage.tsx:69-147`, `:627-638`, `:705-975` | A `role=button` row containing a link, a button and a switch; an invisible file input with no focus ring; labels without `htmlFor` | The title is the button; `has-[:focus-visible]:ring-2` on the label; matching `id`/`htmlFor` pairs | Nested interactive controls; invisible focus; detached labels. | -| ✅ PR1 · `AskChat.tsx:102`, `FactoidsResults.tsx:59` | The bookmark and external-link icons appear only on hover | Always visible at 60% opacity, full on focus | Invisible on touch and to the keyboard. | -| ✅ PR3 · `TimerPage.tsx:329`, `SparringRound.tsx:157`, `streaming-indicator.tsx:56` | `aria-live` on a clock that ticks every second; fake stage text announced every 3s | Announce 1:00, 0:30 and 0:00 only; `role="timer"`; announce only real status changes | Constant screen-reader chatter. | -| ✅ PR2 · `LibraryPage.tsx:95-124`, `ModelSelector.tsx:76-86` (about 30 `text-[9-11px]` uses) | 9–11px text in px | `text-xs` (0.75rem) as the floor | Below a legible floor; doesn't scale with the user's font size. | -| ✅ PR2 · `ThemeToggle.tsx:21`, `tooltip.tsx` (0 importers) | Icon-only header controls use `title`; the theme icon is a laptop in "system" mode | Wrap them in Tooltip, which gets an instant skip delay inside the provider | `title` doesn't show on touch or focus; the laptop icon is ambiguous. | - -### 7. Foundations: feedback, wayfinding, agency, typography - -| Where | Before | After | Why | -| --- | --- | --- | --- | -| ✅ PR2 · `AppLayout.tsx:31-41`, `:185`, `:190-215`, `:199`; `HomePage.tsx:155` | Nav is Home, Current debate (a popover, never active), Library, History, Settings. Nothing is active on the eight tool routes. The brand is an `<h1>`; Home's `<h1>` is also "DebateLab" | "Current debate" becomes a link, active on `/app/debate` and every tool route, with the chevron as a separate trigger. Rename Home to "Tools". The brand becomes a `<span>`. Home's h1 describes the page | "Where am I?" is unanswered on most screens; specific labels beat generic ones (Apple §16). | -| ✅ PR2 · `NotFound.tsx:11-13`, `SourcesPage.tsx:129-162`, `App.tsx:153-158`, `PrivacyPage.tsx`, `TermsPage.tsx`, `LoginPage.tsx:150-284` | The 404 reloads to the marketing page; Sources, Legal and Login have no way home; the route error offers only Reload | `<Link to={user ? '/app' : '/'}>`; a header or back link on public pages; "Go to Home" on the error | Never trap the user. | -| ✅ PR2–3 · `LibraryPage.tsx:129-140` vs `:1025-1044`; `DebateDialogs.tsx:104-110`; `HistoryPage.tsx:352-379`; `SettingsPage.tsx:386-454`; `LibraryPage.tsx:421`; `FlowchartPage.tsx:382-390` vs `:784-804`; `AdminPage.tsx:997-1012` | Five confirmation models. A source row deletes instantly, while the same source in the sheet asks. Saved flowcharts delete instantly, while nodes ask. `window.confirm`. Admin Ban and Make admin are one click | Reversible deletes act at once with `toast({ action: { label: 'Undo' } })`; irreversible ones get an AlertDialog with `buttonVariants({ variant: 'destructive' })`; admin role changes use the existing `pendingAction` dialog | Apple §16 Agency: undo for slips, confirmations used sparingly and consistently. PR2 draws the line at whether the app can put the item back exactly: Library sources delete at once with Undo (the flowchart's nodes and saved flowcharts follow in PR3); saved cases, sparring rounds, Clear Library and the Settings deletes use a destructive-styled confirm; a debate keeps its inline confirm, which already names what goes with it; admin role changes confirm through a dialog. | -| ✅ PR2 · `LibraryPage.tsx:218-233`, `:442-444`; `LoginPage.tsx:74-133`, `:277-281`, `:57-143` | Required-field errors arrive as toasts after submit; login errors appear below the tabs, not tied to the field; raw backend `error.message` in English | Inline `role="alert"` under the field with `aria-describedby` and focus moved to it (as `DebateForm.tsx:115-118` already does); map Supabase error codes to i18n | Validate inline; localized errors. | -| ✅ PR3 · `GenerateResults.tsx:55-57`, Attack, Brainstorm, Ask without citations | No AI caveat anywhere; Generate evidence reads as fact unless `needsSource` is set | One line under every result header: "AI-generated. Check facts and sources before using them in a round."; label evidence "Unverified" unless a researched source backs it | Apple §16.3 Responsibility. | -| ✅ PR3 · `AskChat.tsx:64-70`, `:384-394`, `:118`; `streaming-indicator.tsx` | Timed status copy cycles ("Almost there…" back to "Thinking…"); "Typing" shows while web research runs | Real phases ("Searching the web…", "Writing"); otherwise "Working…" plus elapsed time after 5s | Status should be true. | -| ✅ PR3 · Error handling in Attack, Brainstorm, Factoids and Generate; `error-display.ts:31`, `:41`; `SparringPage.tsx:26-29`, `:297-307`; `AskPage.tsx:155`, `:280`; `AskChat.tsx:405-407` | Toast-only errors with no Retry; hard-coded English; Retry duplicates the user's bubble; oversized input is cleared before it's rejected | An inline error panel with Retry, like `CrossfirePage.tsx:943-958`; i18n; retry without re-adding the message; clear only on accept | Recoverable feedback; forgiveness. | -| ✅ PR3 · `AttackPage.tsx:67-92`, `GeneratePage.tsx:195-214`, `FactoidsPage.tsx:91-108`; `markdown.tsx:7-13` | "Discuss" prompts are indented template literals that render as a code block, and send non-streaming; `className="inline"` lands on a block wrapper; no link renderer | Build prompts with `join('\n')` through i18n and the streaming path; an `inline` prop mapping `p` to `span`; links with `target="_blank" rel="noopener noreferrer"` and `overflow-wrap:anywhere` | Broken rendering of the user's own message and of label/value rows. | -| ✅ PR2–3 · Page headers: `HomePage.tsx:154`, `CurrentDebatePage.tsx:112`, `SettingsPage.tsx:182`, `LibraryPage.tsx:474`, `HistoryPage.tsx:118`, `AskPage.tsx:350`, `SparringSetup.tsx:78`, `FlowchartPage.tsx:479-491`; card titles `card.tsx:19`, `dialog.tsx:74` | Eight header patterns; h1 weight and tracking vary; Flowchart has no h1; card titles `text-xl`, `text-lg` or `text-2xl` with `tracking-tight leading-none` | One `PageHeader`: h1 `text-3xl font-bold tracking-tight leading-tight`, optional eyebrow, meta line and actions slot. Card titles `text-lg font-semibold` with size-specific tracking (24px −0.019em, 18px −0.014em, 16px −0.011em) and `leading-tight` | Apple §15: tracking depends on size; Familiarity. | -| ✅ PR3 · Results in Attack, Brainstorm, Generate and Factoids; `HomePage.tsx:53-73` vs `CurrentDebatePage.tsx:194-204` | The export toolbar sits in a different place per tool; copy exists only in Generate; only Generate scrolls to results; the tool list is built twice with different labels | One `<ResultHeader>` (meta row, actions row, sources disclosure); `useRevealResults()` on mobile; one shared `ToolLink` | The same tool family should share a layout. | -| ✅ PR3 · `GeneratePage.tsx` (screenshot) | "No current debate" appears twice (page banner and form notice); h1 "Generate", card title "Generate Debate Case" and button "Generate Debate Case"; an emoji 🎯 as the empty-state art next to Lucide icons elsewhere | One debate notice; drop the card title; use the `EmptyState` Lucide component everywhere | Redundant copy; mixed iconography. | -| ✅ PR3 · `AskChat.tsx:517`, `:582-637`; `CrossfirePage.tsx:915-932`, `:679`, `:693`; `SparringRound.tsx:203-295`; `TimerPage.tsx:87`, `:203`, `:258-261`, `:378-382` | Chat height `100vh`; four switches above the input; the live Crossfire header never shows the resolution; the opponent's latest speech sits below a 240px composer; no wake lock; R and Escape reset the timer with no undo; keyboard hints shown on touch | `100dvh` and an Options popover; show the resolution; pin the last speech above the composer; `navigator.wakeLock` while running, with vibration and sound on the same frame at 0:00; reset only while paused or with Undo; hide kbd hints under `pointer:coarse` | Wayfinding, Apple §13 harmony, forgiveness. | -| ✅ PR2 · `HistoryPage.tsx:136-379`, `LibraryPage.tsx:39`, `:880`, `CurrentDebatePage.tsx:69-206` | The tab resets after Back; fixed 500–600px inner scroll boxes trap scrolling on phones; lists are cut at 4 or 6 with no "View all"; three equal outline buttons and no primary action on the debate page | The tab in `useSearchParams`; `max-h` with page scroll on mobile; "View all (N)"; move the tools up and make them primary | "What's there?" and hierarchy. | -| ✅ PR2 · `DebateDialogs.tsx:167-225`, `PricingModal.tsx:96`, `LimitReachedModal.tsx:39-47` | The 5-day idle gate blocks Escape on every route including Settings; "Current" is always on Free; the limit modal offers only Upgrade | Allow Escape to mean "Continue"; badge the real tier; add "Not now" | Never trap; true status; agency. | -| ✅ PR2 · `SharedPage.tsx:75-148`, legal pages | Shared items render as raw JSON in a 600px scroll box; "Import to Account" is a dead end that also loses the item on sign-in; legal text at 14px and about 100 characters per line | Render with the result components at `max-w-[65ch] text-base leading-7`; remove Import until it exists; legal pages at `text-base leading-7 max-w-[65ch]` | Recipients get developer output; reading measure. | -| ✅ PR1 · `index.html:16`, `public/manifest.webmanifest:9` | `theme-color` fixed at `#0f172a` | Light and dark `theme-color` metas, updated by ThemeProvider | Browser chrome stays dark over the light UI. | -| ✅ PR4 · `src/locales/es.json`, `hu.json`; `Hero.tsx:41-51` | English Title Case carried into Spanish and Hungarian; the hero h1 steps from `text-4xl` to `text-7xl` with one tracking value; a 132-character Spanish announcement in a `rounded-full` pill | Sentence case in es and hu; `text-[clamp(2.25rem,1.2rem+4.5vw,4.5rem)] leading-[1.05] tracking-[-0.03em]`; `rounded-2xl` or shorter copy | Reads as machine-translated; size-specific tracking. | - -## Already done well - -- Motion tokens: `--motion-ease-fluid: cubic-bezier(0.22,1,0.36,1)` and a 160ms fast duration (`index.css:145-149`). Overlays never start from `scale(0)`, and modals stay centred. -- `MotionConfig reducedMotion="user"` (`App.tsx:278`), plus `useReducedMotion` in Navbar, AskChat, Crossfire and Generate. -- No page-transition animation. Route skeletons are static, shaped like their pages, and announce with `role="status"` (`App.tsx:185-198`, `RouteSkeleton.tsx:208`). -- Routes prefetch on pointer enter, focus, pointerdown and touchstart (`NavLink.tsx`, `HomePage.tsx:55`, `AppLayout.tsx:73-75`). -- `theme-init.js` sets the theme before first paint, and the startup shell has dark-mode parity. -- Inputs use `text-base md:text-sm`, so iOS doesn't zoom on focus. The viewport meta removes the tap delay. -- `DebateForm` validates inline with a live counter. The inline debate delete states its consequence. `DebateContextControl` says exactly what the AI will receive before a request. `DebateMotionGate` asks at the moment of ambiguity with three explicit choices. -- Ask batches stream deltas every 30ms, aborts on scope change and keeps partial replies with a note. Citations carry verified and unverified badges, and weak sources are labelled background only. -- Crossfire and Sparring have inline errors with Retry, IME-safe Enter, character counters, a Cmd/Ctrl+Enter hint, offline-aware submit and honest "estimated" labels. Voice has a just-in-time privacy notice and a Stop for opponent audio. -- The Timer counts down against a deadline, not by counting ticks, and has 64px Start and Reset targets. -- Account deletion has a pending state, can't be closed mid-delete and confirms by typing the email. -- The layout holds: no horizontal overflow in any of the 80 page states or in Hungarian at 375px, and no console errors. - -## Not verified - -- Real touch gestures on a device. Drag, swipe and the sticky-hover findings come from code plus emulation. -- WebKit and Safari rendering, including `backdrop-filter` cost. -- Landing at 360px in Spanish and Hungarian (the hero demo toggle and the announcement pill are estimated from string lengths). -- Live-model streaming. The Ask scroll and caret issues come from code and the fake provider's short replies. -- Production or staging. Everything ran on the local stack. - -## Suggested order of work - -1. **Foundations PR.** Tokens, deleting the global CSS layer, overlay curves and origins, the reduced-motion rewrite, hover gating, tap highlight and safe areas. It touches `index.css`, `tailwind.config.ts` and `components/ui/*`, and clears about half the rows. -2. **Shell PR.** Navigation and active states, the brand `<span>`, the mobile menu as a Sheet with a scrim, a shared `PageHeader`, the delete and undo rule, and pending states. -3. **AI tools PR.** Stop and Cancel, the Ask scroll and remount fixes, one honest loading indicator, `ResultHeader`, inline errors with Retry, the AI caveat, Timer fixes. -4. **Landing PR.** Remove the loops, split reveal from hover, transform strings, a static nav, press states, font loading. - -Each PR should run `verify-debatelab` recipes on desktop and mobile, plus a reduced-motion pass and screenshots in both themes. diff --git a/plans/2026-10-16-security-preship-pass.md b/plans/2026-10-16-security-preship-pass.md index ad11e6a0c..809b07788 100644 --- a/plans/2026-10-16-security-preship-pass.md +++ b/plans/2026-10-16-security-preship-pass.md @@ -12,7 +12,7 @@ The checklist follows the shape of a 25-item pre-ship security pass run by paral - Report only. Do not change source. The one exception: for each database finding, write a pgTAP snippet that fails today and paste it into your report. - A Pass needs evidence too: a file and line, or a command and its output. - Hosted settings you cannot read (Supabase Auth dashboard, Google OAuth client, Vercel env, Stripe endpoint config, network restrictions) get the verdict "Needs Adam" with exactly what to check. -- Earlier reviews already list some issues: `plans/2026-10-08-ultracode-review-report.md` (sections 3, 9, 10, 11, 16) and `plans/2026-10-01-next-15.md` (items 1, 2, 4). Check whether each is still open at this head and say so rather than re-discovering it. +- Earlier reviews already list some issues: the 2026-10-08 review report (removed from the tree on 2026-10-29; in git history) (sections 3, 9, 10, 11, 16) and `plans/2026-10-01-next-15.md` (items 1, 2, 4). Check whether each is still open at this head and say so rather than re-discovering it. Severity: Critical (exploitable now, data or money at risk), High (exploitable with modest effort or a precondition), Medium (defence in depth missing where it matters), Low (hardening), Info. diff --git a/plans/README.md b/plans/README.md index 572ed2d43..6b99f8080 100644 --- a/plans/README.md +++ b/plans/README.md @@ -17,24 +17,8 @@ Every plan lives in this folder and opens with a `**Status:**` line; a plan list ## On hold -- **Collaborative Workspace.** Hidden in production. Plans: [debate workspace](2026-09-20_131317-debate-workspace.md) and [debate workspace, reduced scope](2026-09-21-debate-workspace-no-bs.md). +- **Collaborative Workspace.** Hidden in production. Its plans were removed from the tree on 2026-10-29; git history keeps them. ## Shipped -These plans are finished. Most of their files were removed and git history keeps them. The linked plans below are kept as the record, and each says `**Status:** Shipped`. - -- Design audit: UI audit against Emil Kowalski's Apple-design and motion skills, shipped as a four-PR stack (Foundations #124, Shell #126, AI tools, Landing): fixed toast stacking, text-colour tokens, the global CSS override layer, touch and reduced-motion gaps, wayfinding, undo and the landing loops. Plan: [2026-10-09 design audit](2026-10-09-design-audit.md). -- Missing translation keys: 90 literal keys and 40 count forms added to each catalog. Plan: [2026-10-02 missing translation keys](2026-10-02-missing-translation-keys.md). -- Voice Sparring and Crossfire: #84. Plan kept as the record: [2026-10-01 voice sparring and crossfire](2026-10-01-voice-sparring-crossfire.md). -- Debate-aware AI and smaller modules: #75. Plan kept as the record: [2026-09-25 debate AI quality modules](2026-09-25_074255-debate-ai-quality-modules.md). -- Current Debate foundation: #72. Plan kept as the record: [2026-09-25 current debate foundation](2026-09-25_074255-current-debate-foundation.md). -- Email OTP and demo-code sign-in: #40, #41. -- AI control enforcement at the edge: #32, #42. -- System prompt improvement and prompt evaluation: #42. -- Model cost accounting: #46; the direct-provider pricing table was later removed because OpenRouter reports the charge. -- Separate source-import quota (PR #11 salvage): #50. -- Startup and navigation performance: #49, #51, #57. -- Hungarian parity and Jev decisions: merged together before the Workspace release. -- Rate-limit UI cleanup: Settings no longer shows the numeric daily limit. -- Bug backlog of 2026-09-06: the stale source date, the Factoids save label, and Flowchart node-delete feedback are fixed; the nav-versus-heading mismatch went away when the header dropped feature links. -- Earlier: admin announcements (#19, #26), agent verification loop (#21, #26, #27), analytics and error tracking (#16, #18, #22–#25, #28), animation polish (#20), Ask and search quality (#17). +Finished plans are removed from the tree and git history keeps them. Recent ones: Design audit (#124, #126), missing translation keys, Voice Sparring and Crossfire (#84), debate-aware AI modules (#75), and the Current Debate foundation (#72). The 2026-10-08 review report was removed on 2026-10-29; the security pre-ship pass under In progress tracks what is left. diff --git a/public/fonts/OFL-fredoka b/public/fonts/OFL-fredoka new file mode 100644 index 000000000..d25934e19 --- /dev/null +++ b/public/fonts/OFL-fredoka @@ -0,0 +1,93 @@ +Copyright 2016 The Fredoka Project Authors (https://github.com/hafontia/Fredoka-One) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/OFL-nunito b/public/fonts/OFL-nunito new file mode 100644 index 000000000..ae9dd5a09 --- /dev/null +++ b/public/fonts/OFL-nunito @@ -0,0 +1,93 @@ +Copyright 2014 The Nunito Project Authors (https://github.com/googlefonts/nunito) Nunito-Italic[wght].ttf: Copyright 2014 The Nunito Project Authors (https://github.com/googlefonts/nunito) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/scripts/staging-sync.mjs b/scripts/staging-sync.mjs index b957259fc..7e5b030fd 100644 --- a/scripts/staging-sync.mjs +++ b/scripts/staging-sync.mjs @@ -27,7 +27,7 @@ import { backendStatus } from "./backend-status.mjs"; export const STAGING_REF = "brifeyypjauyusmkptkx"; export const PRODUCTION_REF = "uziwdtkzofptihahfooo"; -export const GITHUB_REPO = "Programcode/DebateLab"; +export const GITHUB_REPO = "Programcode/DebateOwl"; const STATUS_CONTEXT = "staging-sync"; const DRIFT_ISSUE_TITLE = "Staging is out of sync with main"; diff --git a/supabase/functions/import-source/testdata/README.md b/supabase/functions/import-source/testdata/README.md new file mode 100644 index 000000000..7ae91772b --- /dev/null +++ b/supabase/functions/import-source/testdata/README.md @@ -0,0 +1,9 @@ +# Test data + +`wikipedia-free-public-transport.html.gz` is the article +[Free public transport](https://en.wikipedia.org/wiki/Free_public_transport) +as Wikipedia served it on 2026-10-22, gzipped and unchanged. Its text is by +Wikipedia contributors and is licensed under +[CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/). It is used +only as a parser fixture in `html-text_test.ts`. The DebateOwl license does +not apply to it.