diff --git a/.claude/skills/verify-debatelab/features/case-generation.md b/.claude/skills/verify-debatelab/features/case-generation.md index 943db2124..3ba4b2aa5 100644 --- a/.claude/skills/verify-debatelab/features/case-generation.md +++ b/.claude/skills/verify-debatelab/features/case-generation.md @@ -1,6 +1,6 @@ # Case generation -A signed-in user enters a motion on Generate and receives a structured debate case: thesis, framework, contentions, the predicted opponent case, and counters. The case can be saved to the account and reopened from History, or exported as PDF or DOCX. +A signed-in user enters a motion on Generate and receives a structured debate case: an opening (hook and "we will show N arguments" line), arguments (title, point, explanation, evidence, why it matters), an "If they say…" list, a closing line and a sources list. The role shapes it: the Introductor may add one definition line; R2 brings no new arguments, only `Backing up our case` (the team's earlier arguments) and "If they say…" with one full answer for each thing the other side is likely to say; the Conclusor has 2-3 "Why we win" points instead of new arguments. Saved cases from before still show their old sections under plain names (`How to judge this debate`, `What we have to show`, `They may also say`) when they have content. ## Sub-features @@ -10,8 +10,9 @@ A signed-in user enters a motion on Generate and receives a structured debate ca - `gen-quota` shows the dialog `Daily AI uses used up` when the account has no quota, without calling the provider. - `gen-save` saves a one-off case (the motion dialog answered with `Not now, use it once`) under a title into `cases` with `debate_id: null`. It appears in History under `One-off and unsorted work`, including from a fresh browser context. - `gen-regenerate` regenerates one section. Only that section shows the overlay `Regenerating
…`; the rest of the case and the result toolbar stay on screen, and every regenerate button and the form's submit button are disabled until it finishes. +- `gen-roles` shows the role's shape: on `/app/generate` pick the `Speaker Role` option `Introductor (4 min)` and the Opening card holds a `What we mean:` line; pick `R2 - Rebuttal/Build (6 min)` and there is no `Arguments` section, `Backing up our case` holds the team's earlier arguments, and `If they say…` holds six full answers covering their main arguments, supporting points and answers to our case (standard length); pick `Conclusor (3 min)` and `Why we win` replaces `Arguments`. - `gen-export` downloads a `.pdf` (starting with `%PDF`) and a `.docx` (starting with `PK`). -- `gen-flowchart` builds a flowchart from the Current Debate's case. `/app/flowchart` opens the template; the button `Build from current case` replaces it with the thesis, one node per contention (claim and evidence), the predicted attacks, and the counters, fitted to the canvas. Node titles the app wrote (`Thesis`, `Argument 1`) follow the UI language. +- `gen-flowchart` builds a flowchart from the Current Debate's case. `/app/flowchart` opens the template; the button `Build from current case` replaces it with the thesis, one node per argument (title, point and evidence, or a Conclusor's reasons why we win), what the other side will say, and our answers, fitted to the canvas. Node titles the app wrote (`Thesis`, `Argument 1`) follow the UI language. ## How to get to it (user POV) @@ -33,7 +34,7 @@ Preconditions: - **Cut off.** Call `await fake.truncate("generate")`, start the debate, and generate. The first reply stops halfway with `finish_reason: "length"`; the Edge Function retries once with a larger cap, and the case appears with no error. `fake.requests()` has two `generate` entries, the second with a larger `maxTokens`. Fake mode only. - **Cancel.** While a generation runs, the results column shows `Writing your case…` and a `Cancel` button. Cancel stops the request and shows no error. - **Quota.** Update `user_settings` to set `daily_limit_override: 0` through `admin`, start the debate, then generate. The dialog shows `Daily AI uses used up`. `fake.requests()` has no `generate` entry. -- **Regenerate a section.** After generating, click the button named `Regenerate Arguments` (the section titles are `Case structure`, `Arguments`, `Predicted opponent case`, `Prepared counters`). Hold the request with `page.route("**/functions/v1/ai", ...)` to check the in-flight state: a `status` with `Regenerating Arguments` is visible and `Regenerate Case structure` is disabled. The toast `Section regenerated` follows. A full generation shows no toast: the result appears in place (and scrolls into view on phones). +- **Regenerate a section.** After generating, click the button named `Regenerate Arguments` (the section titles are `Opening`, `Arguments`, `If they say…` and `Closing line`; an R2 case shows `Backing up our case` instead of `Arguments`, and a Conclusor case shows `Why we win` instead of `Arguments` and `If they say…`). Hold the request with `page.route("**/functions/v1/ai", ...)` to check the in-flight state: a `status` with `Regenerating Arguments` is visible and `Regenerate Opening` is disabled. The toast `Section regenerated` follows. A full generation shows no toast: the result appears in place (and scrolls into view on phones). - **Flowchart.** After generating in the debate, open `/app/flowchart` and click the button named `Build from current case` (Hungarian: `Létrehozás a jelenlegi esetből`). It appears only when the case belongs to the Current Debate, or to one-off work while no debate is chosen. The contention claims are visible, and an edge is a button named `From Thesis to Argument 1`. - **Export.** Wait for `page.waitForEvent("download")` while clicking the button named `PDF` or `DOCX` (use `exact`). Check the suggested filename's extension and the file's magic bytes. diff --git a/.claude/skills/verify-debatelab/features/live-generation.md b/.claude/skills/verify-debatelab/features/live-generation.md index 2defe01ea..aa0d74b00 100644 --- a/.claude/skills/verify-debatelab/features/live-generation.md +++ b/.claude/skills/verify-debatelab/features/live-generation.md @@ -4,7 +4,7 @@ Every AI answer appears while the model writes it. Generate, Attack, Brainstorm, ## Sub-features -- `live-generate`: Generate's draft shows `Case structure` and later sections before the result, then the full result and its toolbar replace it. +- `live-generate`: Generate's draft shows `Opening` and later sections before the result, then the full result and its toolbar replace it. - `live-cancel`: `Cancel` on the draft stops the request; no draft, result, or error stays on the page. - `live-tools`: Attack, Brainstorm, Factoids, and cited Ask each ask for live generation and receive an event stream. - `live-practice`: Crossfire turns and debriefs and Sparring speeches and scorecards ask for live generation (checked in `recipes/practice.spec.ts` with `expectStreamed`). @@ -20,7 +20,7 @@ Preconditions: - Doctor passes. The user is signed in. `startDebate(page, motion)` avoids the motion dialog. -- **Draft.** Click `Generate debate case`. `getByTestId("live-draft")` appears, holding a region named `Case structure`, one `live-caret`, and a `Cancel` button. It disappears when the result renders. +- **Draft.** Click `Generate debate case`. `getByTestId("live-draft")` appears, holding a region named `Opening`, one `live-caret`, and a `Cancel` button. It disappears when the result renders. - **Request.** The page's `ai` request body has `stream: true`. The response is `text/event-stream`: `phase` events (`research`, `writing`, `checking`), many `delta` events with the model's raw text, an optional `reset` when a repair retry starts the answer again, and exactly one final `result` event `{status, body}` holding what a plain request would return. Read it with `readAIResponse(response)`. - **Provider.** `expectStreamed(["generate"])`: every provider call for the feature had `stream: true`. Research sub-calls (`research-decision`, `research-queries`) are never streamed. - **Errors.** Checks before the feature (auth, input, quota, model access) still answer with plain JSON and their real status. A failure after streaming starts arrives as a `result` with the error status, which the client turns into the same error a plain request gets. diff --git a/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts b/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts index 181585152..5020e69c3 100644 --- a/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts @@ -1,5 +1,5 @@ import { readFile } from "node:fs/promises"; -import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, type Evidence, expect, expectProviderCalls, fake, LIVE, signIn, startDebate, test } from "./fixtures"; +import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, type Evidence, expect, expectProviderCalls, fake, LIVE, providerCalls, signIn, startDebate, test } from "./fixtures"; // Feature map: features/case-generation.md. const motion = "This House would make public transport free at the point of use"; @@ -149,6 +149,73 @@ test("gen-flowchart: the Flowchart builds from the Current Debate's case, and it await evidence.shot("02-built-from-case-hu"); }); +test("gen-roles: the speaker role shapes the case: Introductor, R2 and Conclusor", async ({ signedIn: page, evidence }) => { + await openGenerate(page); + const pickRole = async (name: string) => { + await page.getByRole("combobox", { name: "Speaker Role" }).click(); + await page.getByRole("option", { name }).click(); + }; + const run = async (role: string) => { + const response = aiResponse(page, "generate"); + await page.getByRole("button", { name: "Generate debate case" }).click(); + const body = await response; + const call = (await providerCalls()).filter((item) => item.feature === "generate").at(-1); + expect(call?.user).toContain(`Role: ${role}`); + return body.output as unknown as CaseOutput & { + opening?: { definition?: string }; + counters: unknown[]; + reinforce?: Array<{ argument: string; text: string }>; + whyWeWin?: string[]; + closingLine?: string; + }; + }; + + await pickRole("Introductor (4 min)"); + const intro = await run("intro"); + await expect(page.getByText(intro.caseStructure.thesis).first()).toBeVisible(); + await expect(page.getByRole("button", { name: "Regenerate Opening" })).toBeVisible(); + if (intro.opening?.definition) await expect(page.getByText("What we mean:")).toBeVisible(); + if (!LIVE) expect(intro.opening?.definition).toBeTruthy(); + await evidence.shot("01-introductor"); + await evidence.aria("01-introductor"); + + await pickRole("R2 - Rebuttal/Build (6 min)"); + const r2 = await run("r2"); + await expect(page.getByText(r2.caseStructure.thesis).first()).toBeVisible(); + // Only the Introductor defines a term. + expect(r2.opening?.definition).toBeFalsy(); + await expect(page.getByText("What we mean:")).toHaveCount(0); + // R2 brings no new arguments: it backs up the team's, then answers theirs. + expect(r2.contentions).toEqual([]); + expect(r2.reinforce?.length ?? 0).toBeGreaterThanOrEqual(1); + await expect(page.getByRole("button", { name: "Regenerate Arguments" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Regenerate Backing up our case" })).toBeVisible(); + for (const item of r2.reinforce ?? []) await expect(page.getByText(item.text).first()).toBeVisible(); + await expect(page.getByRole("button", { name: "Regenerate If they say…" })).toBeVisible(); + await expect(page.getByText("They say", { exact: true })).toHaveCount(r2.counters.length); + // One full answer for each thing they are likely to say: standard length + // asks for six, and the Edge Function keeps a live reply with at least four. + if (LIVE) { + expect(r2.counters.length).toBeGreaterThanOrEqual(4); + expect(r2.counters.length).toBeLessThanOrEqual(6); + } else { + expect(r2.counters).toHaveLength(6); + } + await evidence.shot("02-r2"); + await evidence.aria("02-r2"); + + await pickRole("Conclusor (3 min)"); + const conc = await run("conc"); + expect(conc.contentions).toEqual([]); + expect(conc.whyWeWin?.length ?? 0).toBeGreaterThanOrEqual(2); + await expect(page.getByRole("button", { name: "Regenerate Why we win" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Regenerate Arguments" })).toHaveCount(0); + for (const reason of conc.whyWeWin ?? []) await expect(page.getByText(reason).first()).toBeVisible(); + if (conc.closingLine) await expect(page.getByText(conc.closingLine).first()).toBeVisible(); + await evidence.shot("03-conclusor"); + await evidence.aria("03-conclusor"); +}); + test("gen-research: generation researches the web and cites what it found", async ({ signedIn: page, evidence }) => { const { body } = await generate(page); await evidence.json("01-generate-response", body); @@ -161,8 +228,10 @@ test("gen-research: generation researches the web and cites what it found", asyn } expect(sources.length, "web sources found by research").toBeGreaterThan(0); expect(sources.every((source) => /^https?:\/\//.test(source.url))).toBe(true); - const panel = page.getByText("Researched sources").locator(".."); + // The sources fold under the result header; open them as a student would. + const panel = page.getByRole("group").filter({ hasText: /^Researched sources/ }); await expect(panel).toBeVisible(); + await panel.getByText(/^Researched sources/).click(); await expect(panel.getByRole("link").first()).toHaveAttribute("href", /^https?:\/\//); await evidence.shot("03-researched-sources"); }); @@ -202,7 +271,7 @@ test("gen-regenerate: regenerating one section keeps the rest of the case and it await expect(overlay).toBeVisible(); await expect(page.getByText(thesis).first()).toBeVisible(); await expect(page.getByRole("button", { name: "PDF", exact: true })).toBeVisible(); - await expect(page.getByRole("button", { name: "Regenerate Case structure" })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Regenerate Opening" })).toBeDisabled(); // The form button reads "Generating..." while busy, so find it by its tour anchor. await expect(page.locator('[data-tour="generate-button"]')).toBeDisabled(); await evidence.shot("02-regenerating-one-section"); @@ -214,7 +283,7 @@ test("gen-regenerate: regenerating one section keeps the rest of the case and it await expect(page.getByText("Section regenerated").first()).toBeVisible({ timeout: AI_TIMEOUT }); await expect(overlay).toHaveCount(0); await expect(page.getByText(thesis).first()).toBeVisible(); - await expect(page.getByRole("button", { name: "Regenerate Case structure" })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Regenerate Opening" })).toBeEnabled(); await evidence.shot("03-regenerated"); }); diff --git a/.claude/skills/verify-debatelab/recipes/live-generation.spec.ts b/.claude/skills/verify-debatelab/recipes/live-generation.spec.ts index 214059c9b..941d84651 100644 --- a/.claude/skills/verify-debatelab/recipes/live-generation.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/live-generation.spec.ts @@ -43,7 +43,7 @@ test("live-generate: a case fills in section by section while the model writes i // Before the answer is complete, the draft shows what is written so far. const draft = page.getByTestId("live-draft"); await expect(draft).toBeVisible({ timeout: AI_TIMEOUT }); - await expect(draft.getByRole("region", { name: "Case structure" })).toBeVisible(); + await expect(draft.getByRole("region", { name: "Opening" })).toBeVisible(); await expect(draft.getByTestId("live-caret")).toHaveCount(1); await expect(draft.getByRole("button", { name: "Cancel" })).toBeVisible(); await evidence.shot("01-writing-live"); diff --git a/agentic/case-generation.e2e.ts b/agentic/case-generation.e2e.ts index 581ddeb82..50183c37d 100644 --- a/agentic/case-generation.e2e.ts +++ b/agentic/case-generation.e2e.ts @@ -18,7 +18,7 @@ test("a student starts a debate and generates a case for it", { ...agentic, tags return data; }, { timeout: 15_000 }).toEqual([{ debates: { motion: MOTION } }]); - await agent.assert("The page shows a generated debate case: a thesis and several contentions, each with a claim and supporting reasoning."); + await agent.assert("The page shows a generated debate case: an opening and several arguments, each with a point and an explanation of why it is true."); if (LIVE) { // Recipes never assert on model wording; a judge can check that the case is on topic. await agent.assert(`The generated case argues about the motion "${MOTION}", not about an unrelated topic.`); @@ -29,5 +29,5 @@ test("a student starts a debate and generates a case for it", { ...agentic, tags await agent.act("Open History and load the debate case you generated."); await expect(screen.getByRole("button", "Regenerate Arguments")).toBeVisible(); if (!LIVE) await expect(fakeAnswer(screen)).toBeVisible(); - await agent.assert("A debate case with a thesis and contentions is open again on screen."); + await agent.assert("A debate case with an opening and arguments is open again on screen."); }); diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 24bfe383e..66df16541 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -18,7 +18,7 @@ The iOS app DebateOwl (`@debatelab/mobile`; the bundle id is still `com.adam.deb ## Layout -- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; the form sheets `debates.tsx` (switcher), `new-debate.tsx`, `delete-account.tsx`, `delete-data.tsx`, `add-link.tsx`, `new-note.tsx`, `add-file.tsx` (Import file), `source/[id].tsx`, `edit-debate.tsx` (Edit settings), `debate-check.tsx` (the five-day check), `limit.tsx` (the website's limit-reached and model-restricted dialogs), `save-case.tsx` (Save case with a title) and `context-review.tsx` (the debate context Review); and `ask.tsx`, `generate.tsx`, `case/[id].tsx`, `attack/`, `brainstorm/`, `factoids/` and `sparring/` (each a form `index.tsx` and a document `[id].tsx`), `crossfire.tsx` and `timer.tsx` (a full-screen modal), `flowchart/` (the screen `index.tsx` with its `card`, `claim` and `maps` form sheets), and `history.tsx`, full screen above the tabs. All are declared in the root `_layout.tsx`. Every Practice tool is in the app, so `(practice)/soon/[tool].tsx` is no longer reached from the Practice tab. +- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; the form sheets `debates.tsx` (switcher), `new-debate.tsx`, `delete-account.tsx`, `delete-data.tsx`, `add-link.tsx`, `new-note.tsx`, `add-file.tsx` (Import file), `source/[id].tsx`, `edit-debate.tsx` (Edit settings), `debate-check.tsx` (the five-day check), `limit.tsx` (the website's limit-reached and model-restricted dialogs), `save-case.tsx` (Save case with a title), `context-review.tsx` (the debate context Review) and `welcome.tsx` (Ollie's welcome and the first-run tour); `share/[code].tsx`, the read-only viewer for a share link, outside the sign-in guard; `+native-intent.tsx`, which maps website links to app routes; and `ask.tsx`, `generate.tsx`, `case/[id].tsx`, `attack/`, `brainstorm/`, `factoids/` and `sparring/` (each a form `index.tsx` and a document `[id].tsx`), `crossfire.tsx` and `timer.tsx` (a full-screen modal), `flowchart/` (the screen `index.tsx` with its `card`, `claim` and `maps` form sheets), and `history.tsx`, full screen above the tabs. All are declared in the root `_layout.tsx`. Every Practice tool is in the app, so `(practice)/soon/[tool].tsx` is no longer reached from the Practice tab. - `src/components/`: shared UI. `app-tabs.tsx` uses native tabs; `app-tabs.web.tsx` draws the mock's tab bar for the web preview. - `src/lib/`: auth (`auth.tsx`), `native-capabilities.ts` (what the running binary has beyond Expo Go), the Supabase client, session storage (AES key in SecureStore, ciphertext in AsyncStorage) and i18n. `debates.ts`, `settings.tsx`, `subscription.ts` and `account.ts` read and write the website's tables the way its stores do (`src/lib/stores/debate-store.ts`, `settings-store.ts`, `src/hooks/use-subscription.ts`, `src/lib/account.ts`), so the app and the website share one Current Debate and one set of settings. Change them together with the website code they mirror. - `sources.ts` mirrors the session store's source actions (`addSource` with Undo's restore, `updateSource`, `removeSource`, `toggleSource` and `setFolderActive` as `setSourcesActive`, the folder actions and `clearLibrary`) and `src/lib/source-import.ts`. Switch changes run in one serial queue, so they reach the server in the order the student made them; a screen shows a change at once and puts it back when the write fails (`source-actions.ts`, with the website's messages). The Library always shows the Current Debate, also in one-off mode, as on the website. Its folder list, search and eight sort orders are the website's, in `packages/core/src/library.ts`. Scan pages is not in the app yet. @@ -27,7 +27,7 @@ The iOS app DebateOwl (`@debatelab/mobile`; the bundle id is still `com.adam.deb - Debates: `new-debate.tsx` and `edit-debate.tsx` share `components/debate-form.tsx`, the website's DebateForm plus the judge (`settings.judge`, which the website's prompts read but its form does not show). The switcher deletes any debate but the current one (the database cascades its sources and work). `use-inactive-debate-check.ts` opens `debate-check.tsx`, the website's five-day check; closing it counts as Continue. - Rows of saved work and sources are `Link`s with `Link.Preview` and `Link.Menu`: a long press shows the document and its actions (`work-actions.ts`: Discuss, Share, and Delete where the website's History deletes). - Ask mirrors `src/lib/ai/streaming.ts` and `askChat` in `src/lib/ai/client.ts`: `ask.ts` sends the requests (plain questions stream from `ai-stream` through `expo/fetch`; with Citations, Web search, Pros/Cons or Argument map on, the whole answer comes from `ai`, because `ai-stream` refuses those options), `ai-request.ts` holds the shared POST with the website's headers, timeout and error kinds, `feature-config.ts` reads each tool's model from `user_settings.feature_configs` (the model picker, `components/model-picker.tsx`, writes it back through `settings.tsx`'s serial queue in the website's canonical shape; tools read it through the same queue, so they run with the model just picked), `context-request.ts` builds the Current Debate context from `context-plan.ts`, which reads a debate's list of material when a form opens, and the text only of the items a request sends (kept in `context-texts.ts` under each row's version: a case's generatedAt, a chat's message count, a round's updated_at; source text is read again after each list read, and `updateResult` and `saveChat` forget what they change), and plans it with the website's planner (`packages/core/src/debate/context-plan.ts`), with the Review choices for one request (Crossfire keeps them for its round) and the pins in `debate_context_pins` (three at most); the line on each tool form, Crossfire, Sparring and Ask (`components/context-line.tsx`) and its Review sheet read the same plan, and `chats.ts` saves finished exchanges to `chats` and `messages`. The SSE parser is shared in `packages/core/src/ai/sse.ts`. `markdown.ts` reads answers, because react-markdown needs the DOM. -- Generate and Attack mirror `generateCase` and `attackCase` in `src/lib/ai/client.ts` and the session store's `recordResult`: `results.ts` sends the requests through `ai-request.ts`'s `postAI`, which streams them as the website does (`stream: true` with a `resultKey`; the reader is `packages/core/src/ai/live.ts`, shared with the website's `src/lib/ai/live.ts`) and reads a dropped stream's answer back from `ai_run_results` (`packages/core/src/ai/run-recovery.ts`). The four tool screens show the draft with `components/live-run.tsx` (the website's `LiveToolRun`; the sections come from `packages/core/src/ai/live-sections.ts`). `results.ts` also checks the output with the website's transform (moved to `packages/core/src/ai/generate-output.ts`), keeps researched sources in the debate's Auto-saved folder (`packages/core/src/ai/discovered-sources.ts`), saves the server's source rankings onto the Library sources a result used (`packages/core/src/ai/source-assessments.ts`, also for Ask's whole answers), says when the server shortened the input, and saves rows to `cases` and `attacks`. `motion.ts` and `motion-gate.ts` are the website's motion gate. `open-results.ts` holds results made in this session, so one-off work and failed saves still open as documents. It and the last Generate and Attack forms live in `session-memory.ts`, which the auth provider clears on every account change, including sign-out. `components/document.tsx` draws results as documents (mock 03); Brainstorm and Factoids reuse it. Export renders `export-html.ts` to a PDF with expo-print and shares it (`export.ts`); the web preview cannot print, so `export.web.ts` keeps the HTML and copied text on `window` for the screenshot script. Word export and Google Docs are not in the app. +- Generate and Attack mirror `generateCase` and `attackCase` in `src/lib/ai/client.ts` and the session store's `recordResult`: `results.ts` sends the requests through `ai-request.ts`'s `postAI`, which streams them as the website does (`stream: true` with a `resultKey`; the reader is `packages/core/src/ai/live.ts`, shared with the website's `src/lib/ai/live.ts`) and reads a dropped stream's answer back from `ai_run_results` (`packages/core/src/ai/run-recovery.ts`). The four tool screens show the draft with `components/live-run.tsx` (the website's `LiveToolRun`; the sections come from `packages/core/src/ai/live-sections.ts`). `results.ts` also checks the output with the website's transform (moved to `packages/core/src/ai/generate-output.ts`), keeps researched sources in the debate's Auto-saved folder (`packages/core/src/ai/discovered-sources.ts`), saves the server's source rankings onto the Library sources a result used (`packages/core/src/ai/source-assessments.ts`, also for Ask's whole answers), says when the server shortened the input, and saves rows to `cases` and `attacks`. `motion.ts` and `motion-gate.ts` are the website's motion gate. `open-results.ts` holds results made in this session, so one-off work and failed saves still open as documents. It and the last Generate and Attack forms live in `session-memory.ts`, which the auth provider clears on every account change, including sign-out. `components/document.tsx` draws results as documents (mock 03); Brainstorm and Factoids reuse it. Export renders `export-html.ts` to a PDF with expo-print and shares it (`export.ts`); the web preview cannot print, so `export.web.ts` keeps the HTML and copied text on `window` for the screenshot script. Word export uses the website's builders, moved to `packages/core/src/export/docx.ts`: `export-docx.ts` packs them with `Packer.toBase64String` and shares the .docx (`export-docx.web.ts` keeps it on `window` for the screenshot script). Google Docs is not in the app. - Brainstorm and Factoids mirror `brainstorm` and `factoids` in `src/lib/ai/client.ts` and their pages, through the same `results.ts`: the brainstorm transform and the verified-factoid selection live in `packages/core/src/ai/brainstorm-output.ts` and `factoids-output.ts`, rows go to `brainstorms` and `factoids`. Brainstorm has the motion gate; Factoids, like Attack, follows the Current Debate and saves with side `neutral`. A factoid's bookmark saves it to the Current Debate's `Factoids` folder, and a search suggestion reopens the Factoids form with that query. `prepareToolHref` in `components/prepare-tools.ts` is where both tabs open a tool. - A case's Regenerate and Undo are `case-edits.ts`: one part is replaced from a new generation with the website's merge (`mergeRegeneratedSection` in `packages/core/src/ai/generate-output.ts`), earlier versions are kept for the session, and Undo writes the restored version back to the saved row (`updateResult`). Held results are observable (`subscribeOpenResults`), so the document shows a change made from its menu or its sheets. - Ask's Stop keeps the answer so far, marked as stopped. Its General and Clarify modes go in the request; Clarify and the options `ai-stream` refuses stream live from the `ai` function through `postAI`. An answer saves to the Current Debate's `Ask` folder. Attachments (`ask-attachments.ts`, expo-document-picker, in Expo Go) are read on the phone (`read-file.ts`) and sent in the website's format (`packages/core/src/ai/ask-files.ts`). @@ -41,11 +41,15 @@ The iOS app DebateOwl (`@debatelab/mobile`; the bundle id is still `com.adam.deb - Announcements mirror the website's banner (`AppLayout.tsx`, `AnnouncementBanner.tsx`) at the top of the Debate tab: `announcements.ts` lists and dismisses through the `announcements` Edge Function, and `use-announcements.ts` keeps the website's startsAt/expiresAt filter (shared in `packages/core/src/announcements.ts`), its one-minute reload guard, and reloads on focus, foreground and account change. Dismissals are stored on the server, so they are shared with the website. - You mirrors the website's Settings for what the app can do: Download my data (`exportAccountData` in `account.ts`, the `account` function's export written as `debatelab-data-.json` and shared through `shareJson`), Delete all my data (`delete-data.tsx`, the email-typed confirmation and `clear_current_user_data`; afterwards session memory is forgotten, one-off mode turns off and the debates reload, as the website clears its stores), and Terms and Privacy, opened in the in-app browser from `app.config.ts`'s `extra.websiteUrl` (`website.ts`). The plan card shows the website's billing states, dates and wording (`SubscriptionStatus.tsx`): `subscription.ts` reads `user_settings`, Stripe `subscriptions` and `app_store_subscriptions` through the shared reader (`packages/core/src/supabase/billing.ts`, via `billing-query.ts`) and resolves them with the resolver the AI functions use (`supabase/functions/_shared/billing-state.ts`); usage is the server's own counter (`ai_usage_today`). Upgrade, Manage subscription and promo codes wait for in-app purchase. - One-off mode is session-only and never written, as on the website; only maps saved in it are kept, with no debate, as the website keeps them. The switcher turns it on with or without a Current Debate, and `OneOffNotice` shows it on the Debate tab, Prepare, Ask, Library and the Generate, Attack, Brainstorm, Factoids, Crossfire and Sparring forms (through `WorkingIn`) and the Flowchart. The Debate tab, Prepare and Library still list the Current Debate's work in one-off mode, as the website's home does. Students prepare both sides of a motion, so a debate has no side: the Debate tab's In favor / Against toggle only chooses which side's work it shows, using the website's rule (`'both'` counts for either side, neutral and side-less work for neither). It starts on In favor and is never stored. -- Offline reading: `offline-cache.ts` keeps a copy of every read in `debates.ts`, `sources.ts`, `results.ts`, `flowcharts.ts`, `chats.ts`, `sparring.ts` and `history.ts` (the debates list, counts and recent work, Library sources with their text, saved results, maps, chats, judged Sparring rounds and History's lists) in AsyncStorage under the user id. A read goes to the network first and saves its answer; when no answer comes back (supabase-js status 0: no connection, DNS, timeout), or `network-status.ts` (expo-network) says the device is offline, the copy answers and `OfflineNotice` shows at the top of `Screen`, `DocumentScreen` (including its iPad outline rail), the Flowchart and the iPad Library list until each copy shown is read from the server again or the screen showing it closes, moves on or leaves view (a screen passes `useCopyHolder().mark()` with its reads). A server error (an expired session, a policy, a 5xx) is thrown, never answered from a copy; a read made of several queries runs them through `readAll` (or `pickFailure`), so one query's refusal wins over another's missing answer. A slower answer never replaces a newer copy, each group keeps its most recently fetched copies only (`LIMITS`), and the auth provider calls `setCacheOwner` before it adopts a session, so another account's copies are removed before its first read. Nothing is written offline: AI and saving fail with their network errors, and there is no write queue. A read added to those modules goes through `cachedRead`, takes an optional `CopyMark`, and throws `readFailed(error, status)`. Every write that succeeds calls `afterWrite` with the copies it changed (`debateListScopes` names a debate's counts and lists; a delete uses `deletedListScopes`, which drops every debate's when no row came back), which drops them and stops older reads from saving over them, or `updateCopy` when it knows the new value. `debateListScopes` also names History's copy (`one-off` for work outside every debate); History's add-to-debate and filing a judged Sparring round into its debate both drop the item's copies on both sides, and Delete all my data drops every copy (`ALL_COPIES`). +- Offline reading: `offline-cache.ts` keeps a copy of every read in `debates.ts`, `sources.ts`, `results.ts`, `flowcharts.ts`, `chats.ts`, `sparring.ts` and `history.ts` (the debates list, counts and recent work, Library sources with their text, saved results, maps, chats, judged Sparring rounds and History's lists) in AsyncStorage under the user id. A read goes to the network first and saves its answer; when no answer comes back (supabase-js status 0: no connection, DNS, timeout), or `network-status.ts` (expo-network) says the device is offline, the copy answers and `OfflineNotice` shows at the top of `Screen`, `DocumentScreen` (including its iPad outline rail), the Flowchart and the iPad Library list until each copy shown is read from the server again or the screen showing it closes, moves on or leaves view (a screen passes `useCopyHolder().mark()` with its reads). A server error (an expired session, a policy, a 5xx) is thrown, never answered from a copy; a read made of several queries runs them through `readAll` (or `pickFailure`), so one query's refusal wins over another's missing answer. A slower answer never replaces a newer copy, each group keeps its most recently fetched copies only (`LIMITS`), and the auth provider calls `setCacheOwner` before it adopts a session, so another account's copies are removed before its first read. AI and saving results fail offline with their network errors. New notes, source edits and Active switches go through `offline-queue.ts` instead (`use-offline-queue.ts` starts it in the root layout): kept on the phone under the user id, replayed in order on launch, reconnect, foreground and every 30 seconds while anything waits, merged per source, dropped and reported when the server refuses them, and shown as "Waiting to sync" meanwhile. A queued note has a `local:` id until it syncs. A read added to those modules goes through `cachedRead`, takes an optional `CopyMark`, and throws `readFailed(error, status)`. Every write that succeeds calls `afterWrite` with the copies it changed (`debateListScopes` names a debate's counts and lists; a delete uses `deletedListScopes`, which drops every debate's when no row came back), which drops them and stops older reads from saving over them, or `updateCopy` when it knows the new value. `debateListScopes` also names History's copy (`one-off` for work outside every debate); History's add-to-debate and filing a judged Sparring round into its debate both drop the item's copies on both sides, and Delete all my data drops every copy (`ALL_COPIES`). - Push: `push.ts` asks for permission and files the Expo push token under the signed-in user with the `register_push_token` RPC; the You tab's Notifications switch turns it on and off, the root layout registers again on launch and when the language changes, and sign-out releases the registration with the account's own access token, after any registration still in flight. Every registration carries the ID of the local record, and `release_push_token` withdraws that ID and deletes only the rows it wrote, so a registration the app gave up on is refused if it reaches the database after the release. Once a release is sent for an ID, the record is marked released and the next registration gets a new ID, so a late release cannot delete it. The local record is cleared only once a release succeeds; otherwise the next change on the phone, from any account, releases it first. The server side is in [supabase/README.md](../../supabase/README.md#push-notifications). `push.web.ts` registers nothing and only flips the switch for the screenshot script. - No build can get a token until `app.config.ts` has `extra.eas.projectId`, from `eas init` on build day (see [docs/development.md](../../docs/development.md#build-day)). Until then the switch shows as unavailable, on Expo Go too. Expo Go on Android has no remote push since SDK 53, and Expo recommends a development build for remote push on iOS as well. - Prebuild applies the `expo-notifications` config plugin automatically once the package is installed, even though `app.config.ts` does not list it, and the plugin sets the `aps-environment` entitlement. `DEBATELAB_BUILD=dev` removes it again (`withoutPushEntitlement`), because the borrowed Signulous profile has no push, so a development build cannot get a token even with a project ID: turning the switch on there fails. Real push needs a provisioning profile with the Push Notifications capability and an APNs key uploaded to EAS, both from Adam's own Apple Developer account; only then can `withoutPushEntitlement` go. -- iPad layouts (iPad mock A, with mock C's docked claim) come from one hook, `useLayoutClass()` in `src/lib/layout.ts`, which also holds every width they use: `phone` on every iPhone and in iPad windows under 700 points, `regular` from 700, `wide` from 1100. Phone layouts must not change, so each screen keeps its phone JSX and branches before it; compare iPhone screenshots before and after. NativeTabs is `sidebarAdaptable` (the iPadOS 18 sidebar; `app-tabs.web.tsx` draws a sidebar or a top tab bar for the preview). The Library opens a source beside its list (`SourceView`, shared with the source sheet), Ask lists its conversations beside the chat (`ask-chat-list.tsx`), the Debate tab is a two-column dashboard, saved documents get an "On this page" rail from 900 points (`DocumentScreen`'s `outline`, `components/outline.tsx`), and the Flowchart docks a card's claim beside the map (`ClaimPanel` in `claim-panel.tsx`, shared with the claim sheet). A docked claim or a source open beside its list is dropped when that map, debate, account or row is no longer the one on screen. All are plain views sized from the window, so they run in Expo Go. +- iPad layouts (iPad mock A, with mock C's docked claim) come from one hook, `useLayoutClass()` in `src/lib/layout.ts`, which also holds every width they use: `phone` on every iPhone and in iPad windows under 700 points, `regular` from 700, `wide` from 1100. Phone layouts must not change, so each screen keeps its phone JSX and branches before it; compare iPhone screenshots before and after. A full-screen iPad window from the regular width shows the app's own sidebar (`components/app-sidebar.tsx`, rows in `sidebar-items.ts`) beside the root stack, so it stays while a tool is open: the Current Debate switcher, then every tool under collapsible Debate, Prepare, Practice, Library and You sections (closed sections are kept on the device). NativeTabs hides its bar there (`hidden`), and `app-tabs.web.tsx` hides the preview's; Split View, Slide Over, Stage Manager windows and widths under 700 keep the tab bar (`sidebarWidthFor`). A sidebar row closes the screens above the tabs before it opens its own, so the sidebar never stacks tools. Screens lay out for the room beside the sidebar: `useLayoutClass()` and `useContentWidth()` subtract `ReservedWidth`, which sheets reset to 0; never size a screen from `useWindowDimensions()` directly. The Prepare and Practice tab pages are not in the sidebar: the Debate dashboard and History cover their lists. The Library opens a source beside its list (`SourceView`, shared with the source sheet), Ask lists its conversations beside the chat (`ask-chat-list.tsx`), the Debate tab is a two-column dashboard, saved documents get an "On this page" rail from 900 points (`DocumentScreen`'s `outline`, `components/outline.tsx`), and the Flowchart docks a card's claim beside the map (`ClaimPanel` in `claim-panel.tsx`, shared with the claim sheet). A docked claim or a source open beside its list is dropped when that map, debate, account or row is no longer the one on screen. All are plain views sized from the window, so they run in Expo Go. +- Share links (`share-links.ts`, `use-share-link.ts`): a saved case, attack, brainstorm or factoids document's menu calls the `share` Edge Function with the session and reuses a link made this session for the same unchanged result, because every call makes a new link and counts toward the daily limit. With no browser Origin the function builds debateowl.com links, so links made against staging open production's site. `share/[code].tsx` reads a link back with the public `get_shared_item` RPC. Universal links (`deep-links.ts`, a tested parser that honours only https debateowl.com and www) need the Associated Domains entitlement, which `app.config.ts` adds to development builds, and the website's apple-app-site-association file, which is not deployed. +- The first run (`first-run.ts`, `use-first-run.ts`, `welcome.tsx`) uses the website's keys: Ollie's welcome once per phone, the four-step tour once per account. The web preview opens it only with `?intro=show` (`first-run.web.ts`). `components/trial-banner.tsx` is the website's trial reminder (days left from `packages/core/src/trial.ts`); `auth.tsx`'s `sessionExpired` is set when the server ends a session (a refused refresh), never for a sign-out the student asked for, and the sign-in screen shows the website's notice. +- Accessibility: every text field is `AppTextInput` (`components/app-text-input.tsx`), with the one Done bar above the keyboard (`KeyboardDoneBar` in the root layout); `Screen`, `DocumentScreen` and sheets scroll fields clear of the keyboard. Text grows with Dynamic Type; only fixed chrome sets `maxFontSizeMultiplier` (floating action-bar buttons and segmented controls 1.6, the sidebar 1.35). The web preview draws a text size from `window.__debateowlFontScale` (`theme/font-scale.web.ts`, the script's `FONT_SCALE`). `a11y.ts` announces a finished Ask answer, Crossfire reply, Sparring speech or tool result once; never make streamed text a live region. iPad shortcuts (⌘N, ⌘K, ⌘↩) go through `key-commands.ts`, which works in the web preview only until a UIKeyCommand module is in the development build; the timer's space bar belongs to the timer work. +- The Current Debate widget (`widgets/current-debate.tsx`, published by `debate-widget.ts` from the debate context and the Debate tab) needs a development build with `DEBATELAB_APP_GROUP`. Quick actions (`quick-actions.ts`) and Spotlight (`spotlight.ts`) switch on only when `expo-quick-actions` or `expo-core-spotlight` is installed; neither is yet. `app.config.ts`'s round-4 section adds their native config for development builds only. - `src/theme/`: the website's DebateOwl design tokens (`src/styles/tokens.css` and the roles in `src/index.css`) in light and dark. `primary` is the blue that passes as text; `primaryFill` with `primaryLedge` is the button face and the ledge it sits on. Success is mint and always comes with a check mark. Fredoka (`weight="display"`) is for titles, the motion and button labels, Nunito for everything else, loaded with expo-font from `@expo-google-fonts`. Buttons are the website's tactile ones (`components/buttons.tsx`), which sink onto their ledge while pressed. Cards and fields have 2pt edges. - `components/owl.tsx`: Ollie, from PNGs of the website's owl SVGs (`src/assets/owl`, `public/owl-mark.svg`), in the pose the website uses for each place: `point` for an empty start, `coach` beside Ask's answers, `arguing` for the Sparring opponent, `reading`, `surprised` and `sleepy` in empty states (`components/empty-state.tsx`), and a tool's pose while it works (`ToolProgress`). `scripts/render-owls.ts` renders them, the app icon, the splash image and the Android icons again after the website's owl changes. - `scripts/screenshots.ts`: web-export screenshots. `scripts/render-tab-icons.ts` redraws the tab bar PNGs. diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 14320679f..e3b322bec 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -1,9 +1,11 @@ import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; import path from 'node:path'; import type { ConfigContext, ExpoConfig } from 'expo/config'; import { type ConfigPlugin, withEntitlementsPlist } from 'expo/config-plugins'; + // One config for Expo Go, the web export and the development build. // DEBATELAB_BUILD=dev (set by the `development` profile in eas.json) adds the // native modules Expo Go lacks. Without it the config is the one Expo Go has @@ -52,6 +54,107 @@ const withoutPushEntitlement: ConfigPlugin = (config) => return mod; }); +// Native config for the Current Debate widget, Home Screen quick actions and +// Spotlight. Called only for a development build (DEBATELAB_BUILD=dev), after the +// expo-widgets entry, so the Expo Go +// config never changes. Each part turns on only when it can work: +// - the widget needs the App Group, which is when app.config.ts adds expo-widgets; +// - quick actions need expo-quick-actions to hand the picked item to the app; +// - Spotlight needs expo-core-spotlight@^2 and its plugin (SDK 56 and 57). +// Strings come from src/locales; a missing key fails the config with its name. + +type Language = 'en' | 'es' | 'hu'; +const LANGUAGES: Language[] = ['en', 'es', 'hu']; + +/** The widget's name in src/widgets/current-debate.tsx's createWidget call. */ +const WIDGET_NAME = 'CurrentDebate'; + +/** + * Info.plist titles are keys into each language's InfoPlist.strings, which + * prebuild writes as `KEY = "value";` without quoting the key, so they are + * plain identifiers. The types are the routes in src/lib/quick-actions.ts. + */ +const QUICK_ACTIONS = [ + { type: 'new-debate', titleKey: 'QuickActionNewDebate', catalogKey: 'mobile.debates.newDebate', symbol: 'plus.bubble' }, + { type: 'timer', titleKey: 'QuickActionStartTimer', catalogKey: 'mobile.quickActions.startTimer', symbol: 'timer' }, + { type: 'ask', titleKey: 'QuickActionAsk', catalogKey: 'askChat.title', symbol: 'questionmark.bubble' }, +] as const; + +type WidgetsPluginOptions = { widgets?: { name: string }[] }; + +export function withRound4(config: ExpoConfig, opts: { appGroup?: string; projectRoot: string }): ExpoConfig { + const catalogs = new Map(); + const text = (language: Language, key: string): string => { + if (!catalogs.has(language)) { + const file = path.join(opts.projectRoot, '../../src/locales', `${language}.json`); + catalogs.set(language, JSON.parse(readFileSync(file, 'utf8'))); + } + const value = key.split('.').reduce( + (node, part) => (node !== null && typeof node === 'object' ? (node as Record)[part] : undefined), + catalogs.get(language), + ); + if (typeof value !== 'string' || value === '') { + throw new Error(`app.config.ts: src/locales/${language}.json has no "${key}".`); + } + return value; + }; + const resolve = createRequire(path.join(opts.projectRoot, 'package.json')).resolve; + const installed = (name: string) => { + try { + resolve(`${name}/package.json`); + return true; + } catch { + return false; + } + }; + + if (opts.appGroup) { + const entry = config.plugins?.find((plugin) => Array.isArray(plugin) && plugin[0] === 'expo-widgets'); + if (!Array.isArray(entry) || entry.length < 2) { + throw new Error( + 'app.config.ts: withRound4 must run after app.config.ts adds the expo-widgets plugin (its DEBATELAB_APP_GROUP block).', + ); + } + const options = entry[1] as WidgetsPluginOptions; + // The gallery's name and description are compiled into the extension in English. + options.widgets = [ + ...(options.widgets ?? []).filter((widget) => widget.name !== WIDGET_NAME), + { + name: WIDGET_NAME, + displayName: text('en', 'nav.currentDebate'), + description: text('en', 'mobile.widget.description'), + ios: { supportedFamilies: ['systemSmall', 'systemMedium'] }, + } as { name: string }, + ]; + } + + if (installed('expo-quick-actions')) { + config.ios = { ...config.ios, infoPlist: { ...config.ios?.infoPlist } }; + config.ios.infoPlist!.UIApplicationShortcutItems = QUICK_ACTIONS.map((action) => ({ + UIApplicationShortcutItemType: action.type, + UIApplicationShortcutItemTitle: action.titleKey, + UIApplicationShortcutItemIconSymbolName: action.symbol, + })); + const locales = (config.locales ??= {}); + for (const language of LANGUAGES) { + const current = locales[language]; + if (typeof current === 'string') { + throw new Error(`app.config.ts: locales.${language} is a file path; it needs to be an object to add the quick actions.`); + } + const titles = Object.fromEntries( + QUICK_ACTIONS.map((action) => [action.titleKey, stringsValue(text(language, action.catalogKey))]), + ); + locales[language] = { ...current, ios: { ...current?.ios, ...titles } }; + } + } + + if (installed('expo-core-spotlight')) { + config.plugins = [...(config.plugins ?? []).filter((plugin) => plugin !== 'expo-core-spotlight'), 'expo-core-spotlight']; + } + + return config; +} + export default ({ projectRoot }: ConfigContext): ExpoConfig => { const config: ExpoConfig = { name: 'DebateOwl', @@ -111,6 +214,10 @@ export default ({ projectRoot }: ConfigContext): ExpoConfig => { es: iosLocale(permissionStrings(projectRoot, 'es')), hu: iosLocale(permissionStrings(projectRoot, 'hu')), }; + // Universal links for debateowl.com/share/* and /app/* (src/app/+native-intent.tsx). They work + // once the provisioning profile has Associated Domains and the website serves + // apple-app-site-association; Expo Go cannot have them. + config.ios!.associatedDomains = ['applinks:debateowl.com', 'applinks:www.debateowl.com']; config.plugins!.push( 'expo-dev-client', [ @@ -129,6 +236,8 @@ export default ({ projectRoot }: ConfigContext): ExpoConfig => { }, ]); } + // The Current Debate widget (with the App Group), quick actions and Spotlight; see withRound4 above. + withRound4(config, { appGroup, projectRoot }); // Registered before Expo applies the plugins list. Mods registered later act // first, so this one runs after the plugins have written their entitlements. return withoutPushEntitlement(config); diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 49c78b0e8..e79921725 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -13,12 +13,14 @@ "@expo-google-fonts/fredoka": "^0.4.1", "@expo-google-fonts/nunito": "^0.4.2", "@expo/dom-webview": "~57.0.1", + "@expo/ui": "~57.0.22", "@formatjs/intl-listformat": "^8.3.15", "@formatjs/intl-pluralrules": "^6.3.15", "@react-native-async-storage/async-storage": "2.2.0", "@supabase/supabase-js": "^2.49.1", "aes-js": "^3.1.2", "date-fns": "^3.6.0", + "docx": "^9.5.1", "expo": "~57.0.27", "expo-audio": "~57.0.5", "expo-clipboard": "~57.0.2", diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index f089e1ebe..726577cdc 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -12,9 +12,10 @@ * separate several with commas (ONLY=iphone-light-en,ipad-dark-hu). * SCREENS=you,timer limits the signed-in screens to those groups: debate, * debate-check, library, prepare, practice, you, empty, ask, results, brainstorm, - * factoids, case-edits, context, limits, + * factoids, case-edits, context, limits, sidebar, first-run, share, * timer, crossfire, sparring, flowchart, one-off, history, announcements, * account, model. Sign-in screens are always shot. + * KEEP_GOING=1 carries on past a failed group (see groupFailed). * ONLY=offline runs only the offline reading check (see shootOfflineScreens). * * The iPad sizes are portrait (1024x1366), landscape (1366x1024), an 11-inch @@ -25,8 +26,9 @@ * script signs in once through the real sign-in screen and also checks that a * cold start without a network keeps the user signed in. The deployed Edge * Functions only accept browser calls from a few origins, so for that run use - * PORT=18080 (http://127.0.0.1:18080). Never print the code or put it in a - * screenshot. + * PORT=18080 (http://127.0.0.1:18080), or another port with + * FUNCTIONS_ORIGIN=http://127.0.0.1:18080 (see newContext). Never print the + * code or put it in a screenshot. * * Signed in, the app applies the account's saved language and theme, which * would override the locale and colour scheme under test. Screenshot runs @@ -132,6 +134,60 @@ const SIGNED_IN_ROUTES = { prepare: '/prepare', practice: '/practice', you: '/yo const NOTIFICATIONS = { en: 'Notifications', hu: 'Értesítések', es: 'Notificaciones' } as const; const REST = `https://${STAGING_HOST}/rest/v1`; +/** + * The deployed Edge Functions answer browser calls only from a few origins + * (supabase/functions/_shared/cors.ts), 127.0.0.1:18080 among them. When + * several worktrees take screenshots at once, set FUNCTIONS_ORIGIN to that + * origin and use any PORT: each function call the page makes is sent again + * from Node with that Origin and its answer handed back to the page. Staging only. + */ +const FUNCTIONS_ORIGIN = process.env.FUNCTIONS_ORIGIN; + +/** + * FONT_SCALE draws a Dynamic Type size (src/theme/font-scale.web.ts): 3.1 is + * iOS's largest accessibility size for body text. Use it with SHOTS_DIR, so + * the large-text shots do not replace the usual ones. + */ +const FONT_SCALE = Number(process.env.FONT_SCALE ?? '1'); + +/** + * Screenshots show each screen at rest: contexts prefer reduced motion, which the app's + * animations follow (Reanimated and react-native-web read prefers-reduced-motion), so + * confetti, count-ups, entrances and springs land at once. MOTION=1 plays them; with + * RECORD_DIR set, every page is also recorded as a video there. + */ +const MOTION = process.env.MOTION === '1'; +const RECORD_DIR = process.env.RECORD_DIR; + +async function newContext(browser: Browser, options: BrowserContextOptions): Promise { + const context = await browser.newContext({ + reducedMotion: MOTION ? 'no-preference' : 'reduce', + ...(RECORD_DIR && options.viewport ? { recordVideo: { dir: RECORD_DIR, size: options.viewport } } : {}), + ...options, + }); + if (FONT_SCALE > 0 && FONT_SCALE !== 1) { + await context.addInitScript((scale) => { + (window as unknown as { __debateowlFontScale?: number }).__debateowlFontScale = scale; + }, FONT_SCALE); + } + if (FUNCTIONS_ORIGIN && FUNCTIONS_ORIGIN !== ORIGIN) { + // Context routes run after page routes, so a page that fakes a function keeps its fake. + await context.route(`https://${STAGING_HOST}/functions/v1/**`, async (route) => { + const request = route.request(); + const cors = { 'access-control-allow-origin': ORIGIN, 'access-control-allow-headers': '*', 'access-control-allow-methods': 'GET, POST, PUT, DELETE, OPTIONS', vary: 'Origin' }; + if (request.method() === 'OPTIONS') return route.fulfill({ status: 204, headers: cors }); + // A page that closes mid-call ends the relay quietly: Playwright's error would print the request's headers, session included. + try { + const response = await route.fetch({ headers: { ...request.headers(), origin: FUNCTIONS_ORIGIN } }); + await route.fulfill({ response, headers: { ...response.headers(), ...cors } }); + } catch { + await route.abort().catch(() => undefined); + } + }); + } + return context; +} + const TYPES: Record = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', @@ -146,8 +202,8 @@ const TYPES: Record = { function serve(): Promise { const server = createServer((req, res) => { const path = normalize(decodeURIComponent(new URL(req.url ?? '/', ORIGIN).pathname)).replace(/^(\.\.[/\\])+/, ''); - // A dynamic route such as /case/ is exported once, as case/[id].html. - const candidates = [join(DIST, path), join(DIST, `${path}.html`), join(DIST, path, 'index.html'), join(DIST, dirname(path), '[id].html')]; + // A dynamic route such as /case/ is exported once, as case/[id].html (share/[code].html for /share/). + const candidates = [join(DIST, path), join(DIST, `${path}.html`), join(DIST, path, 'index.html'), join(DIST, dirname(path), '[id].html'), join(DIST, dirname(path), '[code].html')]; const file = candidates.find((candidate) => candidate.startsWith(DIST) && existsSync(candidate) && statSync(candidate).isFile()); if (!file) { res.writeHead(404).end('Not found'); @@ -217,6 +273,19 @@ async function shoot(page: Page, name: string) { await page.screenshot({ path: join(OUT, `${name}.png`), mask: [page.getByTestId('account-email'), page.getByTestId('delete-data-confirmation')] }); } +/** + * A finished round or case opens under its celebration: shoot it as `name`, then tap it away + * (unless it already went on its own) and wait until the screen underneath is clear. + */ +async function shootCelebration(page: Page, name: string) { + const celebration = page.getByTestId('celebration'); + await celebration.waitFor({ timeout: 30_000 }); + await page.waitForTimeout(400); + await shoot(page, name); + await celebration.click({ timeout: 2_000 }).catch(() => {}); + await celebration.waitFor({ state: 'detached', timeout: 5_000 }); +} + /** Answers user_settings reads with the run's language and the System theme (see the header). */ async function pinSettings(page: Page, language: string) { await pinDebateActivity(page, 0); @@ -252,9 +321,31 @@ async function pinDebateActivity(page: Page, ageMs: number) { } /** Waits until the screen's data has arrived, so no spinner is caught mid-load. */ +/** The app's navigation: the tab bar on iPhone and in Split View, the sidebar in a full-screen iPad window. */ +const appShell = (page: Page) => page.locator(':is([data-testid="app-tab-bar"], [data-testid="app-sidebar"]):visible').first(); + +/** Opens a Prepare tool the way the layout offers it: from the iPad sidebar, or from the Prepare tab's tile. */ +async function openPrepareTool(page: Page, tool: string) { + const sidebar = page.getByTestId('app-sidebar'); + if (await sidebar.isVisible()) { + await sidebar.getByTestId(`sidebar-item-${tool}`).click(); + return; + } + if (!(await page.getByTestId(`prepare-tool-${tool}`).filter({ visible: true }).isVisible())) { + await page.getByRole('tab', { name: /^(Prepare|Felkészülés|Preparar)$/ }).click(); + } + await page.getByTestId(`prepare-tool-${tool}`).filter({ visible: true }).click(); +} + async function settle(page: Page) { await page.waitForLoadState('networkidle'); - await page.waitForFunction(() => document.querySelectorAll('[role="progressbar"]').length === 0, undefined, { timeout: 15_000 }); + // Only what shows: a screen opened from a link has the Debate tab mounted, unfocused, beneath it, + // and that tab loads only once it is shown. + await page.waitForFunction( + () => Array.from(document.querySelectorAll('[role="progressbar"]')).every((el) => !(el as HTMLElement).checkVisibility()), + undefined, + { timeout: 15_000 }, + ); } async function shootDebateScreens(page: Page, prefix: string) { @@ -411,16 +502,19 @@ async function fakeAsk(page: Page, hold: boolean) { } // A made-up case in GenerateOutput's shape, after mock 03: three arguments for a Standard case. +// A made-up case in the new format (#244): opening, titled arguments, "If they say…", closing line and sources. const FAKE_CASE = { + opening: { + hook: 'Picture a school run where the loudest thing on the street is children talking.', + definition: 'City centre: the area inside the inner ring road, with exemptions for disabled drivers and deliveries.', + }, caseStructure: { - thesis: 'City centres are shared public space, and private cars take the most of it while costing everyone else their health.', - framework: 'City centres are shared public space. The motion asks who gets to use that space, and at what cost to everyone else\'s health.', - keyDefinitions: ['City centre: the area inside a city\'s inner ring road', 'Private cars: privately owned passenger vehicles, with exemptions for disabled drivers'], - burdenOfProof: 'We must show that removing private cars improves health and access for most people who use the centre.', + thesis: 'We will show three arguments that prove city centres work better for everyone without private cars: cleaner air, streets back for people, and buses that run on time.', }, contentions: [ { id: '1', + title: 'Clean air', claim: 'Clean air is a public health duty', reasoning: 'Traffic is the largest source of roadside pollution, and the people who breathe it most are pedestrians, cyclists and residents, not drivers.', evidence: 'Low-traffic zones cut roadside NO₂ within their first year, according to the Clean Streets Review.', @@ -429,7 +523,8 @@ const FAKE_CASE = { }, { id: '2', - claim: 'Streets back for people', + title: 'Streets back for people', + claim: 'Cars take most of the street while carrying a minority of the people in it', reasoning: 'Parked and moving cars take most of a centre\'s street space while carrying a minority of the people in it.', evidence: 'The Street Space Audit found cars use most of the road area; the Footfall Study found shops gain visits when streets close to traffic.', needsSource: false, @@ -437,6 +532,7 @@ const FAKE_CASE = { }, { id: '3', + title: 'Buses on time', claim: 'Transit works when it isn\'t stuck in traffic', reasoning: 'Buses lose most of their delay to congestion caused by private cars.', evidence: 'Bus journeys became quicker and more reliable after the centre closed to through traffic, per the Clean Streets Review.', @@ -444,13 +540,16 @@ const FAKE_CASE = { impact: 'Faster, more reliable buses make the ban fair to people who cannot walk or cycle.', }, ], - predictedOpponentCase: { - likelyArguments: ['Shift workers and carers need cars at hours when transit is thin', 'Businesses lose customers who drive in'], - strategicWeaknesses: ['Depends on exemptions working in practice'], - keyClashes: ['Access for the few against health for the many', 'Shops and footfall'], - }, counters: [ - { targetArgument: 'Shift workers need cars', counterpoint: 'Exemptions and night buses cover them.', rebuttal: 'Every real ban exempts carers and shift workers, and transit follows demand.' }, + { targetArgument: 'Shift workers and carers need cars at night', counterpoint: 'Every real ban exempts carers and shift workers, and night buses follow demand.', source: 'Clean Streets Review' }, + { targetArgument: 'Shops lose customers who drive in', counterpoint: 'Footfall rose when streets closed to traffic: people on foot shop more often than drivers.', source: 'Footfall Study' }, + { targetArgument: 'It just moves traffic to the suburbs', counterpoint: 'Through traffic falls overall when the centre closes, because some trips stop being made by car at all.' }, + ], + closingLine: 'A city centre should belong to the people in it, not the cars passing through it.', + sources: [ + { org: 'Clean Streets Review', year: '2025', claim: 'Low-traffic zones cut roadside NO₂ within a year', url: 'https://example.org/clean-streets' }, + { org: 'Street Space Audit', year: '2024', claim: 'Cars use most of the road area in city centres', url: 'https://example.org/street-space' }, + { org: 'Footfall Study', claim: 'Shops gain visits when streets close to traffic', url: 'https://example.org/footfall' }, ], metadata: { side: 'pro', @@ -636,6 +735,7 @@ async function shootResultScreens(context: BrowserContext, prefix: string, langu await page.getByTestId('generate-motion').waitFor({ timeout: 30_000 }); await page.waitForFunction(() => (document.querySelector('[data-testid="generate-motion"]') as HTMLTextAreaElement | null)?.value.trim()); await page.getByRole('button', { name: /^(Generate debate case|Vitaeset generálása|Generar caso de debate)$/i }).click(); + await shootCelebration(page, `${prefix}-case-celebration`); await page.getByTestId('case-document').waitFor({ timeout: 30_000 }); await page.waitForTimeout(400); await shoot(page, `${prefix}-case`); @@ -695,7 +795,7 @@ async function shootResultScreens(context: BrowserContext, prefix: string, langu await page.getByTestId('attack-this-case').click(); await page.getByTestId('attack-case-text').waitFor({ timeout: 15_000 }); - await page.waitForFunction(() => (document.querySelector('[data-testid="attack-case-text"]') as HTMLTextAreaElement | null)?.value.startsWith('THESIS:')); + await page.waitForFunction(() => (document.querySelector('[data-testid="attack-case-text"]') as HTMLTextAreaElement | null)?.value.startsWith('OPENING:')); await shoot(page, `${prefix}-attack-form`); await page.getByRole('button', { name: /^(Find weaknesses|Gyengeségek keresése|Encontrar debilidades)$/i }).click(); await page.getByTestId('attack-document').waitFor({ timeout: 30_000 }); @@ -786,6 +886,7 @@ const MENU = { regenerate: /^(Regenerate a section|Szakasz újragenerálása|Regenerar una sección)$/, arguments: /^(Regenerate Arguments|Érvek újragenerálása|Regenerar Argumentos)$/, save: /^(Save|Mentés|Guardar)$/, + shareLink: /^(Share link|Link megosztása|Compartir enlace)$/, }; /** @@ -822,6 +923,9 @@ async function shootCaseEdits(context: BrowserContext, prefix: string, language: await page.waitForFunction(() => (document.querySelector('[data-testid="generate-motion"]') as HTMLTextAreaElement | null)?.value.trim()); await page.getByRole('button', { name: /^(Generate debate case|Vitaeset generálása|Generar caso de debate)$/i }).click(); await page.getByTestId('case-document').waitFor({ timeout: 30_000 }); + // A new case opens with its celebration (shot in the results group); tap it away first. + await page.getByTestId('celebration').click({ timeout: 5_000 }).catch(() => {}); + await page.getByTestId('celebration').waitFor({ state: 'detached', timeout: 5_000 }); await page.waitForTimeout(3800); // The arrival notice fades. return { page, seen }; }; @@ -1064,9 +1168,7 @@ async function shootFlowchart(context: BrowserContext, prefix: string, language: if (!(await toggle.isChecked())) throw new Error('The one-off switch did not turn on'); await oneOff.getByTestId('debates-sheet').getByRole('button', { name: /^(Done|Kész|Listo)$/ }).click(); await oneOff.getByTestId('debates-sheet').waitFor({ state: 'detached' }); - await oneOff.getByRole('tab', { name: /^(Prepare|Felkészülés|Preparar)$/ }).click(); - await oneOff.getByTestId('prepare-tool-flowchart').waitFor({ timeout: 30_000 }); - await oneOff.getByTestId('prepare-tool-flowchart').click(); + await openPrepareTool(oneOff, 'flowchart'); await oneOff.getByTestId('flowchart-screen').getByTestId('one-off-notice').waitFor({ timeout: 30_000 }); await oneOff.getByTestId('flow-card').first().waitFor({ timeout: 30_000 }); await settle(oneOff); @@ -1136,6 +1238,13 @@ async function fakeLibrary(page: Page) { rows = (rows ?? []).filter((row) => row.id !== id); return route.fulfill({ status: 200, headers: CORS, json: removed.map((row) => ({ debate_id: row.debate_id })) }); } + if (request.method() === 'PATCH') { + // An edit or an Active switch, kept as the server keeps it, so the list read after a write shows it. + const filter = url.searchParams.get('id') ?? ''; + const ids = filter.startsWith('eq.') ? [filter.slice(3)] : filter.startsWith('in.(') ? filter.slice(4, -1).split(',').map((id) => id.replace(/"/g, '')) : []; + const body = request.postDataJSON() as Record; + rows = (rows ?? []).map((row) => (ids.includes(String(row.id)) ? { ...row, ...body } : row)); + } return route.fulfill({ status: 204, headers: CORS }); }); } @@ -1413,6 +1522,7 @@ async function shootCrossfireScreens(context: BrowserContext, prefix: string, la await shoot(page, `${prefix}-crossfire-exchange`); await page.getByTestId('crossfire-end').click(); + await shootCelebration(page, `${prefix}-crossfire-celebration`); await page.getByTestId('crossfire-debrief').waitFor({ timeout: 15_000 }); await page.waitForTimeout(400); await shoot(page, `${prefix}-crossfire-debrief`); @@ -1557,6 +1667,7 @@ async function shootSparringScreens(context: BrowserContext, prefix: string, lan await shoot(page, `${prefix}-sparring-judging`); fake.release('score'); + await shootCelebration(page, `${prefix}-sparring-celebration`); await page.getByTestId('sparring-scorecard').waitFor({ timeout: 15_000 }); await page.waitForTimeout(400); await shoot(page, `${prefix}-sparring-scorecard`); @@ -1612,20 +1723,27 @@ async function shootOneOffTools(page: Page, prefix: string) { await settle(page); await turnOnOneOff(page); await closeSwitcher(page); - await page.getByRole('tab', { name: /^(Prepare|Felkészülés|Preparar)$/ }).click(); - await page.getByTestId('prepare-tool-generate').waitFor({ timeout: 30_000 }); - // The Debate tab stays mounted behind Prepare with its own notice; only the visible one is Prepare's. - await page.getByTestId('one-off-notice').filter({ visible: true }).waitFor(); - await settle(page); - await shoot(page, `${prefix}-prepare-oneoff`); + // The iPad sidebar lists the tools themselves, so there is no Prepare tab to shoot there. + const sidebar = await page.getByTestId('app-sidebar').isVisible(); + if (!sidebar) { + await page.getByRole('tab', { name: /^(Prepare|Felkészülés|Preparar)$/ }).click(); + await page.getByTestId('prepare-tool-generate').waitFor({ timeout: 30_000 }); + // The Debate tab stays mounted behind Prepare with its own notice; only the visible one is Prepare's. + await page.getByTestId('one-off-notice').filter({ visible: true }).waitFor(); + await settle(page); + await shoot(page, `${prefix}-prepare-oneoff`); + } // Brainstorm and Factoids say it through the same strip; a reload would end one-off mode, so go back in the app. for (const tool of ['brainstorm', 'factoids'] as const) { - await page.getByTestId(`prepare-tool-${tool}`).click(); + await openPrepareTool(page, tool); await page.getByTestId(`${tool}-screen`).getByTestId('one-off-notice').waitFor({ timeout: 30_000 }); - await page.goBack(); - await page.getByTestId('prepare-tool-generate').filter({ visible: true }).waitFor(); + // The sidebar goes from tool to tool; on a phone the tool goes back to Prepare's tiles. + if (!sidebar) { + await page.goBack(); + await page.getByTestId('prepare-tool-generate').filter({ visible: true }).waitFor(); + } } - await page.getByTestId('prepare-tool-generate').click(); + await openPrepareTool(page, 'generate'); await page.getByTestId('generate-motion').waitFor({ timeout: 30_000 }); const notice = page.getByTestId('generate-screen').getByTestId('one-off-notice'); await notice.waitFor(); @@ -1635,6 +1753,228 @@ async function shootOneOffTools(page: Page, prefix: string) { await page.getByTestId('generate-screen').getByTestId('working-in').waitFor(); } +/** + * The iPad sidebar (ipad devices only): every tool under Debate, Prepare, + * Practice, Library and You. It shoots the sidebar fully expanded, then with + * Practice and Library closed while Generate is open, and checks that the + * closed sections stay closed after a reload, that a row highlights the screen + * it opens, that picking another row from an open tool replaces that tool + * instead of stacking a second screen, and that a 600-point window (Split View) + * has the tab bar instead. + */ +async function shootSidebar(context: BrowserContext, prefix: string, language: string, problems: string[]): Promise { + const page = await context.newPage(); + watchErrors(page, problems, `${prefix} sidebar`); + await pinSettings(page, language); + const viewport = page.viewportSize(); + await page.goto(`${ORIGIN}/`); + await page.evaluate(() => localStorage.removeItem('debateowl.sidebar.collapsed')); + await page.goto(`${ORIGIN}/`); + const sidebar = page.getByTestId('app-sidebar'); + if (viewport && viewport.width < 700) { + await page.getByTestId('app-tab-bar').waitFor(); + if (await sidebar.isVisible()) throw new Error(`${prefix}: a ${viewport.width}-point window shows the sidebar`); + await page.close(); + return 0; + } + await sidebar.waitFor({ timeout: 30_000 }); + if (await page.getByTestId('app-tab-bar').isVisible()) throw new Error(`${prefix}: the tab bar shows beside the sidebar`); + await page.getByTestId('current-debate-motion').waitFor({ timeout: 30_000 }); + await settle(page); + const selected = async () => sidebar.locator('[aria-selected="true"]').getAttribute('data-testid'); + if ((await selected()) !== 'sidebar-item-dashboard') throw new Error(`${prefix}: the Debate tab does not highlight Dashboard`); + await shoot(page, `${prefix}-sidebar-expanded`); + + // Generate from the sidebar, then Attack from Generate: the sidebar replaces the open tool. + await sidebar.getByTestId('sidebar-item-generate').click(); + await page.getByTestId('generate-motion').waitFor({ timeout: 30_000 }); + await sidebar.getByTestId('sidebar-item-attack').click(); + await page.waitForURL('**/attack**'); + await sidebar.getByTestId('sidebar-item-generate').click(); + await page.getByTestId('generate-motion').waitFor({ timeout: 30_000 }); + if ((await selected()) !== 'sidebar-item-generate') throw new Error(`${prefix}: Generate is not highlighted on Generate`); + await page.goBack(); + await page.waitForURL((url) => url.pathname === '/'); + await page.getByTestId('current-debate-motion').waitFor({ timeout: 30_000 }); + await sidebar.getByTestId('sidebar-item-generate').click(); + await page.getByTestId('generate-motion').waitFor({ timeout: 30_000 }); + + // A tool opened cold, as from a link: the sidebar still replaces it, and Back reaches the tabs. + await page.goto(`${ORIGIN}/generate`); + await page.getByTestId('generate-motion').waitFor({ timeout: 30_000 }); + await sidebar.getByTestId('sidebar-item-ask').click(); + await page.waitForURL('**/ask**'); + await sidebar.getByTestId('sidebar-item-attack').click(); + await page.waitForURL('**/attack**'); + // The app's own Back (browser history on the web does not follow the stack). + await page.getByTestId('document-back').filter({ visible: true }).click(); + await page.waitForURL((url) => url.pathname === '/', { timeout: 15_000 }); + // The dashboard drops a load it loses focus during, so let it finish before covering it again. + await page.getByTestId('current-debate-motion').waitFor({ timeout: 30_000 }); + await settle(page); + await sidebar.getByTestId('sidebar-item-generate').click(); + await page.getByTestId('generate-motion').waitFor({ timeout: 30_000 }); + + for (const section of ['practice', 'library']) { + const toggle = sidebar.getByTestId(`sidebar-toggle-${section}`); + await toggle.click(); + if ((await toggle.getAttribute('aria-expanded')) !== 'false') throw new Error(`${prefix}: ${section} did not collapse`); + } + await settle(page); + await shoot(page, `${prefix}-sidebar-collapsed`); + + await page.goto(`${ORIGIN}/library`); + await sidebar.waitFor({ timeout: 30_000 }); + for (const section of ['practice', 'library']) { + if (await sidebar.getByTestId(`sidebar-section-${section}`).locator('[data-testid^="sidebar-item-"]').count()) { + throw new Error(`${prefix}: ${section} opened again after a reload`); + } + } + // A closed section still marks the screen it holds. + await sidebar.getByTestId('sidebar-toggle-library').click(); + if ((await selected()) !== 'sidebar-item-sources') throw new Error(`${prefix}: Sources is not highlighted on the Library`); + + // Split View: the same iPad at 600 points keeps the tab bar. + if (viewport) { + await page.setViewportSize({ width: 600, height: viewport.height }); + await page.getByTestId('app-tab-bar').waitFor(); + if (await sidebar.isVisible()) throw new Error(`${prefix}: the sidebar stays in a 600-point window`); + await page.setViewportSize(viewport); + } + await page.close(); + return 2; +} + +/** + * A share link, end to end on staging: the made-up saved case's Share link + * calls the real `share` function (one new link), and the link's code opens + * the in-app viewer, which reads it back with the public get_shared_item RPC. + */ +async function shootShareLink(context: BrowserContext, prefix: string, language: string, problems: string[]): Promise { + const page = await context.newPage(); + watchErrors(page, problems, `${prefix} share`); + await pinSettings(page, language); + await fakeSavedCase(page); + await page.goto(`${ORIGIN}/case/${SAVED_CASE_ID}`); + await page.getByTestId('case-document').waitFor({ timeout: 30_000 }); + await page.getByTestId('document-more').click(); + await page.getByRole('menuitem', { name: MENU.shareLink }).last().click(); + await page.waitForFunction(() => Boolean((window as unknown as { __debatelabExport?: { sharedUrl?: string } }).__debatelabExport?.sharedUrl), undefined, { timeout: 30_000 }); + const url = await page.evaluate(() => (window as unknown as { __debatelabExport?: { sharedUrl?: string } }).__debatelabExport?.sharedUrl ?? ''); + const code = /^\/share\/([A-Za-z0-9_-]+)$/.exec(new URL(url).pathname)?.[1]; + if (!code) throw new Error(`The share link has no code: ${new URL(url).host}${new URL(url).pathname.replace(/[^/]+$/, '…')}`); + console.log(`${prefix}: share link on ${new URL(url).host}, a ${code.length}-character code`); + await page.close(); + + const viewer = await context.newPage(); + watchErrors(viewer, problems, `${prefix} shared view`); + await viewer.goto(`${ORIGIN}/share/${code}`); + await viewer.getByTestId('shared-document').waitFor({ timeout: 30_000 }); + await viewer.getByTestId('case-opening').waitFor(); + await settle(viewer); + await shoot(viewer, `${prefix}-shared-case`); + await viewer.close(); + return 1; +} + +/** + * The first run and two account notices, each in a context of its own: + * Ollie's welcome (asked for with ?intro=show, as on the website) and the + * tour's first and last steps; the trial reminder, from a user_settings read + * answered as a trial ending in two days with no subscription rows; and the + * expired-session notice, from a stored session whose refresh the page + * answers with invalid_grant. Nothing is written. + */ +async function shootFirstRun( + browser: Browser, + options: BrowserContextOptions, + session: StorageState, + prefix: string, + language: string, + problems: string[], +): Promise { + let shots = 0; + const welcome = await newContext(browser, { ...options, storageState: session }); + const page = await welcome.newPage(); + watchErrors(page, problems, `${prefix} welcome`); + await pinSettings(page, language); + await page.goto(`${ORIGIN}/?intro=show`); + await page.getByTestId('debateowl-intro').waitFor({ timeout: 30_000 }); + // The owl's lines land over 6.6 seconds. + await page.waitForTimeout(7_000); + await shoot(page, `${prefix}-welcome`); + await page.getByTestId('intro-done').click(); + await page.getByTestId('tour-step-debate').waitFor(); + await shoot(page, `${prefix}-tour-debate`); + for (const step of ['library', 'generate', 'ready']) { + await page.getByTestId('tour-next').click(); + await page.getByTestId(`tour-step-${step}`).waitFor(); + } + await shoot(page, `${prefix}-tour-ready`); + await page.getByTestId('tour-next').click(); + await page.getByTestId('tour-step-ready').waitFor({ state: 'detached' }); + shots += 3; + await page.unrouteAll({ behavior: 'ignoreErrors' }); + await welcome.close(); + + const trial = await newContext(browser, { ...options, storageState: session }); + const trialPage = await trial.newPage(); + watchErrors(trialPage, problems, `${prefix} trial`); + await pinSettings(trialPage, language); + const endsAt = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000 + 60 * 60 * 1000).toISOString(); + await trialPage.route(`${REST}/user_settings*`, async (route) => { + if (route.request().method() !== 'GET' || !route.request().url().includes('trial_ends_at')) return route.fallback(); + // A read still running when the context closes ends quietly: Playwright's error would print the request's headers. + try { + const response = await route.fetch(); + const body: unknown = await response.json(); + const asTrial = (row: Record) => ({ ...row, subscription_tier: 'trial', trial_ends_at: endsAt, grace_period_ends_at: null, daily_limit_override: null }); + await route.fulfill({ response, json: Array.isArray(body) ? body.map((row) => asTrial(row as Record)) : asTrial(body as Record) }); + } catch { + await route.abort().catch(() => undefined); + } + }); + for (const table of ['subscriptions', 'app_store_subscriptions']) { + await trialPage.route(`${REST}/${table}*`, (route) => route.fulfill({ status: 200, contentType: 'application/json', json: [] })); + } + await trialPage.goto(`${ORIGIN}/`); + await trialPage.getByTestId('trial-banner').waitFor({ timeout: 30_000 }); + await settle(trialPage); + await shoot(trialPage, `${prefix}-trial-banner`); + await trialPage.getByTestId('trial-banner-dismiss').click(); + await trialPage.getByTestId('trial-banner').waitFor({ state: 'detached' }); + shots += 1; + await trialPage.unrouteAll({ behavior: 'ignoreErrors' }); + await trial.close(); + + // The stored session's access token has expired and the server refuses its refresh token. + const expired = await newContext(browser, { ...options, storageState: session }); + await expired.addInitScript(() => { + for (const key of Object.keys(localStorage)) { + if (!key.includes('auth')) continue; + try { + const value = JSON.parse(localStorage.getItem(key) ?? 'null') as { expires_at?: number } | null; + if (value && typeof value === 'object' && 'expires_at' in value) localStorage.setItem(key, JSON.stringify({ ...value, expires_at: 1 })); + } catch { + // Not a session. + } + } + }); + await expired.route(`https://${STAGING_HOST}/auth/v1/token*`, (route) => + route.fulfill({ status: 400, contentType: 'application/json', json: { error: 'invalid_grant', error_description: 'Invalid Refresh Token: Refresh Token Not Found', code: 'refresh_token_not_found' } }), + ); + // The refused refresh is the point of this page, so its 400 is not reported as a problem. + const expiredPage = await expired.newPage(); + await expiredPage.goto(`${ORIGIN}/`); + await expiredPage.getByTestId('session-expired').waitFor({ timeout: 45_000 }); + await shoot(expiredPage, `${prefix}-session-expired`); + shots += 1; + await expiredPage.unrouteAll({ behavior: 'ignoreErrors' }); + await expired.unrouteAll({ behavior: 'ignoreErrors' }); + await expired.close(); + return shots; +} + async function shootEmptyDebate(page: Page, prefix: string) { await page.route(`${REST}/current_debates*`, (route) => route.fulfill({ status: 200, contentType: 'application/json', json: [] }), @@ -1826,16 +2166,31 @@ async function shootModelPicker(context: BrowserContext, prefix: string, languag } /** Shoots whatever page of the group is open when it fails, and names the group in the error. */ +/** + * KEEP_GOING=1 records a failed group and runs the rest, so one flaky step + * does not cost every later shot; the run still exits with 1 and lists them. + */ +const KEEP_GOING = process.env.KEEP_GOING === '1'; +const groupFailures: string[] = []; + +function groupFailed(label: string, error: unknown): number { + const message = `${label}: ${(error instanceof Error ? error.message : String(error)).split('\n')[0]}`; + if (!KEEP_GOING) throw new Error(message); + console.error(`FAILED ${message}`); + groupFailures.push(message); + return 0; +} + function failedIn(context: BrowserContext, prefix: string, group: string) { - return async (error: unknown) => { + return async (error: unknown): Promise => { const page = context.pages().at(-1); if (page) await shoot(page, `${prefix}-failed-${group}`).catch(() => undefined); - throw new Error(`${group}: ${error instanceof Error ? error.message : String(error)}`); + return groupFailed(`${prefix} ${group}`, error); }; } async function signIn(browser: Browser, problems: string[]): Promise { - const context = await browser.newContext({ ...DEVICES.iphone, locale: LOCALES.en }); + const context = await newContext(browser, { ...DEVICES.iphone, locale: LOCALES.en }); const page = await context.newPage(); watchErrors(page, problems, 'sign-in'); const authHosts: string[] = []; @@ -1872,7 +2227,7 @@ async function checkOfflineColdStart(browser: Browser, state: StorageState, prob session.expires_at = Math.floor(Date.now() / 1000) - 60; entry.value = JSON.stringify(session); - const context = await browser.newContext({ ...DEVICES.iphone, locale: LOCALES.en, storageState: expired }); + const context = await newContext(browser, { ...DEVICES.iphone, locale: LOCALES.en, storageState: expired }); const page = await context.newPage(); // Offline for the backend only; the page itself still loads from the local server. const toBackend = (url: URL) => url.host === STAGING_HOST; @@ -1961,7 +2316,7 @@ async function openFlowchart(page: Page) { */ async function shootOfflineScreens(browser: Browser, state: StorageState, problems: string[], run: (typeof OFFLINE_RUNS)[number], isolation: boolean) { const { prefix, scheme, language } = run; - const context = await browser.newContext({ ...DEVICES.iphone, colorScheme: scheme, locale: LOCALES[language], storageState: state }); + const context = await newContext(browser, { ...DEVICES.iphone, colorScheme: scheme, locale: LOCALES[language], storageState: state }); const page = await context.newPage(); try { return await readOffline(context, page, problems, run, isolation); @@ -2130,7 +2485,7 @@ async function main() { for (const [language, locale] of Object.entries(LOCALES)) { const prefix = `${device}-${scheme}-${language}`; if (ONLY.length > 0 && !ONLY.some((only) => prefix.startsWith(only))) continue; - const signedOut = await browser.newContext({ ...options, colorScheme: scheme, locale }); + const signedOut = await newContext(browser, { ...options, colorScheme: scheme, locale }); const page = await signedOut.newPage(); watchErrors(page, problems, prefix); await page.goto(`${ORIGIN}/sign-in`); @@ -2145,14 +2500,14 @@ async function main() { await signedOut.close(); if (!session) continue; - const signedIn = await browser.newContext({ ...options, colorScheme: scheme, locale, storageState: session }); + const signedIn = await newContext(browser, { ...options, colorScheme: scheme, locale, storageState: session }); const tabPage = await signedIn.newPage(); watchErrors(tabPage, problems, `${prefix} signed in`); await pinSettings(tabPage, language); if (want('debate')) { await shootDebateScreens(tabPage, prefix).catch(async (error: unknown) => { await shoot(tabPage, `${prefix}-failed`); - throw error; + groupFailed(prefix, error); }); shots += 10; } @@ -2163,14 +2518,14 @@ async function main() { if (want('library')) { await shootLibraryScreens(tabPage, prefix, problems).catch(async (error: unknown) => { await shoot(tabPage, `${prefix}-failed`); - throw error; + groupFailed(prefix, error); }); shots += 14; } for (const [name, route] of Object.entries(SIGNED_IN_ROUTES)) { if (!want(name)) continue; await tabPage.goto(`${ORIGIN}${route}`); - await tabPage.getByRole('tablist').first().waitFor(); + await appShell(tabPage).waitFor(); if (name === 'you') await tabPage.getByTestId('subscription-tier').waitFor({ timeout: 30_000 }); await settle(tabPage); await shoot(tabPage, `${prefix}-${name}`); @@ -2192,6 +2547,16 @@ async function main() { await shootEmptyDebate(emptyPage, prefix); shots += 2; } + if (want('sidebar') && device.startsWith('ipad')) { + shots += await shootSidebar(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'sidebar')); + } + // Each run makes one real share link on staging, so English only. + if (want('share') && language === 'en') { + shots += await shootShareLink(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'share')); + } + if (want('first-run')) { + shots += await shootFirstRun(browser, { ...options, colorScheme: scheme, locale }, session, prefix, language, problems).catch(failedIn(signedIn, prefix, 'first-run')); + } if (want('ask')) { await shootAskScreens(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'ask')); shots += 6; @@ -2269,8 +2634,8 @@ async function main() { console.log(`${shots} screenshots in ${OUT}`); await writeFile(join(OUT, 'clipped.json'), JSON.stringify(clipped, null, 2)); console.log(`${Object.keys(clipped).length} shots with clipped text (clipped.json)`); - if (problems.length > 0) { - console.error(problems.join('\n')); + if (problems.length > 0 || groupFailures.length > 0) { + console.error([...groupFailures, ...problems].join('\n')); process.exitCode = 1; } } diff --git a/apps/mobile/src/app/(tabs)/(debate)/index.tsx b/apps/mobile/src/app/(tabs)/(debate)/index.tsx index 9d29cf38a..7db1f34a9 100644 --- a/apps/mobile/src/app/(tabs)/(debate)/index.tsx +++ b/apps/mobile/src/app/(tabs)/(debate)/index.tsx @@ -1,20 +1,25 @@ import { Link, useFocusEffect, useRouter } from 'expo-router'; import { useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; +import { Pressable, StyleSheet, View } from 'react-native'; +import Animated from 'react-native-reanimated'; import { AnnouncementBanner } from '@/components/announcement-banner'; +import { TrialBanner } from '@/components/trial-banner'; import { MainActionButton, TextButton } from '@/components/buttons'; -import { Card, IconTile } from '@/components/card'; +import { Card, IconTile, ListItemIn, usePressScale } from '@/components/card'; import { Icon } from '@/components/icon'; import type { IconName } from '@/components/icon-paths'; import { OneOffNotice } from '@/components/one-off-notice'; import { PREPARE_TOOLS, prepareToolHref } from '@/components/prepare-tools'; import { Screen } from '@/components/screen'; import { SegmentedControl } from '@/components/segmented-control'; +import { Skeleton, SkeletonRows } from '@/components/skeleton'; import { SOURCE_ICONS, sourceMeta } from '@/components/source-display'; import { Text } from '@/components/text'; import { WorkRow } from '@/components/work-row'; +import { publishCurrentDebate } from '@/lib/debate-widget'; +import { indexForSpotlight } from '@/lib/spotlight'; import { useAuth } from '@/lib/auth'; import { useDebates } from '@/lib/debate-context'; import { @@ -64,8 +69,8 @@ export default function DebateScreen() { if (status === 'loading') { return ( - - + + ); } @@ -88,6 +93,7 @@ export default function DebateScreen() { return ( + {/* Mirrored, so the owl points across at Start debate, as on the website's home. */} @@ -109,6 +115,7 @@ export default function DebateScreen() { return ( + @@ -123,6 +130,7 @@ export default function DebateScreen() { return ( + @@ -206,6 +214,10 @@ function DebateDetails({ debateId, dashboard = false }: { debateId: string; dash const router = useRouter(); const { session } = useAuth(); const userId = session?.user.id ?? null; + // Read when counts land, for the Home Screen widget; not a reason to load again. + const { current } = useDebates(); + const currentRef = useRef(current); + currentRef.current = current; const [view, setView] = useState('pro'); const [counts, setCounts] = useState(null); const [recent, setRecent] = useState | null>(null); @@ -228,6 +240,13 @@ function DebateDetails({ debateId, dashboard = false }: { debateId: string; dash ]); if (!isCurrent()) return; setCounts(nextCounts); + const shown = currentRef.current; + if (shown?.id === debateId) { + publishCurrentDebate({ debateId, motion: shown.motion, counts: nextCounts }); + // Spotlight finds this debate's cases (development build with expo-core-spotlight; a no-op otherwise). + const cases = [...pro, ...con].filter((item, index, all) => item.kind === 'case' && all.findIndex((other) => other.id === item.id) === index); + void indexForSpotlight('cases', cases.map((item) => ({ url: `debatelab://case/${item.id}` as const, title: item.title, description: shown.motion }))); + } setRecent({ pro, con }); setSources(nextSources?.slice(0, DASHBOARD_SOURCES) ?? null); setFailed(false); @@ -252,6 +271,7 @@ function DebateDetails({ debateId, dashboard = false }: { debateId: string; dash const results = counts?.results[view]; const items = recent?.[view] ?? null; + const countsLoading = counts === null && !failed; const sideAndStats = ( <> @@ -266,10 +286,16 @@ function DebateDetails({ debateId, dashboard = false }: { debateId: string; dash /> - - - - + {/* While the counts load (and have not failed) the row is the loading indicator, with shapes for the numbers. */} + + + + @@ -281,13 +307,10 @@ function DebateDetails({ debateId, dashboard = false }: { debateId: string; dash {t('mobile.tabs.prepare')} - {PREPARE_TOOLS.map((tool) => ( - router.push(prepareToolHref(tool.key, 'debate'))} - /> + {PREPARE_TOOLS.map((tool, index) => ( + + router.push(prepareToolHref(tool.key, 'debate'))} /> + ))} @@ -312,7 +335,7 @@ function DebateDetails({ debateId, dashboard = false }: { debateId: string; dash void load(() => true)} /> ) : items === null ? ( - + ) : items.length === 0 ? ( @@ -322,7 +345,9 @@ function DebateDetails({ debateId, dashboard = false }: { debateId: string; dash ) : ( {items.map((item, index) => ( - void load(() => true)} /> + + void load(() => true)} /> + ))} )} @@ -341,7 +366,7 @@ function DebateDetails({ debateId, dashboard = false }: { debateId: string; dash } const recentSources = failed ? null : sources === null ? ( - + ) : sources.length === 0 ? ( @@ -351,7 +376,9 @@ function DebateDetails({ debateId, dashboard = false }: { debateId: string; dash ) : ( {sources.map((source, index) => ( - + + + ))} ); @@ -403,14 +430,17 @@ function RecentSource({ source, first }: { source: Source; first: boolean }) { function RecentSourceRow({ source, first, onPress }: { source: Source; first: boolean; onPress?: () => void }) { const { t } = useTranslation(); const { colors } = useTheme(); + const press = usePressScale(); return ( {({ pressed }) => ( - + @@ -421,18 +451,22 @@ function RecentSourceRow({ source, first, onPress }: { source: Source; first: bo - + )} ); } -function Stat({ value, label }: { value: number | undefined; label: string }) { +function Stat({ value, label, loading }: { value: number | undefined; label: string; loading: boolean }) { return ( - - {value ?? '–'} - + {loading ? ( + + ) : ( + + {value ?? '–'} + + )} {label} @@ -453,9 +487,10 @@ function SparCard({ onPress }: { onPress: () => void }) { { backgroundColor: colors.primaryFill, borderBottomColor: colors.primaryLedge }, pressed && styles.sparPressed, ]}> - + {/* Ollie is present here, so he blinks. */} + - + {t('mobile.debate.spar')} {t('sparring.format.standard')} @@ -472,25 +507,49 @@ function SparCard({ onPress }: { onPress: () => void }) { // Long one-word labels (Hungarian "Folyamatábra") shrink to fit on iOS; the web preview cannot shrink text, so it truncates them. function ToolTile({ icon, label, onPress }: { icon: IconName; label: string; onPress: () => void }) { const { colors } = useTheme(); + const press = usePressScale(); return ( - [ - styles.tile, - { backgroundColor: colors.card, borderColor: colors.border }, - pressed && styles.pressed, - ]}> - - - {label} - + + + + + {label} + + ); } +/** + * The Debate tab before the debates arrive, in the shape of the page: the switcher pill, the + * motion, its settings, the side toggle, the counts and the recent work. The loading indicator. + */ +function DebateSkeleton() { + const { t } = useTranslation(); + return ( + + + + + + + + + + {[0, 1, 2].map((stat) => ( + + + + + ))} + + + + + ); +} + const styles = StyleSheet.create({ - loading: { marginTop: 32 }, body: { fontSize: 16, lineHeight: 22 }, pressed: { opacity: 0.7 }, pill: { @@ -519,6 +578,12 @@ const styles = StyleSheet.create({ stats: { flexDirection: 'row', gap: 24, marginTop: 13, marginBottom: 15, marginHorizontal: 2 }, statValue: { fontSize: 21, lineHeight: 26, letterSpacing: -0.2 }, statLabel: { fontSize: 15 }, + statShape: { marginVertical: 3 }, + skeletonPill: { marginTop: 4 }, + skeletonMotion: { gap: 8, marginTop: 14, marginBottom: 14 }, + skeletonSettings: { marginBottom: 18 }, + skeletonStat: { gap: 7 }, + skeletonSpar: { marginBottom: 20 }, spar: { borderRadius: radius.card, borderBottomWidth: 5, @@ -538,9 +603,9 @@ const styles = StyleSheet.create({ sectionRow: { flexDirection: 'row', alignItems: 'center', gap: 12, marginTop: 10, marginRight: 2 }, sectionTitle: { flex: 1, marginTop: 0, marginBottom: 0 }, grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 }, + // The grid cell is the arriving wrapper; the tile fills it and presses in inside it. + tileSlot: { flexGrow: 1, flexBasis: '45%' }, tile: { - flexGrow: 1, - flexBasis: '45%', flexDirection: 'row', alignItems: 'center', gap: 10, diff --git a/apps/mobile/src/app/(tabs)/(library)/library.tsx b/apps/mobile/src/app/(tabs)/(library)/library.tsx index 59088805b..74e3da213 100644 --- a/apps/mobile/src/app/(tabs)/(library)/library.tsx +++ b/apps/mobile/src/app/(tabs)/(library)/library.tsx @@ -2,10 +2,10 @@ import { GENERAL_FOLDER, matchesLibrarySearch, normalizeFolder, sortLibrarySourc import { useFocusEffect, useRouter } from 'expo-router'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, ScrollView, StyleSheet, View, useWindowDimensions } from 'react-native'; +import { ScrollView, StyleSheet, View } from 'react-native'; import { TextButton } from '@/components/buttons'; -import { Card } from '@/components/card'; +import { Card, ListItemIn } from '@/components/card'; import { Chip } from '@/components/chip'; import { EmptyState } from '@/components/empty-state'; import { LibraryHeader } from '@/components/library-header'; @@ -15,6 +15,7 @@ import { OfflineNotice } from '@/components/offline-notice'; import { OneOffNotice } from '@/components/one-off-notice'; import { Screen } from '@/components/screen'; import { SearchField } from '@/components/search-field'; +import { SkeletonRows } from '@/components/skeleton'; import { folderLabel } from '@/components/source-display'; import { SourceRow } from '@/components/source-row'; import { SourceView } from '@/components/source-view'; @@ -24,7 +25,8 @@ import { useAuth } from '@/lib/auth'; import { confirmDestructive, promptText } from '@/lib/confirm'; import { useDebates } from '@/lib/debate-context'; import { haptics } from '@/lib/haptics'; -import { LIBRARY_ROOMY_WIDTH, LIST_WIDTH, SIDEBAR_WIDTH, useLayoutClass } from '@/lib/layout'; +import { LIBRARY_ROOMY_WIDTH, LIST_WIDTH, useContentWidth, useLayoutClass } from '@/lib/layout'; +import { clearQueuedDebate, currentSourceId, waitingSourceIds, withQueuedWrites } from '@/lib/offline-queue'; import { openRowId } from '@/lib/open-pane'; import { pickSourceFile } from '@/lib/pick-source-file'; import { useSourceActions } from '@/lib/source-actions'; @@ -32,6 +34,7 @@ import { holdPickedFile } from '@/lib/source-file'; import { clearLibrary, createFolder, deleteFolder, fetchSources, renameFolder, subscribeSourcesChanged, type Source } from '@/lib/sources'; import { useCopyHolder } from '@/lib/use-copy-holder'; import { useLibraryFolders } from '@/lib/use-library-folders'; +import { useQueuedWrites } from '@/lib/use-offline-queue'; import { useTheme } from '@/theme/use-theme'; const ALL = '\u0000all'; @@ -55,20 +58,22 @@ function LibraryView({ debateId, layout }: { debateId: string | null; layout: Re const router = useRouter(); const { colors } = useTheme(); const toast = useToast(); - const { width } = useWindowDimensions(); + const width = useContentWidth(); const { session } = useAuth(); const { status } = useDebates(); const userId = session?.user.id ?? null; const library = useLibrary(debateId); // On iPad the selection belongs to the account it was made in; signing out clears it. const [selected, setSelected] = useState<{ userId: string; id: string } | null>(null); - const shown = layout !== 'phone' && selected && selected.userId === userId ? selected.id : null; + // A waiting note that has synced stays open under its row's id. + const shown = layout !== 'phone' && selected && selected.userId === userId ? currentSourceId(library.queued, selected.id) : null; // A refresh that no longer has the open source closes the detail pane. Searching does not: `sources` is the full list. + // A note that has just synced counts until the list is read again. useEffect(() => { if (!shown || library.sources === null) return; - if (openRowId(shown, library.sources) === null) setSelected(null); - }, [shown, library.sources]); + if (openRowId(shown, [...library.sources, ...library.queued.synced.values()]) === null) setSelected(null); + }, [shown, library.sources, library.queued]); const onAdd = async (kind: AddSourceKind) => { if (kind === 'link') return router.push('/add-link'); @@ -98,7 +103,8 @@ function LibraryView({ debateId, layout }: { debateId: string | null; layout: Re const confirmed = await confirmDestructive({ title: t('libraryPage.clearAll'), message: t('libraryPage.clearConfirm'), confirm: t('libraryPage.clearAll'), cancel: t('common.cancel') }); if (!confirmed) return; try { - await clearLibrary(userId, debateId); + // Notes and changes still waiting to sync would come back after the clear; they go once it succeeds. + await clearQueuedDebate(userId, debateId, () => clearLibrary(userId, debateId)); setSelected(null); library.setSources([]); toast({ tone: 'success', title: t('common.deleted'), message: t('libraryPage.clearSuccess') }); @@ -136,7 +142,7 @@ function LibraryView({ debateId, layout }: { debateId: string | null; layout: Re ) : null; const body = debateId ? ( - setSelected({ userId, id: source.id })} /> + setSelected({ userId, id: source.id })} onRemoved={(id) => setSelected((current) => (current && currentSourceId(library.queued, current.id) === id ? null : current))} /> ) : null; if (layout === 'phone') { @@ -145,14 +151,14 @@ function LibraryView({ debateId, layout }: { debateId: string | null; layout: Re {header} {/* New sources still go to the Current Debate in one-off mode, as on the website, so this only says the mode is on. */} - {status === 'loading' ? : null} + {status === 'loading' && !debateId ? : null} {noDebate} {body} ); } - const available = width - (layout === 'wide' ? SIDEBAR_WIDTH : 0); + const available = width; // With nothing open the list takes the whole width, as the website's Library does; an open // source splits it, and the list keeps 40% (at least its usual width) so titles stay readable. const listWidth = Math.max(available >= LIBRARY_ROOMY_WIDTH ? LIST_WIDTH.libraryRoomy : LIST_WIDTH.library, Math.round(available * 0.4)); @@ -161,17 +167,22 @@ function LibraryView({ debateId, layout }: { debateId: string | null; layout: Re {header} - {status === 'loading' ? : null} + {status === 'loading' && !debateId ? : null} {noDebate} {body} - {shown && userId ? ( - - setSelected(null)} /> + {shown && selected && userId ? ( + + {/* Keyed by the id that was opened: a note that syncs swaps its local id for the row's, and the + view follows that itself, so an edit in progress survives the sync. */} + setSelected((current) => (current?.id === selected.id ? null : current))} /> ) : null} @@ -184,8 +195,9 @@ type Library = ReturnType; function useLibrary(debateId: string | null) { const { session } = useAuth(); const userId = session?.user.id ?? null; - const [sources, setSources] = useState(null); + const [fetched, setSources] = useState(null); const [failed, setFailed] = useState(false); + const queued = useQueuedWrites(userId); const [query, setQuery] = useState(''); const [filter, setFilter] = useState('all'); const [sort, setSort] = useState('smart'); @@ -219,6 +231,21 @@ function useLibrary(debateId: string | null) { ); useEffect(() => subscribeSourcesChanged(() => void load()), [load]); + // Changes that wait for the connection show on top of what was read, online or from the saved copy. + const sources = useMemo(() => (fetched && debateId ? withQueuedWrites(queued, debateId, fetched) : fetched), [fetched, queued, debateId]); + const waiting = useMemo(() => waitingSourceIds(queued), [queued]); + // A note that syncs takes its row's place at once, before the list is read again. + const synced = useRef(new Set(queued.synced.keys())); + useEffect(() => { + for (const [tempId, source] of queued.synced) { + if (synced.current.has(tempId)) continue; + synced.current.add(tempId); + if (source.debateId !== debateId) continue; + // The list may hold the note under its temporary id (from the saved copy); the row replaces it, once. + setSources((previous) => previous && [source, ...previous.filter((item) => item.id !== tempId && item.id !== source.id)]); + } + }, [queued.synced, debateId]); + // A folder that no longer exists here (renamed or deleted elsewhere, or only another debate's) // shows all folders again. Checked only when the folder list reloads: a folder just created or // renamed here is chosen before its reload lands, and the reload then lists it. @@ -242,16 +269,16 @@ function useLibrary(debateId: string | null) { setSources((previous) => previous?.map((source) => (ids.includes(source.id) ? { ...source, ...change } : source)) ?? previous); }, []); - return { userId, debateId: debateId!, sources, setSources, failed, load, query, setQuery, filter, setFilter, sort, setSort, folder, setFolder, folders, inFolder, visible, patch }; + return { userId, debateId: debateId!, sources, setSources, queued, waiting, failed, load, query, setQuery, filter, setFilter, sort, setSort, folder, setFolder, folders, inFolder, visible, patch }; } -function LibraryBody({ library, selectedId, onSelect }: { library: Library; selectedId: string | null; onSelect?: (source: Source) => void }) { +function LibraryBody({ library, selectedId, onSelect, onRemoved }: { library: Library; selectedId: string | null; onSelect?: (source: Source) => void; onRemoved?: (id: string) => void }) { const { t } = useTranslation(); const router = useRouter(); const toast = useToast(); const actions = useSourceActions(); const activeWrites = useRef({ seq: 0, latest: new Map(), confirmed: new Map() }); - const { sources, failed, folders, folder, inFolder, visible, patch, userId, debateId } = library; + const { sources, failed, folders, folder, inFolder, visible, patch, userId, debateId, waiting } = library; if (failed && !sources) { return ( @@ -263,7 +290,7 @@ function LibraryBody({ library, selectedId, onSelect }: { library: Library; sele ); } - if (!sources) return ; + if (!sources) return ; const active = sources.filter((source) => source.isActive).length; const chipFolders = Array.from(new Set([...folders, ...sources.map((source) => source.folder)])).sort(); @@ -295,6 +322,9 @@ function LibraryBody({ library, selectedId, onSelect }: { library: Library; sele const remove = async (source: Source) => { const removed = await actions.remove(source); if (removed) library.setSources((previous) => previous?.filter((item) => item.id !== source.id) ?? previous); + // Deleting the source open beside the list closes it, as its own Delete does. The parent checks + // the selection when the delete lands, so a source opened meanwhile stays open. + if (removed) onRemoved?.(source.id); return removed; }; @@ -370,29 +400,31 @@ function LibraryBody({ library, selectedId, onSelect }: { library: Library; sele ) : ( {visible.map((source, index) => ( - (onSelect ? onSelect(source) : router.push({ pathname: '/source/[id]', params: { id: source.id } })), - onToggle: (isActive) => void toggle([source], isActive), - onDelete: () => remove(source), - onCopy: () => void actions.copy(source), - onShare: () => actions.share(source), - onMove: (target) => { - patch([source.id], { folder: target }); - void actions.move(source, target).then((moved) => { - if (!moved) patch([source.id], { folder: source.folder }); - }); - }, - }} - /> + + (onSelect ? onSelect(source) : router.push({ pathname: '/source/[id]', params: { id: source.id } })), + onToggle: (isActive) => void toggle([source], isActive), + onDelete: () => remove(source), + onCopy: () => void actions.copy(source), + onShare: () => actions.share(source), + onMove: (target) => { + patch([source.id], { folder: target }); + void actions.move(source, target).then((moved) => { + if (!moved) patch([source.id], { folder: source.folder }); + }); + }, + }} + /> + ))} )} @@ -401,7 +433,6 @@ function LibraryBody({ library, selectedId, onSelect }: { library: Library; sele } const styles = StyleSheet.create({ - loading: { marginTop: 32 }, body: { fontSize: 15, lineHeight: 21 }, summary: { fontSize: 14.5, marginHorizontal: 2, marginTop: -6 }, chipRow: { marginHorizontal: -20, flexGrow: 0 }, diff --git a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx index b5f18de0b..1fb2a71e2 100644 --- a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx +++ b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx @@ -1,10 +1,12 @@ import { useFocusEffect, useRouter, type Href } from 'expo-router'; import { useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Pressable, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; -import { Card, IconTile } from '@/components/card'; +import { Card, IconTile, ListItemIn } from '@/components/card'; +import { EmptyState } from '@/components/empty-state'; import { Icon } from '@/components/icon'; +import { PressScale } from '@/components/press-scale'; import { PRACTICE_TOOLS, type PracticeTool } from '@/components/practice-tools'; import { ScreenTitle } from '@/components/screen-title'; import { Screen } from '@/components/screen'; @@ -81,23 +83,20 @@ export default function PracticeScreen() { {PRACTICE_TOOLS.map((tool, index) => ( - router.push(TOOL_ROUTES[tool.key])} - testID={`practice-tool-${tool.key}`} - style={({ pressed }) => [styles.row, index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> - - - - {t(`nav.${tool.key}`)} - - - {t(`navDesc.${tool.key}`)} - - - - + 0 && { borderTopWidth: 1, borderTopColor: colors.line }}> + router.push(TOOL_ROUTES[tool.key])} testID={`practice-tool-${tool.key}`} style={styles.row}> + + + + {t(`nav.${tool.key}`)} + + + {t(`navDesc.${tool.key}`)} + + + + + ))} @@ -114,21 +113,18 @@ export default function PracticeScreen() { ) : items.length === 0 ? ( - - - {t('historyPage.emptySparringDesc')} - - + ) : ( {items.map((round, index) => ( - void load()} - /> + + void load()} + /> + ))} )} @@ -144,7 +140,6 @@ const styles = StyleSheet.create({ rowText: { flex: 1, gap: 2 }, rowTitle: { fontSize: 16 }, rowMeta: { fontSize: 14 }, - pressed: { opacity: 0.7 }, section: { fontSize: 20, lineHeight: 26, marginTop: 6 }, body: { fontSize: 15.5, lineHeight: 22 }, }); diff --git a/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx b/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx index 3ceece427..5a2cde705 100644 --- a/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx +++ b/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx @@ -1,15 +1,17 @@ import { useFocusEffect, useRouter } from 'expo-router'; import { useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import { TextButton } from '@/components/buttons'; -import { Card, IconTile } from '@/components/card'; +import { Card, IconTile, ListItemIn } from '@/components/card'; import { Icon } from '@/components/icon'; import { OneOffNotice } from '@/components/one-off-notice'; +import { PressScale } from '@/components/press-scale'; import { PREPARE_TOOLS, prepareToolHref, type PrepareTool } from '@/components/prepare-tools'; import { Screen } from '@/components/screen'; import { ScreenTitle } from '@/components/screen-title'; +import { SkeletonRows } from '@/components/skeleton'; import { Text } from '@/components/text'; import { WorkRow } from '@/components/work-row'; import { useAuth } from '@/lib/auth'; @@ -74,23 +76,20 @@ export default function PrepareScreen() { {PREPARE_TOOLS.map((tool, index) => ( - openTool(tool.key)} - testID={`prepare-tool-${tool.key}`} - style={({ pressed }) => [styles.row, index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> - - - - {t(`nav.${tool.key}`)} - - - {t(`navDesc.${tool.key}`)} - - - - + 0 && { borderTopWidth: 1, borderTopColor: colors.line }}> + openTool(tool.key)} testID={`prepare-tool-${tool.key}`} style={styles.row}> + + + + {t(`nav.${tool.key}`)} + + + {t(`navDesc.${tool.key}`)} + + + + + ))} @@ -110,7 +109,7 @@ export default function PrepareScreen() { void load()} /> ) : items === null ? ( - + ) : items.length === 0 ? ( @@ -120,7 +119,9 @@ export default function PrepareScreen() { ) : ( {items.map((item, index) => ( - void load()} /> + + void load()} /> + ))} )} @@ -134,8 +135,6 @@ const styles = StyleSheet.create({ rowText: { flex: 1, gap: 2 }, rowTitle: { fontSize: 16 }, rowMeta: { fontSize: 14 }, - pressed: { opacity: 0.7 }, section: { fontSize: 18.5, lineHeight: 24, letterSpacing: -0.2, marginTop: 8, marginHorizontal: 2 }, body: { fontSize: 15.5, lineHeight: 21 }, - loading: { marginTop: 16 }, }); diff --git a/apps/mobile/src/app/(tabs)/(you)/you.tsx b/apps/mobile/src/app/(tabs)/(you)/you.tsx index f64d3e61a..1a068ad6a 100644 --- a/apps/mobile/src/app/(tabs)/(you)/you.tsx +++ b/apps/mobile/src/app/(tabs)/(you)/you.tsx @@ -5,16 +5,18 @@ import * as WebBrowser from 'expo-web-browser'; import type { TFunction } from 'i18next'; import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, AppState, Linking, Pressable, StyleSheet, View } from 'react-native'; +import { ActivityIndicator, AppState, Linking, Pressable, StyleSheet, View, type PressableProps } from 'react-native'; +import Animated from 'react-native-reanimated'; import { TextButton } from '@/components/buttons'; -import { Card, IconTile } from '@/components/card'; +import { Card, IconTile, usePressScale } from '@/components/card'; import { SwitchRow } from '@/components/form-rows'; import { Icon } from '@/components/icon'; import type { IconName } from '@/components/icon-paths'; import { ScreenTitle } from '@/components/screen-title'; import { Screen } from '@/components/screen'; import { SegmentedControl } from '@/components/segmented-control'; +import { Skeleton } from '@/components/skeleton'; import { Text } from '@/components/text'; import { exportAccountData } from '@/lib/account'; import { useAuth } from '@/lib/auth'; @@ -126,11 +128,10 @@ export default function YouScreen() { - router.push({ pathname: '/history', params: { from: 'you' } })} - testID="you-history" - style={({ pressed }) => [styles.row, pressed && styles.pressed]}> + testID="you-history"> @@ -141,7 +142,7 @@ export default function YouScreen() { - + @@ -204,13 +205,13 @@ export default function YouScreen() { ) : null} - void download()} testID="you-download-data" - style={({ pressed }) => [styles.row, { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> + divider> @@ -220,19 +221,16 @@ export default function YouScreen() { {t('settings.yourDataDesc')} + {/* Busy inside its own row, so it keeps the spinner. */} {exporting ? : null} - + {t('settings.dangerZone')} - router.push('/delete-data')} - testID="you-delete-data" - style={({ pressed }) => [styles.row, pressed && styles.pressed]}> + router.push('/delete-data')} testID="you-delete-data"> {t('settings.deleteAllData')} @@ -242,11 +240,8 @@ export default function YouScreen() { - - router.push('/delete-account')} - style={({ pressed }) => [styles.row, { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> + + router.push('/delete-account')} divider> {t('settings.deleteAccount')} @@ -256,7 +251,7 @@ export default function YouScreen() { - + @@ -273,15 +268,23 @@ export default function YouScreen() { function LinkRow({ icon, label, url, first }: { icon: IconName; label: string; url: string; first?: boolean }) { const { colors } = useTheme(); return ( - void WebBrowser.openBrowserAsync(url)} - style={({ pressed }) => [styles.row, !first && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> + void WebBrowser.openBrowserAsync(url)} divider={!first}> {label} + + ); +} + +/** A row in a card that presses in under the finger; its divider stays put. */ +function PressRow({ divider = false, children, ...props }: Omit & { divider?: boolean; children: ReactNode }) { + const { colors } = useTheme(); + const press = usePressScale(); + return ( + + {({ pressed }) => {children}} ); } @@ -426,11 +429,10 @@ function SubscriptionCard({ userId }: { userId: string | undefined }) { void load(() => true)} /> ) : !subscription ? ( - - - - {t('settings.loading')} - + // The plan's lines loading in their own shape; "Loading" is what VoiceOver reads. + + + ) : ( @@ -460,7 +462,7 @@ const styles = StyleSheet.create({ planTitle: { flex: 1, fontSize: 17 }, planBody: { gap: 4 }, usage: { fontSize: 16 }, - loadingRow: { flexDirection: 'row', alignItems: 'center', gap: 10 }, + planShape: { marginVertical: 3 }, badge: { alignSelf: 'flex-start', flexDirection: 'row', alignItems: 'center', gap: 4, borderRadius: 999, borderWidth: 1, paddingHorizontal: 10, paddingVertical: 3 }, badgeText: { fontSize: 13 }, setting: { gap: 7 }, diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx index d6c75f96e..380678a2c 100644 --- a/apps/mobile/src/app/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -1,6 +1,9 @@ import AppTabs from '@/components/app-tabs'; import { useInactiveDebateCheck } from '@/lib/use-inactive-debate-check'; +// Tabs opened beneath a linked screen (the root layout's anchor) start on the Debate tab. +export const unstable_settings = { anchor: '(debate)' }; + export default function TabsLayout() { useInactiveDebateCheck(); return ; diff --git a/apps/mobile/src/app/+native-intent.tsx b/apps/mobile/src/app/+native-intent.tsx new file mode 100644 index 000000000..84d069642 --- /dev/null +++ b/apps/mobile/src/app/+native-intent.tsx @@ -0,0 +1,18 @@ +import { appPathForUrl } from '@/lib/deep-links'; + +/** + * Every URL the system hands the app passes here first (Expo Router's native + * intent). Website links (universal links, once Associated Domains is set up) + * become app routes: https://debateowl.com/share/ opens the shared item, + * /app/ the matching tool. The app's own scheme (debatelab://), Expo Go's + * exp:// and a plain launch pass through unchanged. Any other web address opens + * the Debate tab, never a route taken from it. + */ +export function redirectSystemPath({ path }: { path: string; initial: boolean }): string { + try { + if (!/^https?:/i.test(path)) return path; + return appPathForUrl(path) ?? '/'; + } catch { + return '/'; + } +} diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 70454b13a..0e4a3ebbf 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -1,17 +1,24 @@ import '@/lib/i18n'; import { useFonts } from 'expo-font'; -import { DarkTheme, DefaultTheme, Stack, ThemeProvider } from 'expo-router'; +import { DarkTheme, DefaultTheme, Stack, ThemeProvider, useRouter } from 'expo-router'; import type { NativeStackNavigationOptions } from 'expo-router/native-stack'; import * as SplashScreen from 'expo-splash-screen'; import { StatusBar } from 'expo-status-bar'; import { useEffect } from 'react'; -import { StyleSheet } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { AppSidebar } from '@/components/app-sidebar'; +import { KeyboardDoneBar } from '@/components/app-text-input'; import { ToastHost, ToastProvider } from '@/components/toast'; import { AuthProvider, useAuth } from '@/lib/auth'; import { DebateProvider } from '@/lib/debate-context'; +import { useFirstRun } from '@/lib/use-first-run'; +import { useKeyCommand } from '@/lib/key-commands'; +import { useQuickActions } from '@/lib/quick-actions'; +import { useOfflineQueue } from '@/lib/use-offline-queue'; +import { ReservedWidth, useSidebarWidth } from '@/lib/layout'; import { watchNetwork } from '@/lib/network-status'; import { refreshPush } from '@/lib/push'; import { SettingsProvider, useSettings } from '@/lib/settings'; @@ -20,6 +27,10 @@ import { useTheme } from '@/theme/use-theme'; void SplashScreen.preventAutoHideAsync(); +// A screen opened straight from a link (a share link, the widget, a quick action) has the tabs +// beneath it, so Back and the iPad sidebar always have the tabs to return to. +export const unstable_settings = { anchor: '(tabs)' }; + export default function RootLayout() { return ( @@ -39,6 +50,8 @@ function Root() { const { session, loading, settled } = useAuth(); const { ready: settingsReady, language } = useSettings(); const { scheme, colors } = useTheme(); + // A full-screen iPad window lists every tool in a sidebar beside the stack, so it stays while a tool is open. + const sidebarWidth = useSidebarWidth(); // Settings come from the device here, so the app opens in the account's language and theme. const ready = (fontsLoaded || fontError !== null) && !loading && settingsReady; @@ -48,9 +61,18 @@ function Root() { // Offline, reads answer from saved copies and the screens say so. useEffect(() => watchNetwork(), []); + // Notes, source edits and Active switches made offline sync when the connection returns. + useOfflineQueue(); // A phone that opted in registers again on launch and when the language changes (see refreshPush). const userId = session?.user.id; + useFirstRun(ready ? userId ?? null : null); + // Home Screen quick actions (development build with expo-quick-actions; nothing in Expo Go). + useQuickActions(ready); + // iPad hardware keyboard: ⌘N starts a debate, ⌘K opens the switcher (key-commands.ts). + const router = useRouter(); + useKeyCommand('cmd+n', () => router.push('/new-debate'), ready && Boolean(userId)); + useKeyCommand('cmd+k', () => router.push('/debates'), ready && Boolean(userId)); useEffect(() => { if (!userId || !settingsReady) return; refreshPush(userId, language).catch((cause: unknown) => { @@ -87,6 +109,9 @@ function Root() { {/* The screenshot script waits for auth-settled before it checks an offline cold start. Swipeable rows need the gesture root. */} + + {session !== null && sidebarWidth > 0 ? : null} + @@ -104,6 +129,8 @@ function Root() { + {/* Ollie's welcome and the first-run tour, at full height so the owl and the buttons both fit. */} + {/* Full screen above the tabs, as in mock 07. */} {/* History, full screen above the tabs like the documents it opens. */} @@ -131,8 +158,13 @@ function Root() { + {/* A shared link opens signed in or out; after the sign-in group, so a signed-out launch still starts on sign-in. */} + + + + ); @@ -140,4 +172,5 @@ function Root() { const styles = StyleSheet.create({ root: { flex: 1 }, + row: { flex: 1, flexDirection: 'row' }, }); diff --git a/apps/mobile/src/app/ask.tsx b/apps/mobile/src/app/ask.tsx index e9d67e5ed..e667f0b24 100644 --- a/apps/mobile/src/app/ask.tsx +++ b/apps/mobile/src/app/ask.tsx @@ -3,9 +3,10 @@ import type { AskOutput } from '@debatelab/core/ai/schemas/ask'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, TextInput, View } from 'react-native'; +import { ActivityIndicator, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { AppTextInput } from '@/components/app-text-input'; import { AskAnswer } from '@/components/ask-answer'; import { AskChatPane } from '@/components/ask-chat-list'; import { AskHistory } from '@/components/ask-history'; @@ -19,6 +20,8 @@ import { OneOffNotice } from '@/components/one-off-notice'; import { ContextLine } from '@/components/context-line'; import { DailyUsageLine } from '@/components/tool-form'; import { Text } from '@/components/text'; +import { useKeyCommand } from '@/lib/key-commands'; +import { announceAnswer } from '@/lib/a11y'; import { AIError, isAbortError, type AIErrorKind, type ResponseExtras } from '@/lib/ai-request'; import { askOnce, canStream, isFinalAskError, isMessageWithinBudget, streamAsk, type AskMode, type AskOptions } from '@/lib/ask'; import { pickAskFiles, readAskFiles, type AskAttachment } from '@/lib/ask-attachments'; @@ -196,6 +199,11 @@ export default function AskScreen() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [loading, lastRole, messages, userId]); + // ⌘↩ sends, as the send button does. + useKeyCommand('cmd+enter', () => { + if (!busy && input.trim()) void send(input, messages, attachments); + }); + const send = async (text: string, history: ChatMessage[], files: AskAttachment[] = []) => { const question = text.trim(); if (!question || !userId) return; @@ -320,6 +328,7 @@ export default function AskScreen() { setStreaming(null); partial.current = ''; setMessages((previous) => [...previous, { id: nextId(), role: 'assistant', content: result.response, timestamp: Date.now(), output }]); + announceAnswer(result.response); } catch (cause) { if (!isCurrent() || isAbortError(cause)) return; setStreaming(null); @@ -509,7 +518,7 @@ export default function AskScreen() { {messages.length === 0 && streaming === null && !busy ? ( - + {t('askChat.startConversation')} @@ -598,7 +607,7 @@ export default function AskScreen() { testID="ask-attach"> - - + {t(THINKING[index])} diff --git a/apps/mobile/src/app/attack/[id].tsx b/apps/mobile/src/app/attack/[id].tsx index dac6b51bc..ef69876e9 100644 --- a/apps/mobile/src/app/attack/[id].tsx +++ b/apps/mobile/src/app/attack/[id].tsx @@ -11,11 +11,13 @@ import { DocumentStatus } from '@/components/document-status'; import { backLabel } from '@/lib/back-label'; import { formatWhen } from '@/lib/dates'; import { copyText, sharePdf, shareText } from '@/lib/export'; +import { shareAttackDocx } from '@/lib/export-docx'; import { attackExportFileName, attackExportHtml } from '@/lib/export-html'; import { crossfireFromAttack } from '@/lib/result-links'; import { attackToText, discussAttackMessage } from '@/lib/result-text'; import { isCompleteAttack } from '@/lib/results'; import { arrivalNoticeText, useResultDocument } from '@/lib/use-result-document'; +import { useShareLink } from '@/lib/use-share-link'; /** * An attack analysis in the same document style as a case. Its pinned next @@ -28,6 +30,7 @@ export default function AttackResultScreen() { const doc = useResultDocument('attack', id); const [exportOpen, setExportOpen] = useState(false); const { setNotice } = doc; + const shareLink = useShareLink(setNotice); useEffect(() => { const text = arrivalNoticeText(t, arrival, failed); @@ -65,11 +68,13 @@ export default function AttackResultScreen() { router.push({ pathname: '/crossfire', params: { from: 'attack' } }); }; + const savedId = result.savedId; const actions: HeaderAction[] = [ { key: 'crossfire', label: t('crossfire.openFromResult'), symbol: 'person.2.wave.2', icon: 'swords', onPress: openCrossfire }, ...(doc.autoSaved.length > 0 ? [{ key: 'undo-sources', label: t('autoSavedSources.undo'), symbol: 'arrow.uturn.backward' as const, icon: 'history' as const, onPress: () => void doc.undoAutoSaved() }] : []), + ...(savedId ? [{ key: 'share-link', label: t('mobile.share.shareLink'), symbol: 'link' as const, icon: 'link' as const, onPress: () => void shareLink('attack', savedId, result.title || title, output) }] : []), ...(result.savedId ? [{ key: 'delete', label: t('common.delete'), symbol: 'trash' as const, icon: 'trash' as const, destructive: true, onPress: () => void doc.remove() }] : []), ]; @@ -78,6 +83,7 @@ export default function AttackResultScreen() { testID="attack-document" outline notice={doc.notice} + noticeTone={doc.noticeTone} header={ run(() => shareText(title, text()))} actions={actions} />} actions={ <> @@ -94,11 +100,14 @@ export default function AttackResultScreen() { items={[ { key: 'pdf', label: t('common.pdf'), icon: 'file' }, { key: 'copy', label: t('mobile.results.copyText'), icon: 'copy' }, + // The website's Word export, built by the same code (packages/core/src/export/docx.ts). + { key: 'docx', label: t('common.docx'), icon: 'file' }, ]} cancelLabel={t('common.cancel')} onClose={() => setExportOpen(false)} onSelect={(choice) => { if (choice === 'pdf') run(() => sharePdf(attackExportHtml(output, t), attackExportFileName(output), title)); + if (choice === 'docx') run(() => shareAttackDocx(output, t, title)); if (choice === 'copy') run(async () => { await copyText(text()); setNotice(t('generateResults.copiedToClipboard')); diff --git a/apps/mobile/src/app/brainstorm/[id].tsx b/apps/mobile/src/app/brainstorm/[id].tsx index aba4e01f9..2402109f3 100644 --- a/apps/mobile/src/app/brainstorm/[id].tsx +++ b/apps/mobile/src/app/brainstorm/[id].tsx @@ -12,10 +12,12 @@ import { DocumentStatus } from '@/components/document-status'; import { backLabel } from '@/lib/back-label'; import { formatWhen } from '@/lib/dates'; import { copyText, sharePdf, shareText } from '@/lib/export'; +import { shareBrainstormDocx } from '@/lib/export-docx'; import { BRAINSTORM_EXPORT_FILE_NAME, brainstormExportHtml } from '@/lib/export-html'; import { brainstormToText, discussBrainstormMessage } from '@/lib/result-text'; import { readBrainstorm } from '@/lib/results'; import { arrivalNoticeText, useResultDocument } from '@/lib/use-result-document'; +import { useShareLink } from '@/lib/use-share-link'; /** * A brainstorm read as a document. Its pinned next steps are the website's: @@ -28,6 +30,7 @@ export default function BrainstormResultScreen() { const doc = useResultDocument('brainstorm', params.id); const [exportOpen, setExportOpen] = useState(false); const { setNotice } = doc; + const shareLink = useShareLink(setNotice); const { notice: arrival, failed } = params; // Problems from saving a fresh result are told once, on arrival. @@ -62,10 +65,13 @@ export default function BrainstormResultScreen() { }); }; + const savedId = result.savedId; const actions: HeaderAction[] = [ ...(doc.autoSaved.length > 0 ? [{ key: 'undo-sources', label: t('autoSavedSources.undo'), symbol: 'arrow.uturn.backward' as const, icon: 'history' as const, onPress: () => void doc.undoAutoSaved() }] : []), + // As Export, not for a brainstorm that only asks questions. + ...(savedId && !questionsOnly ? [{ key: 'share-link', label: t('mobile.share.shareLink'), symbol: 'link' as const, icon: 'link' as const, onPress: () => void shareLink('brainstorm', savedId, title, output) }] : []), ...(result.savedId ? [{ key: 'delete', label: t('common.delete'), symbol: 'trash' as const, icon: 'trash' as const, destructive: true, onPress: () => void doc.remove() }] : []), ]; @@ -74,6 +80,7 @@ export default function BrainstormResultScreen() { testID="brainstorm-document" outline notice={doc.notice} + noticeTone={doc.noticeTone} header={ run(() => shareText(title, brainstormToText(output, t)))} actions={actions} />} // The website shows only the questions when the model needs more, with no export or discussion. actions={questionsOnly ? undefined : ( @@ -97,11 +104,14 @@ export default function BrainstormResultScreen() { items={[ { key: 'pdf', label: t('common.pdf'), icon: 'file' }, { key: 'copy', label: t('mobile.results.copyText'), icon: 'copy' }, + // The website's Word export, built by the same code (packages/core/src/export/docx.ts). + { key: 'docx', label: t('common.docx'), icon: 'file' }, ]} cancelLabel={t('common.cancel')} onClose={() => setExportOpen(false)} onSelect={(choice) => { if (choice === 'pdf') run(() => sharePdf(brainstormExportHtml(output, t), BRAINSTORM_EXPORT_FILE_NAME, title)); + if (choice === 'docx') run(() => shareBrainstormDocx(output, t, title)); if (choice === 'copy') run(async () => { await copyText(brainstormToText(output, t)); setNotice(t('generateResults.copiedToClipboard')); diff --git a/apps/mobile/src/app/case/[id].tsx b/apps/mobile/src/app/case/[id].tsx index 52f3d5131..df92284a2 100644 --- a/apps/mobile/src/app/case/[id].tsx +++ b/apps/mobile/src/app/case/[id].tsx @@ -1,11 +1,14 @@ +import { readCase } from '@debatelab/core/ai/case-view'; import type { CaseSection } from '@debatelab/core/ai/generate-output'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; import { ActionMenu } from '@/components/action-menu'; import { PrimaryButton, SecondaryButton } from '@/components/buttons'; import { CaseDocument } from '@/components/case-document'; +import { Celebration, clearCelebration, hasCelebration } from '@/components/celebration'; import { openCitation } from '@/components/citation-target'; import { DocumentScreen, DocumentTitle } from '@/components/document'; import { DocumentHeader, type HeaderAction } from '@/components/document-header'; @@ -16,11 +19,13 @@ import { useCaseEdits } from '@/lib/case-edits'; import { useDebates } from '@/lib/debate-context'; import { formatWhen } from '@/lib/dates'; import { copyText, sharePdf, shareText } from '@/lib/export'; +import { shareCaseDocx } from '@/lib/export-docx'; import { caseExportFileName, caseExportHtml } from '@/lib/export-html'; import { caseToText, discussCaseMessage } from '@/lib/result-text'; import { buildFlowchartFromCase, crossfireFromCase } from '@/lib/result-links'; import { isCompleteCase } from '@/lib/results'; import { arrivalNoticeText, useResultDocument } from '@/lib/use-result-document'; +import { useShareLink } from '@/lib/use-share-link'; /** A generated case read as a document, as in mock 03. */ export default function CaseScreen() { @@ -33,6 +38,10 @@ export default function CaseScreen() { const [regenerateOpen, setRegenerateOpen] = useState(false); const { setNotice } = doc; const edits = useCaseEdits(doc.state.status === 'ready' ? doc.state.key : null, setNotice); + const shareLink = useShareLink(setNotice); + // Only the case Generate just made celebrates, once; a saved case reopened, or this one after Back, does not. + const [celebrate, setCelebrate] = useState(() => hasCelebration(id)); + useEffect(() => clearCelebration(id), [id]); // Problems from saving a fresh result are told once, on arrival. useEffect(() => { @@ -77,15 +86,18 @@ export default function CaseScreen() { else setNotice(`${t('generate.flowchartImportFailed')}. ${t(outcome === 'noData' ? 'generate.flowchartNoData' : 'generate.flowchartOtherDebate')}`); }; - // The website's section cards, in their order; the opening shows only when the case has one. + // The website's section cards that can be regenerated, in their order, each only when the case shows it. + const view = readCase(output); const sections: { key: CaseSection; title: string }[] = [ - ...(output.exordio && (output.exordio.hook || output.exordio.transition) ? [{ key: 'exordio' as const, title: t('generateResults.exordio') }] : []), - { key: 'contentions', title: t('generateResults.arguments') }, - { key: 'structure', title: t('generateResults.caseStructure') }, - { key: 'opponent', title: t('generateResults.predictedOpponent') }, - { key: 'counters', title: t('generateResults.preparedCounters') }, - ]; + { key: 'opening' as const, title: t('generateResults.opening'), shown: Boolean(view.opening.hook || view.opening.definition || view.opening.roadmap || view.legacy.framework || view.legacy.burdenOfProof || view.legacy.definitions.length) }, + { key: 'contentions' as const, title: t('generateResults.arguments'), shown: view.arguments.length > 0 }, + { key: 'reinforce' as const, title: t('generateResults.reinforce'), shown: view.reinforce.length > 0 }, + { key: 'whyWeWin' as const, title: t('generateResults.whyWeWin'), shown: view.whyWeWin.length > 0 }, + { key: 'counters' as const, title: t('generateResults.ifTheySay'), shown: view.ifTheySay.length > 0 || view.legacy.likelyArguments.length > 0 || view.legacy.weaknesses.length > 0 || view.legacy.clashes.length > 0 }, + { key: 'closing' as const, title: t('generateResults.closingLine'), shown: Boolean(view.closingLine) }, + ].filter((section) => section.shown).map(({ key, title }) => ({ key, title })); const regeneratingTitle = sections.find((section) => section.key === edits.regenerating)?.title; + const savedId = result.savedId; const actions: HeaderAction[] = [ ...(edits.regenerating @@ -101,60 +113,87 @@ export default function CaseScreen() { ...(doc.autoSaved.length > 0 ? [{ key: 'undo-sources', label: t('autoSavedSources.undo'), symbol: 'arrow.uturn.backward' as const, icon: 'history' as const, onPress: () => void doc.undoAutoSaved() }] : []), + // Only a saved case can be shared: the link is made from its row. + ...(savedId ? [{ key: 'share-link', label: t('mobile.share.shareLink'), symbol: 'link' as const, icon: 'link' as const, onPress: () => void shareLink('case', savedId, title, output) }] : []), ...(result.savedId ? [{ key: 'delete', label: t('common.delete'), symbol: 'trash' as const, icon: 'trash' as const, destructive: true, onPress: () => void doc.remove() }] : []), ]; + const argumentCount = view.arguments.length; + const sourceCount = view.sources.length; + return ( - run(() => shareText(title, caseToText(output)))} actions={actions} />} - // "Attack this case" is the longer label, so it gets the wider button. - actionWidths={[1.3, 1]} - actions={ - <> - router.push({ pathname: '/attack', params: { caseKey: key } })} - testID="attack-this-case" - /> - setExportOpen(true)} disabled={doc.busy} testID="export-button" /> - - }> - - {regeneratingTitle ? : null} - openCitation(router, target)} /> - ({ key: section.key, label: t('common.regenerate', { title: section.title }), icon: 'reset' as const }))} - cancelLabel={t('common.cancel')} - onClose={() => setRegenerateOpen(false)} - onSelect={(choice) => void edits.regenerate(choice as CaseSection)} - /> - setExportOpen(false)} - onSelect={(choice) => { - if (choice === 'pdf') run(() => sharePdf(caseExportHtml(output, t), caseExportFileName(output), title)); - if (choice === 'copy') run(async () => { - await copyText(caseToText(output)); - setNotice(t('generateResults.copiedToClipboard')); - }); - }} - /> - + + run(() => shareText(title, caseToText(output)))} actions={actions} />} + // "Attack this case" is the longer label, so it gets the wider button. + actionWidths={[1.3, 1]} + actions={ + <> + router.push({ pathname: '/attack', params: { caseKey: key } })} + testID="attack-this-case" + /> + setExportOpen(true)} disabled={doc.busy} testID="export-button" /> + + }> + + {regeneratingTitle ? : null} + openCitation(router, target)} /> + ({ key: section.key, label: t('common.regenerate', { title: section.title }), icon: 'reset' as const }))} + cancelLabel={t('common.cancel')} + onClose={() => setRegenerateOpen(false)} + onSelect={(choice) => void edits.regenerate(choice as CaseSection)} + /> + setExportOpen(false)} + onSelect={(choice) => { + if (choice === 'pdf') run(() => sharePdf(caseExportHtml(output, t), caseExportFileName(output), title)); + if (choice === 'docx') run(() => shareCaseDocx(output, t, title)); + if (choice === 'copy') run(async () => { + await copyText(caseToText(output)); + setNotice(t('generateResults.copiedToClipboard')); + }); + }} + /> + + {celebrate ? ( + 0 ? [{ key: 'sources', label: t('generateResults.sources'), value: sourceCount }] : []), + ]} + // keepOpenResult already tapped success when the case arrived. + haptic={false} + onDone={() => setCelebrate(false)} + /> + ) : null} + ); } + +const styles = StyleSheet.create({ + root: { flex: 1 }, +}); diff --git a/apps/mobile/src/app/context-review.tsx b/apps/mobile/src/app/context-review.tsx index b95134113..ad52e97e0 100644 --- a/apps/mobile/src/app/context-review.tsx +++ b/apps/mobile/src/app/context-review.tsx @@ -7,12 +7,14 @@ import { useTranslation } from 'react-i18next'; import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; import { SecondaryButton, TextButton } from '@/components/buttons'; +import { ListItemIn, SkeletonTextRows } from '@/components/card'; import { Icon } from '@/components/icon'; import { SheetScrollView } from '@/components/sheet'; import { Text } from '@/components/text'; import { useAuth } from '@/lib/auth'; import { hasContextChoices, PinLimitError, resetContextChoices, setOutputChoice, setSourceChoice, togglePin, useContextPlan } from '@/lib/context-plan'; import { useDebates } from '@/lib/debate-context'; +import { haptics } from '@/lib/haptics'; import { radius } from '@/theme/colors'; import { useTheme } from '@/theme/use-theme'; @@ -76,7 +78,7 @@ export default function ContextReviewSheet() { {!plan ? ( - + ) : ( <> @@ -104,7 +106,9 @@ export default function ContextReviewSheet() { ) : ( {plan.sources.map((source, index) => ( - setSourceChoice(plan.feature, plan.debateId, source.id, include)} /> + + setSourceChoice(plan.feature, plan.debateId, source.id, include)} /> + ))} )} @@ -125,14 +129,15 @@ export default function ContextReviewSheet() { ) : ( {plan.outputs.map((output, index) => ( - setOutputChoice(plan.feature, plan.debateId, output.ref, include)} - onPin={() => void pin(output)} - /> + + setOutputChoice(plan.feature, plan.debateId, output.ref, include)} + onPin={() => void pin(output)} + /> + ))} )} @@ -169,7 +174,10 @@ function CheckRow({ checked, label, meta, note, first, onChange, trailing }: { accessibilityRole="checkbox" accessibilityState={{ checked }} accessibilityLabel={label} - onPress={() => onChange(!checked)} + onPress={() => { + haptics.tap(); + onChange(!checked); + }} style={({ pressed }) => [styles.rowMain, pressed && styles.pressed]}> {checked ? : null} @@ -226,7 +234,10 @@ function OutputRow({ output, first, pinning, onChange, onPin }: { accessibilityLabel={output.pinned ? t('debateContext.unpinAria', { title }) : t('debateContext.pinAria', { title })} disabled={pinning} hitSlop={6} - onPress={onPin} + onPress={() => { + haptics.tap(); + onPin(); + }} style={({ pressed }) => [styles.pin, output.pinned && { backgroundColor: colors.tile }, pressed && styles.pressed]} testID="debate-context-pin"> {pinning ? : } @@ -240,7 +251,6 @@ const styles = StyleSheet.create({ header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, title: { fontSize: 21, lineHeight: 26, letterSpacing: -0.3, flexShrink: 1 }, body: { fontSize: 15, lineHeight: 21 }, - loading: { marginTop: 24 }, section: { gap: 8 }, heading: { fontSize: 16 }, note: { fontSize: 14.5, lineHeight: 20 }, diff --git a/apps/mobile/src/app/crossfire.tsx b/apps/mobile/src/app/crossfire.tsx index e4728e22d..cb9d23f0f 100644 --- a/apps/mobile/src/app/crossfire.tsx +++ b/apps/mobile/src/app/crossfire.tsx @@ -8,13 +8,16 @@ import { type CrossfireState, type CrossfireTurn, } from '@debatelab/core/crossfire/schemas'; +import { formatRoundTime } from '@debatelab/core/sparring/formats'; import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; import { useCallback, useEffect, useReducer, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, AppState, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, TextInput, View } from 'react-native'; +import { ActivityIndicator, AppState, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { AppTextInput } from '@/components/app-text-input'; import { MainActionButton, TextButton } from '@/components/buttons'; +import { Celebration } from '@/components/celebration'; import { ChoiceCards, CrossfireDebriefDocument, CrossfireTranscript } from '@/components/crossfire-views'; import { DocumentScreen, DocumentTitle, SectionHeading } from '@/components/document'; import { DocumentHeader } from '@/components/document-header'; @@ -28,6 +31,8 @@ import { SegmentedControl } from '@/components/segmented-control'; import { Text } from '@/components/text'; import { TimerRing } from '@/components/timer-ring'; import { ToolTitle, WorkingIn } from '@/components/tool-form'; +import { useKeyCommand } from '@/lib/key-commands'; +import { announceAnswer } from '@/lib/a11y'; import { abortError, AIError, isAbortError, type ResponseExtras, untilAborted } from '@/lib/ai-request'; import { useAuth } from '@/lib/auth'; import { haptics } from '@/lib/haptics'; @@ -123,6 +128,8 @@ export default function CrossfireScreen() { const roundQuota = useSessionQuota('crossfire'); // Start waits for the round's cost; a second tap meanwhile does nothing. const [checkingQuota, setCheckingQuota] = useState(false); + // The round whose debrief arrived while this screen watched celebrates once; a kept or restarted one does not. + const [celebrating, setCelebrating] = useState(null); // The debate chosen when the round started; null for one-off rounds. const debateTarget = useRef(crossfireDraft.get()?.debateId ?? null); const [draftTurn, setDraftTurn] = useState(''); @@ -244,8 +251,9 @@ export default function CrossfireScreen() { }); if (controller.signal.aborted) return; dispatch({ type: 'ai-turn-received', turn: nextTurn(snapshot.turns.length + 1, 'ai', reply.type, reply.text) }); - // The turn is back with the student. + // The turn is back with the student; VoiceOver reads the finished reply. haptics.turn(); + announceAnswer(reply.text); afterResponse(forUser, debateId, extras); if (reply.signalToken) { const signalController = new AbortController(); @@ -288,6 +296,8 @@ export default function CrossfireScreen() { // The effect below saves the finished round from the state it reaches, with any signal that came in meanwhile. if (debateId) pendingRecord.current = { sessionId: snapshot.sessionId, forUser, debateId }; dispatch({ type: 'debrief-received', debrief }); + // Shown only if the reducer took the debrief: the screen checks the round reached complete. + setCelebrating(snapshot.sessionId); // Review choices cover the round; its last request clears them, as on the website. resetContextChoices('crossfire'); afterResponse(forUser, debateId, extras); @@ -390,6 +400,9 @@ export default function CrossfireScreen() { if (live.current) beginSession(debateId, forUser); }; + // ⌘↩ submits the turn on an iPad keyboard. + useKeyCommand('cmd+enter', () => submitTurn()); + const submitTurn = () => { if (state.phase !== 'active' || !userId) return; const kind = expectedUserTurnKind(state); @@ -424,6 +437,7 @@ export default function CrossfireScreen() { abortSignals(); debateTarget.current = null; dispatch({ type: 'session-reset' }); + setCelebrating(null); setDraftTurn(''); setFormError(''); setNotice(''); @@ -546,20 +560,34 @@ export default function CrossfireScreen() { } if (state.phase === 'complete') { + const celebrate = celebrating === state.sessionId; return ( - } - notice={notice} - actions={}> - - - {t('crossfire.debriefSubtitle')} - - - - - + + } + notice={notice} + actions={}> + + + {t('crossfire.debriefSubtitle')} + + + + + + {celebrate ? ( + setCelebrating(null)} + /> + ) : null} + ); } @@ -689,7 +717,7 @@ export default function CrossfireScreen() { - - - {status === 'loading' ? : null} + {/* Only before any debate is known: a refresh keeps showing the list it has. */} + {status === 'loading' && debates.length === 0 ? : null} {status === 'error' ? ( @@ -156,7 +160,7 @@ export default function DebatesSheet() { ? t('mobile.debates.activeToday') : formatRelativeDate(debate.lastActivityAt); const row = ( - void pick(debate)} - style={({ pressed }) => [styles.row, { backgroundColor: colors.card }, pressed && styles.pressed]}> + onPress={() => void pick(debate)}> {debate.motion} @@ -182,32 +185,39 @@ export default function DebatesSheet() { ) : isCurrent ? ( ) : null} - + ); const divider = index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }; // The current debate cannot be deleted, as on the website. - if (isCurrent) return {row}; + if (isCurrent) { + return ( + + {row} + + ); + } return ( - ( - void removeDebate(debate)} - style={[styles.deleteAction, { backgroundColor: colors.destructive }]}> - - - {t('common.delete')} - - - )}> - {row} - + + ( + void removeDebate(debate)} + style={[styles.deleteAction, { backgroundColor: colors.destructive }]}> + + + {t('common.delete')} + + + )}> + {row} + + ); })} @@ -252,6 +262,19 @@ export default function DebatesSheet() { ); } +/** + * A debate's row: its face stays put (it covers the swipe's Delete), and its content presses in. + */ +function DebateRowPress({ children, ...props }: Omit & { children: ReactNode }) { + const { colors } = useTheme(); + const press = usePressScale(); + return ( + + {({ pressed }) => {children}} + + ); +} + const styles = StyleSheet.create({ content: { gap: 14 }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, @@ -265,7 +288,6 @@ const styles = StyleSheet.create({ minHeight: 46, }, searchInput: { flex: 1, fontSize: 16.5, fontFamily: fontFamily.regular, paddingVertical: 10 }, - loading: { marginVertical: 24 }, body: { fontSize: 15, lineHeight: 21 }, list: { padding: 0, gap: 0, overflow: 'hidden' }, deleteAction: { width: 92, alignItems: 'center', justifyContent: 'center', gap: 3 }, diff --git a/apps/mobile/src/app/factoids/[id].tsx b/apps/mobile/src/app/factoids/[id].tsx index 1559b80be..47b8d85e7 100644 --- a/apps/mobile/src/app/factoids/[id].tsx +++ b/apps/mobile/src/app/factoids/[id].tsx @@ -16,11 +16,13 @@ import { backLabel } from '@/lib/back-label'; import { formatWhen } from '@/lib/dates'; import { useDebates } from '@/lib/debate-context'; import { copyText, sharePdf, shareText } from '@/lib/export'; +import { shareFactoidsDocx } from '@/lib/export-docx'; import { FACTOIDS_EXPORT_FILE_NAME, factoidsExportHtml } from '@/lib/export-html'; import { discussFactoidsMessage, factoidsToText } from '@/lib/result-text'; import { readFactoids } from '@/lib/results'; import { addSource } from '@/lib/sources'; import { arrivalNoticeText, useResultDocument } from '@/lib/use-result-document'; +import { useShareLink } from '@/lib/use-share-link'; /** * Factoids read as a document. Each factoid's source opens from its @@ -41,6 +43,7 @@ export default function FactoidsResultScreen() { const live = useRef(true); const owner = useRef(userId); const { setNotice } = doc; + const shareLink = useShareLink(setNotice); const { notice: arrival, failed } = params; useEffect(() => { @@ -109,10 +112,12 @@ export default function FactoidsResultScreen() { } }; + const savedId = result.savedId; const actions: HeaderAction[] = [ ...(doc.autoSaved.length > 0 ? [{ key: 'undo-sources', label: t('autoSavedSources.undo'), symbol: 'arrow.uturn.backward' as const, icon: 'history' as const, onPress: () => void doc.undoAutoSaved() }] : []), + ...(savedId ? [{ key: 'share-link', label: t('mobile.share.shareLink'), symbol: 'link' as const, icon: 'link' as const, onPress: () => void shareLink('factoids', savedId, title, output) }] : []), ...(result.savedId ? [{ key: 'delete', label: t('common.delete'), symbol: 'trash' as const, icon: 'trash' as const, destructive: true, onPress: () => void doc.remove() }] : []), ]; @@ -121,6 +126,7 @@ export default function FactoidsResultScreen() { testID="factoids-document" outline notice={doc.notice} + noticeTone={doc.noticeTone} header={ run(() => shareText(title, factoidsToText(output, t)))} actions={actions} />} actions={ <> @@ -152,11 +158,14 @@ export default function FactoidsResultScreen() { items={[ { key: 'pdf', label: t('common.pdf'), icon: 'file' }, { key: 'copy', label: t('mobile.results.copyText'), icon: 'copy' }, + // The website's Word export, built by the same code (packages/core/src/export/docx.ts). + { key: 'docx', label: t('common.docx'), icon: 'file' }, ]} cancelLabel={t('common.cancel')} onClose={() => setExportOpen(false)} onSelect={(choice) => { if (choice === 'pdf') run(() => sharePdf(factoidsExportHtml(output, t), FACTOIDS_EXPORT_FILE_NAME, title)); + if (choice === 'docx') run(() => shareFactoidsDocx(output, t, title)); if (choice === 'copy') run(async () => { await copyText(factoidsToText(output, t)); setNotice(t('generateResults.copiedToClipboard')); diff --git a/apps/mobile/src/app/flowchart/index.tsx b/apps/mobile/src/app/flowchart/index.tsx index 5903c5b22..1840e532f 100644 --- a/apps/mobile/src/app/flowchart/index.tsx +++ b/apps/mobile/src/app/flowchart/index.tsx @@ -297,7 +297,7 @@ export default function FlowchartScreen() { /> ); const page = ( - + {t('nav.flowchart')} diff --git a/apps/mobile/src/app/flowchart/maps.tsx b/apps/mobile/src/app/flowchart/maps.tsx index 7ebe8e90a..d53ca2bed 100644 --- a/apps/mobile/src/app/flowchart/maps.tsx +++ b/apps/mobile/src/app/flowchart/maps.tsx @@ -2,15 +2,18 @@ import { starterFlowchart } from '@debatelab/core/flowchart'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; +import { ActivityIndicator, StyleSheet, View } from 'react-native'; import { PrimaryButton, TextButton } from '@/components/buttons'; +import { ListItemIn, SkeletonTextRows } from '@/components/card'; import { Icon } from '@/components/icon'; +import { PressScale } from '@/components/press-scale'; import { SheetScrollView } from '@/components/sheet'; import { Text } from '@/components/text'; import { useAuth } from '@/lib/auth'; import { confirmDestructive } from '@/lib/confirm'; import { formatRelativeDate } from '@/lib/dates'; +import { haptics } from '@/lib/haptics'; import { getDraft, NEW_MAP, newDraft, openDraft, savedDraft, scopeDebateId, showDraft, useFlowchartDrafts } from '@/lib/flowchart-drafts'; import { fetchFlowchart, fetchFlowchartList, flowchartScreenCopies, type FlowchartSummary } from '@/lib/flowcharts'; import { useCopyHolder } from '@/lib/use-copy-holder'; @@ -119,25 +122,30 @@ export default function SavedMapsSheet() { }; const row = (key: string, title: string, meta: string, selected: boolean, onPress: () => void, index: number, busy = false) => ( - [styles.row, index > 0 && { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.line }, pressed && styles.pressed]} - testID="saved-map"> - - - {title} - - - {meta} - - - {busy ? : selected ? : null} - + 0 && { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.line }}> + { + if (!selected) haptics.select(); + onPress(); + }} + style={styles.row} + testID="saved-map"> + + + {title} + + + {meta} + + + {/* Opening one map is an inline busy state, so it keeps its spinner. */} + {busy ? : selected ? : null} + + ); const items = shown?.items ?? []; @@ -169,7 +177,7 @@ export default function SavedMapsSheet() { ) : null} {!shown ? ( - + ) : shown.failed ? ( @@ -200,6 +208,5 @@ const styles = StyleSheet.create({ rowText: { flex: 1, gap: 2 }, rowTitle: { fontSize: 16 }, rowMeta: { fontSize: 13.5 }, - pressed: { opacity: 0.7 }, failed: { gap: 6, alignItems: 'flex-start' }, }); diff --git a/apps/mobile/src/app/generate.tsx b/apps/mobile/src/app/generate.tsx index e84aad068..3c08e81a5 100644 --- a/apps/mobile/src/app/generate.tsx +++ b/apps/mobile/src/app/generate.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'; import { StyleSheet, View } from 'react-native'; import { MainActionButton } from '@/components/buttons'; +import { markCelebration } from '@/components/celebration'; import { DocumentScreen } from '@/components/document'; import { DocumentHeader } from '@/components/document-header'; import { Field } from '@/components/field'; @@ -140,6 +141,8 @@ export default function GenerateScreen() { sources: discoveredSources, }); const notice = arrivalNotices({ recordFailed, sourcesFailed: sources.failed, assessed, truncated }); + // The case just made celebrates when its document opens; reopening it later does not. + markCelebration(key); router.replace({ pathname: '/case/[id]', params: { id: key, from: from ?? 'prepare', ...(notice ? { notice } : {}), ...(sources.failed > 0 ? { failed: String(sources.failed) } : {}) }, diff --git a/apps/mobile/src/app/history.tsx b/apps/mobile/src/app/history.tsx index 49da13873..c26665ea7 100644 --- a/apps/mobile/src/app/history.tsx +++ b/apps/mobile/src/app/history.tsx @@ -1,22 +1,25 @@ import { Link, useFocusEffect, useLocalSearchParams, useRouter, type Href } from 'expo-router'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import Animated from 'react-native-reanimated'; import ReanimatedSwipeable, { type SwipeableMethods } from 'react-native-gesture-handler/ReanimatedSwipeable'; import { ActionMenu } from '@/components/action-menu'; import { PrimaryButton, TextButton } from '@/components/buttons'; -import { Card, IconTile } from '@/components/card'; +import { Card, IconTile, ListItemIn, usePressScale } from '@/components/card'; import { Chip } from '@/components/chip'; import { DocumentHeader } from '@/components/document-header'; import { Icon } from '@/components/icon'; import { Screen } from '@/components/screen'; +import { SkeletonRows } from '@/components/skeleton'; import { Text } from '@/components/text'; import { KIND_ICONS, workHref } from '@/components/work-row'; import { useAuth } from '@/lib/auth'; import { backLabel } from '@/lib/back-label'; import { confirmDestructive, showNotice } from '@/lib/confirm'; import { formatWhen } from '@/lib/dates'; +import { haptics } from '@/lib/haptics'; import { useDebates } from '@/lib/debate-context'; import type { Debate } from '@/lib/debates'; import { @@ -224,7 +227,10 @@ export default function HistoryScreen() { ]} cancelLabel={t('common.cancel')} onClose={() => setScopeOpen(false)} - onSelect={setScopeChoice} + onSelect={(key) => { + if (key !== scope) haptics.select(); + setScopeChoice(key); + }} testID="history-scope-menu" /> @@ -252,7 +258,7 @@ export default function HistoryScreen() { void load(scope)} /> ) : !shown ? ( - + ) : shown.kind === 'unsorted' ? ( ) : ( @@ -302,13 +308,14 @@ function TabList({ tab, items, onDelete }: { tab: HistoryTab; items: HistoryItem {items.map((item, index) => { const href = historyHref(item); return ( - router.push(href) : undefined} - onDelete={item.kind === 'case' || item.kind === 'sparring' ? () => onDelete(item) : undefined} - /> + + router.push(href) : undefined} + onDelete={item.kind === 'case' || item.kind === 'sparring' ? () => onDelete(item) : undefined} + /> + ); })} @@ -336,6 +343,7 @@ function HistoryRow({ item, first, onPress, onDelete }: { item: HistoryItem; fir const { colors } = useTheme(); const swipeable = useRef(null); const work = useWorkActions(); + const press = usePressScale(); const href = historyHref(item); const title = item.title || t(`debate.kinds.${item.kind}`); @@ -354,10 +362,12 @@ function HistoryRow({ item, first, onPress, onDelete }: { item: HistoryItem; fir }} disabled={!onPress} onPress={onPress} + onPressIn={press.onPressIn} + onPressOut={press.onPressOut} testID="history-row" style={{ backgroundColor: colors.card }}> {({ pressed }) => ( - + @@ -368,7 +378,7 @@ function HistoryRow({ item, first, onPress, onDelete }: { item: HistoryItem; fir {onPress ? : null} - + )} ); @@ -470,17 +480,18 @@ function UnsortedList({ {notice} ) : null} - {entries.map((entry) => ( - { - setNotice(t('debate.attached')); - onAttached(entry); - }} - /> + {entries.map((entry, index) => ( + + { + setNotice(t('debate.attached')); + onAttached(entry); + }} + /> + ))} ); @@ -560,7 +571,10 @@ function UnsortedRow({ entry, debates, userId, onAttached }: { entry: UnsortedEn items={debates.map((debate) => ({ key: debate.id, label: debate.motion }))} cancelLabel={t('common.cancel')} onClose={() => setPicking(false)} - onSelect={setTarget} + onSelect={(key) => { + if (key !== target) haptics.select(); + setTarget(key); + }} /> void attach()} /> @@ -573,7 +587,6 @@ const styles = StyleSheet.create({ root: { flex: 1 }, title: { fontSize: 32, lineHeight: 38, letterSpacing: -0.8 }, body: { fontSize: 15, lineHeight: 21 }, - loading: { marginTop: 32 }, pressed: { opacity: 0.7 }, scope: { flexDirection: 'row', alignItems: 'center', gap: 12, borderWidth: 2, borderRadius: 14, paddingVertical: 11, paddingHorizontal: 14 }, tabRow: { marginHorizontal: -20, flexGrow: 0 }, diff --git a/apps/mobile/src/app/new-note.tsx b/apps/mobile/src/app/new-note.tsx index fa110d123..212a886dc 100644 --- a/apps/mobile/src/app/new-note.tsx +++ b/apps/mobile/src/app/new-note.tsx @@ -12,11 +12,14 @@ import { useToast } from '@/components/toast'; import { useAuth } from '@/lib/auth'; import { useDebates } from '@/lib/debate-context'; import { haptics } from '@/lib/haptics'; -import { addSource } from '@/lib/sources'; +import { queueAddSource } from '@/lib/offline-queue'; import { useLibraryFolders } from '@/lib/use-library-folders'; import { useTheme } from '@/theme/use-theme'; -/** Write a note: a text source in the Current Debate, like the website's manual entry. */ +/** + * Write a note: a text source in the Current Debate, like the website's manual + * entry. Offline it is kept on the phone and added once the connection is back. + */ export default function NewNoteSheet() { const { t } = useTranslation(); const router = useRouter(); @@ -39,8 +42,10 @@ export default function NewNoteSheet() { setSaving(true); setError(''); try { - await addSource(userId, { title: title.trim(), content: content.trim(), type: 'text', folder, debateId: current.id }); - toast({ tone: 'success', title: t('libraryPage.sourceSaved', { title: title.trim() }) }); + const { status } = await queueAddSource(userId, { title: title.trim(), content: content.trim(), type: 'text', folder, debateId: current.id }); + toast(status === 'saved' + ? { tone: 'success', title: t('libraryPage.sourceSaved', { title: title.trim() }) } + : { tone: 'info', title: t('mobile.offlineQueue.noteQueuedTitle'), message: t('mobile.offlineQueue.noteQueuedDesc') }); router.back(); } catch (cause) { console.warn('Could not save the note:', cause instanceof Error ? cause.message : cause); diff --git a/apps/mobile/src/app/share/[code].tsx b/apps/mobile/src/app/share/[code].tsx new file mode 100644 index 000000000..4b87d43a5 --- /dev/null +++ b/apps/mobile/src/app/share/[code].tsx @@ -0,0 +1,167 @@ +import type { AttackOutput } from '@debatelab/core/ai/schemas/attack'; +import { AttackOutputSchema } from '@debatelab/core/ai/schemas/attack'; +import type { BrainstormOutput } from '@debatelab/core/ai/schemas/brainstorm'; +import type { FactoidsOutput } from '@debatelab/core/ai/schemas/factoids'; +import type { GenerateOutput } from '@debatelab/core/ai/schemas/generate'; +import { GenerateOutputSchema } from '@debatelab/core/ai/schemas/generate'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import * as WebBrowser from 'expo-web-browser'; +import { useEffect, useState, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { AttackDocument } from '@/components/attack-document'; +import { BrainstormDocument } from '@/components/brainstorm-document'; +import { PrimaryButton } from '@/components/buttons'; +import { CaseDocument } from '@/components/case-document'; +import type { CitationTarget } from '@/components/citation-target'; +import { DocumentScreen, DocumentTitle } from '@/components/document'; +import { DocumentHeader, type HeaderAction, type HeaderButton } from '@/components/document-header'; +import { DocumentStatus } from '@/components/document-status'; +import { EmptyState } from '@/components/empty-state'; +import { FactoidsDocument } from '@/components/factoids-document'; +import { useAuth } from '@/lib/auth'; +import { formatDate } from '@/lib/dates'; +import { isShareCode } from '@/lib/deep-links'; +import { isCompleteAttack, isCompleteCase, readBrainstorm, readFactoids } from '@/lib/results'; +import { readSharedItem, type SharedItem } from '@/lib/share-links'; +import { websiteShareUrl } from '@/lib/website'; + +type Shown = + | { kind: 'case'; output: GenerateOutput } + | { kind: 'attack'; output: AttackOutput } + | { kind: 'brainstorm'; output: BrainstormOutput } + | { kind: 'factoids'; output: FactoidsOutput }; + +type State = + | { status: 'loading' } + | { status: 'missing' } + | { status: 'failed' } + | { status: 'ready'; item: SharedItem; shown: Shown | null }; + +/** + * Shared data is public input, so, as on the website's SharedPage, it is checked + * against the tool's schema before a document renders it. Anything else opens + * on the website. + */ +function readShared(item: SharedItem): Shown | null { + // A reader that normalises before its schema check can throw on hostile data (a `toString` that is not a + // function); that is unreadable data, shown with the website fallback, never a screen stuck loading. + try { + return readSharedUnchecked(item); + } catch { + return null; + } +} + +function readSharedUnchecked(item: SharedItem): Shown | null { + switch (item.itemType) { + case 'case': + case 'generate': { + const parsed = GenerateOutputSchema.safeParse(item.itemData); + return parsed.success && isCompleteCase(parsed.data) ? { kind: 'case', output: parsed.data } : null; + } + case 'attack': { + const parsed = AttackOutputSchema.safeParse(item.itemData); + return parsed.success && isCompleteAttack(parsed.data) ? { kind: 'attack', output: parsed.data } : null; + } + case 'brainstorm': { + const output = readBrainstorm(item.itemData); + return output ? { kind: 'brainstorm', output } : null; + } + case 'factoids': { + const output = readFactoids(item.itemData); + return output ? { kind: 'factoids', output } : null; + } + default: + return null; + } +} + +/** A citation on a shared copy opens only a web address: the reader has no Library of the sharer's. */ +function openSharedCitation(target: CitationTarget | undefined) { + if (target?.url && /^https?:\/\//i.test(target.url)) void WebBrowser.openBrowserAsync(target.url); +} + +/** + * A shared result, opened from a debateowl.com/share link or the app's own + * scheme: read-only, signed in or not, with the website as the fallback. + */ +export default function SharedItemScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const { session } = useAuth(); + const { code: raw } = useLocalSearchParams<{ code: string }>(); + // debatelab://share/ reaches this screen without the native intent's check. + const code = isShareCode(raw) ? raw : null; + const [held, setHeld] = useState<{ code: string | null; state: State }>({ code, state: { status: 'loading' } }); + const state: State = !code ? { status: 'missing' } : held.code === code ? held.state : { status: 'loading' }; + + useEffect(() => { + if (!code) return; + let active = true; + void readSharedItem(code) + .then((read) => { + if (!active) return; + setHeld({ code, state: read.status === 'found' ? { status: 'ready', item: read.item, shown: readShared(read.item) } : read }); + }) + .catch(() => { + if (active) setHeld({ code, state: { status: 'failed' } }); + }); + return () => { + active = false; + }; + }, [code]); + + const openWebsite = () => { + if (code) void WebBrowser.openBrowserAsync(websiteShareUrl(code)); + }; + // Opened from a link, the screen may be the only one: Done leaves for the app's start, or sign-in when signed out. + const buttons: HeaderButton[] = router.canGoBack() + ? [] + : [{ key: 'done', label: t('mobile.common.done'), symbol: 'xmark', icon: 'x', onPress: () => router.replace(session ? '/' : '/sign-in') }]; + const actions: HeaderAction[] = code + ? [{ key: 'website', label: t('mobile.share.openWebsite'), symbol: 'safari', icon: 'globe', onPress: openWebsite }] + : []; + const header = ; + + if (state.status === 'loading') { + return ( + + + + ); + } + + const fallback = (title: string, description: string): ReactNode => ( + + + + {code ? : null} + + + + ); + + if (state.status === 'missing') return fallback(t('sharedPage.notFoundTitle'), code ? t('sharedPage.contentNotFound') : t('sharedPage.noCode')); + if (state.status === 'failed') return fallback(t('sharedPage.notFoundTitle'), t('sharedPage.loadFailed')); + + const { item, shown } = state; + const title = item.title || t('sharedPage.title'); + if (!shown) return fallback(title, t('mobile.share.unknownKind')); + + const date = formatDate(item.createdAt); + return ( + + + {shown.kind === 'case' ? : null} + {shown.kind === 'attack' ? : null} + {shown.kind === 'brainstorm' ? : null} + {shown.kind === 'factoids' ? : null} + + ); +} + +const styles = StyleSheet.create({ + fallback: { marginTop: 24 }, +}); diff --git a/apps/mobile/src/app/sign-in.tsx b/apps/mobile/src/app/sign-in.tsx index 14965dc35..230575a64 100644 --- a/apps/mobile/src/app/sign-in.tsx +++ b/apps/mobile/src/app/sign-in.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, View, useWindowDimensions, type TextInput } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { SuccessCheck } from '@/components/success-check'; import { MainActionButton, TextButton } from '@/components/buttons'; import { Field } from '@/components/field'; import { SegmentedControl } from '@/components/segmented-control'; @@ -31,7 +32,7 @@ export default function SignInScreen() { const insets = useSafeAreaInsets(); // On iPad the form sits in the middle of the screen instead of under the status bar. const centred = useWindowDimensions().width >= 700; - const { signInWithEmailOtp, verifyEmailOtp, signInWithDemoCode } = useAuth(); + const { signInWithEmailOtp, verifyEmailOtp, signInWithDemoCode, sessionExpired } = useAuth(); const [mode, setMode] = useState('email'); const [status, setStatus] = useState('idle'); const [codeSent, setCodeSent] = useState(false); @@ -217,11 +218,20 @@ export default function SignInScreen() { ) : null} + {sessionExpired && status !== 'code-sent' ? ( + // The website's login notice after the server ended the session; it goes once a sign-in succeeds. + + + + {t('login.sessionExpired')} + + + ) : null} {status === 'code-sent' ? ( // Success is mint and always comes with a check mark, as on the website. - - + + {t('login.codeSent')} @@ -242,6 +252,8 @@ const styles = StyleSheet.create({ success: { flexDirection: 'row', alignItems: 'flex-start', gap: 8 }, successMark: { width: 22, height: 22, borderRadius: 11, alignItems: 'center', justifyContent: 'center', marginTop: 1 }, successText: { flex: 1 }, + notice: { flexDirection: 'row', alignItems: 'flex-start', gap: 10, borderWidth: 2, borderRadius: 14, padding: 12 }, + noticeText: { flex: 1 }, root: { flex: 1 }, // Phone-width on iPad, centred. content: { paddingHorizontal: 24, gap: 24, width: '100%', maxWidth: 460, alignSelf: 'center' }, diff --git a/apps/mobile/src/app/sparring/index.tsx b/apps/mobile/src/app/sparring/index.tsx index 3f938b745..dfea691ce 100644 --- a/apps/mobile/src/app/sparring/index.tsx +++ b/apps/mobile/src/app/sparring/index.tsx @@ -17,11 +17,13 @@ import { import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { AppState, Keyboard, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, TextInput, useWindowDimensions, View } from 'react-native'; +import { AppState, Keyboard, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { AppTextInput } from '@/components/app-text-input'; import { ActionMenu } from '@/components/action-menu'; import { MainActionButton, TextButton } from '@/components/buttons'; +import { Celebration } from '@/components/celebration'; import { DocumentScreen } from '@/components/document'; import { DocumentHeader } from '@/components/document-header'; import { Field } from '@/components/field'; @@ -31,20 +33,24 @@ import { LiveTopBar, MainRoundControl, RoundIconButton, SideControl } from '@/co import { SegmentedControl } from '@/components/segmented-control'; import { ContextLine } from '@/components/context-line'; import { SessionCostNote } from '@/components/session-cost-note'; -import { SparringScorecardScreen } from '@/components/sparring-views'; +import { SparringScorecardScreen, winnerText } from '@/components/sparring-views'; +import { SuccessCheck } from '@/components/success-check'; import { Text } from '@/components/text'; import { TimerRing } from '@/components/timer-ring'; import { ToolTitle, WorkingIn } from '@/components/tool-form'; +import { useKeyCommand } from '@/lib/key-commands'; import { AIError, isAbortError, untilAborted } from '@/lib/ai-request'; import { useAuth } from '@/lib/auth'; import { backLabel } from '@/lib/back-label'; import { useDebates } from '@/lib/debate-context'; import { fetchFeatureConfig } from '@/lib/feature-config'; +import { useContentWidth } from '@/lib/layout'; import { openRefusal } from '@/lib/refusal'; import { sessionQuotaRefusal, useSessionQuota } from '@/lib/session-quota'; import { decideMotionTarget, shouldPrefillMotion } from '@/lib/motion'; import { chooseMotionTarget } from '@/lib/motion-gate'; import { useSettings } from '@/lib/settings'; +import { announceAnswer } from '@/lib/a11y'; import { attachSparring, judgeSparring, newSparringId, noteJudgedRound, SparringResponseError, sparringDraft, sparringTurn } from '@/lib/sparring'; import { formatClock } from '@/lib/timer'; import { tapTurnStart, useKeepAwakeWhile } from '@/lib/timer-signals'; @@ -117,6 +123,8 @@ export default function SparringScreen() { const roundQuota = useSessionQuota('sparring'); // Start waits for the round's cost; a second tap meanwhile does nothing. const [checkingQuota, setCheckingQuota] = useState(false); + // The round judged while this screen watched celebrates once; a round reopened or kept from earlier does not. + const [celebrating, setCelebrating] = useState(null); // Another account starts with no round: the session slot was already cleared, and this screen's copy goes too. useEffect(() => { @@ -170,6 +178,7 @@ export default function SparringScreen() { setRequest({ status: 'idle' }); if (!completed) return; commit(completed); + setCelebrating(completed.id); // The server saved the judged round before it answered; file it in its debate. if (completed.debateId) { attachSparring(forUser, completed.id, completed.debateId, completed.userSide).catch((cause: unknown) => { @@ -218,6 +227,8 @@ export default function SparringScreen() { setRequest({ status: 'idle' }); if (!next) return; commit(next); + // VoiceOver reads the opponent's finished speech, never as it streams in. + announceAnswer(generated.content); if (next.phase === 'judging') void runJudge(next, forUser); else if (currentSparringTurn(next)?.speaker === 'user') tapTurnStart(); } catch (cause) { @@ -313,6 +324,9 @@ export default function SparringScreen() { if (live.current && owner.current === forUser && startAttempt.current === attempt) begin(side, debateId, forUser); }; + // ⌘↩ submits the speech on an iPad keyboard. + useKeyCommand('cmd+enter', () => submit()); + const submit = () => { const latest = sessionRef.current; // A second tap after the speech went in lands on the opponent's turn; it is not a rejected speech. @@ -341,6 +355,7 @@ export default function SparringScreen() { setRequest({ status: 'idle' }); setNotice(''); setSubmissionError(''); + setCelebrating(null); commit(null); }; @@ -441,16 +456,37 @@ export default function SparringScreen() { } if (session.phase === 'complete' && session.scorecard) { + const celebrate = celebrating === session.id; return ( - { - if (sessionRef.current?.id === session.id) restart(); - }} - /> + + { + if (sessionRef.current?.id === session.id) restart(); + }} + holdEntrance={celebrate} + /> + {celebrate ? ( + total + speech.timeUsedSeconds, 0)), + format: formatRoundTime, + }, + ]} + onDone={() => setCelebrating(null)} + /> + ) : null} + ); } @@ -513,7 +549,8 @@ function LiveRound({ const { t } = useTranslation(); const router = useRouter(); const insets = useSafeAreaInsets(); - const { width, height } = useWindowDimensions(); + const { height } = useWindowDimensions(); + const width = useContentWidth(); const { colors } = useTheme(); const [keyboard, setKeyboard] = useState(false); const [expanded, setExpanded] = useState(false); @@ -581,7 +618,7 @@ function LiveRound({ {status} {judging ? ( - + ) : ( {guidance} - (); + const [phase, setPhase] = useState<'intro' | number>(intro === '1' ? 'intro' : 0); + const { session } = useAuth(); + const userId = session?.user.id; + const router = useRouter(); + + // Closing the sheet by swiping it away counts as Got it and Skip tour. + const finished = useRef(false); + useEffect( + () => () => { + if (finished.current || !userId) return; + if (intro === '1') void markIntroSeen(); + void markTourDone(userId); + }, + [intro, userId], + ); + + const finishTour = () => { + finished.current = true; + if (intro === '1') void markIntroSeen(); + if (userId) void markTourDone(userId); + if (router.canDismiss()) router.dismiss(); + else router.back(); + }; + + if (phase === 'intro') { + return ( + { + void markIntroSeen(); + if (tourDone) finishTour(); + else setPhase(0); + }} + /> + ); + } + return ; +} + +function Intro({ onDone }: { onDone: (tourDone: boolean) => void }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const { tour } = useLocalSearchParams<{ tour?: string }>(); + const reduceMotion = useReduceMotion(); + const [beat, setBeat] = useState(0); + const [settled, setSettled] = useState(false); + const lines = useRef(BEATS.slice(1).map(() => new Animated.Value(0))).current; + + useEffect(() => { + if (reduceMotion || settled) { + setBeat(LAST_BEAT); + setSettled(true); + lines.forEach((line) => line.setValue(1)); + return; + } + const timers = BEATS.slice(1).map(({ at }, index) => + setTimeout(() => { + setBeat(index + 1); + Animated.timing(lines[index], { toValue: 1, duration: 320, easing: Easing.out(Easing.cubic), useNativeDriver: true }).start(); + }, at), + ); + const done = setTimeout(() => setSettled(true), 6600); + return () => { + timers.forEach(clearTimeout); + clearTimeout(done); + }; + }, [reduceMotion, settled, lines]); + + const texts: { text: string; icon?: IconName; heading?: boolean }[] = [ + { text: t('debateOwlIntro.greetingNamed', { name: OWL_NAME }), heading: true }, + { text: t('debateOwlIntro.newLook'), icon: 'pencil' }, + { text: t('debateOwlIntro.company'), icon: 'more' }, + // Success is mint and always comes with a check mark. + { text: t('debateOwlIntro.sameTools'), icon: 'check' }, + ]; + + return ( + + + + + + {/* VoiceOver reads the whole bubble at once; the lines only fade in on screen. */} + line.text).join(' ')}> + {texts.map((line, index) => ( + + {line.icon ? ( + + ) : null} + + {line.text} + + + ))} + + + + + {t('debateOwlIntro.noticeTitle')} + + {t('debateOwlIntro.noticeBody')} + + + {settled ? null : setSettled(true)} />} + + onDone(tour === 'done')} testID="intro-done" /> + + + + ); +} + +function Tour({ step, onStep, onFinish }: { step: number; onStep: (step: number) => void; onFinish: () => void }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const router = useRouter(); + const { current } = useDebates(); + const { key, pose, icon } = STEPS[step]; + const last = step === STEPS.length - 1; + const title = t(key === 'debate' ? 'onboarding.welcome' : `onboarding.${key}`); + const description = + key === 'debate' ? t(current ? 'onboarding.debateCurrentDesc' : 'onboarding.debateFirstDesc') : t(`onboarding.${key}Desc`); + + useEffect(() => { + AccessibilityInfo.announceForAccessibility(`${title}. ${t('mobile.tour.progress', { step: step + 1, steps: STEPS.length })}`); + }, [title, step, t]); + + const go = (next: number) => { + haptics.select(); + onStep(next); + }; + + return ( + + + + + + + + + + + {STEPS.map((item, index) => ( + + ))} + + + + {title} + + {description} + + {key === 'debate' && !current ? ( + // One primary action per step: before a debate exists, that is Start debate. + router.push('/new-debate')} /> + ) : null} + + {step > 0 ? ( + + go(step - 1)} /> + + ) : null} + + go(step + 1)} + testID="tour-next" + /> + + + {last ? null : ( + + + + )} + + ); +} + +const styles = StyleSheet.create({ + content: { gap: 22, paddingTop: 30 }, + flex: { flex: 1 }, + introTop: { alignItems: 'center', gap: 18 }, + tourTop: { alignItems: 'center' }, + owlCircle: { width: 136, height: 136, borderRadius: 68, alignItems: 'center', justifyContent: 'flex-end', overflow: 'hidden' }, + owl: { marginBottom: -8 }, + stepIcon: { position: 'absolute', bottom: 0, right: '36%', width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' }, + bubble: { alignSelf: 'stretch', borderWidth: 2, borderRadius: radius.card, paddingHorizontal: 18, paddingVertical: 14, gap: 8 }, + line: { flexDirection: 'row', alignItems: 'flex-start', gap: 10 }, + greeting: { flex: 1, fontSize: 21, lineHeight: 27 }, + lineText: { flex: 1, fontSize: 16, lineHeight: 23 }, + notice: { gap: 8 }, + noticeTitle: { fontSize: 22, lineHeight: 28 }, + stepTitle: { fontSize: 26, lineHeight: 32, textAlign: 'center' }, + noticeBody: { fontSize: 16.5, lineHeight: 24 }, + centred: { textAlign: 'center' }, + dots: { flexDirection: 'row', justifyContent: 'center', gap: 8 }, + dot: { width: 8, height: 8, borderRadius: 4 }, + dotCurrent: { width: 22 }, + footer: { flexDirection: 'row', alignItems: 'center', gap: 12 }, + skip: { alignItems: 'center' }, +}); diff --git a/apps/mobile/src/components/action-menu.web.tsx b/apps/mobile/src/components/action-menu.web.tsx index 40f33cecd..e10a2618d 100644 --- a/apps/mobile/src/components/action-menu.web.tsx +++ b/apps/mobile/src/components/action-menu.web.tsx @@ -15,7 +15,7 @@ export function ActionMenu({ visible, title, items, cancelLabel, onSelect, onClo {title ? ( - + {title} ) : null} @@ -30,7 +30,7 @@ export function ActionMenu({ visible, title, items, cancelLabel, onSelect, onClo onSelect(item.key); }} style={({ pressed }) => [styles.item, (index > 0 || title) && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && { backgroundColor: colors.line }]}> - {item.label} + {item.label} {item.icon ? : null} ))} diff --git a/apps/mobile/src/components/app-sidebar.tsx b/apps/mobile/src/components/app-sidebar.tsx new file mode 100644 index 000000000..9c4856a62 --- /dev/null +++ b/apps/mobile/src/components/app-sidebar.tsx @@ -0,0 +1,315 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useNavigationContainerRef, usePathname, useRouter } from 'expo-router'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import Animated, { FadeInUp, FadeOutUp, LinearTransition, useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { Icon } from './icon'; +import { OwlMark, useReduceMotion } from './owl'; +import { PressScale } from './press-scale'; +import { SIDEBAR_SECTIONS, activeSidebarItem, type SidebarItem, type SidebarSection } from './sidebar-items'; +import { Text } from './text'; + +import { useDebates } from '@/lib/debate-context'; +import { haptics } from '@/lib/haptics'; +import { SIDEBAR_MAX_FONT_SCALE } from '@/lib/layout'; +import { DURATION, motionSetting, SPRING } from '@/theme/motion'; +import { pickSourceFile } from '@/lib/pick-source-file'; +import { holdPickedFile } from '@/lib/source-file'; +import { useTheme } from '@/theme/use-theme'; + +const COLLAPSED_KEY = 'debateowl.sidebar.collapsed'; + +/** The part of a navigator's state the sidebar reads. */ +type AnyState = { key?: string; index?: number; routes: { name: string; state?: unknown }[] }; + +// Screens the root layout declares; the stack holding any of them is the root stack. +const ROOT_SCREENS = new Set(['(tabs)', 'ask', 'history', 'generate', 'case/[id]', 'attack/index', 'attack/[id]', 'brainstorm/index', 'brainstorm/[id]', 'factoids/index', 'factoids/[id]', 'flowchart/index', 'crossfire', 'sparring/index', 'sparring/[id]', 'share/[code]']); + +/** The root stack: the navigator that holds the tabs and every screen pushed above them. */ +function rootStack(state: AnyState | undefined): AnyState | undefined { + let current = state; + while (current) { + if (current.routes.some((route) => ROOT_SCREENS.has(route.name))) return current; + const focused = current.routes[current.index ?? current.routes.length - 1]; + current = focused?.state as AnyState | undefined; + } + return undefined; +} + +/** The tab under the pushed screens, as the `from` the tools' back buttons read ("prepare", "you"…). */ +function tabUnder(stack: AnyState | undefined): string | undefined { + const tabs = stack?.routes.find((route) => route.name === '(tabs)')?.state as AnyState | undefined; + const name = tabs?.routes[tabs.index ?? 0]?.name; + return name?.replace(/^\((.*)\)$/, '$1'); +} + +/** Which sections the student closed; kept on the device, as iPadOS keeps a sidebar's sections. */ +// The closed sections as last read or set, so a sidebar drawn again (a rotation) starts from them. +let knownCollapsed: ReadonlySet | null = null; + +/** + * Which sections the student closed; kept on the device, as iPadOS keeps a sidebar's sections. + * `loaded` stays false until the stored choice is read, so a closed section never flashes open. + */ +function useCollapsedSections() { + const [collapsed, setCollapsed] = useState>(() => knownCollapsed ?? new Set()); + const [loaded, setLoaded] = useState(knownCollapsed !== null); + useEffect(() => { + if (knownCollapsed !== null) return; + let live = true; + AsyncStorage.getItem(COLLAPSED_KEY) + .then((value) => { + const keys: unknown = value ? JSON.parse(value) : []; + knownCollapsed = new Set(Array.isArray(keys) ? keys.filter((key): key is string => typeof key === 'string') : []); + if (live) setCollapsed(knownCollapsed); + }) + .catch(() => { + knownCollapsed = new Set(); + }) + .finally(() => { + if (live) setLoaded(true); + }); + return () => { + live = false; + }; + }, []); + const toggle = (key: string) => + setCollapsed((previous) => { + const next = new Set(previous); + if (next.has(key)) next.delete(key); + else next.add(key); + knownCollapsed = next; + AsyncStorage.setItem(COLLAPSED_KEY, JSON.stringify([...next])).catch(() => undefined); + return next; + }); + return { collapsed, loaded, toggle }; +} + +/** + * The iPad sidebar: the Current Debate switcher on top, then every tool under + * collapsible Debate, Prepare, Practice, Library and You sections. It sits + * beside the root stack (see the root _layout.tsx), so it stays while a tool + * is open; picking a row closes the screens above the tabs first, so the + * sidebar always leads to one screen, never a pile of them. + */ +export function AppSidebar({ width }: { width: number }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const navigation = useNavigationContainerRef(); + const pathname = usePathname(); + const { current, oneOff } = useDebates(); + const { collapsed, loaded, toggle } = useCollapsedSections(); + const active = activeSidebarItem(pathname); + + const open = async (item: SidebarItem) => { + haptics.tap(); + const stack = rootStack(navigation.getRootState() as AnyState | undefined); + const from = tabUnder(stack); + const { target } = item; + if (target.kind === 'screen' && pathname === target.pathname) return; + let file: string | null = null; + if (target.kind === 'pickFile') { + const asset = await pickSourceFile().catch((cause: unknown) => { + console.warn('Could not open the file picker:', cause instanceof Error ? cause.message : cause); + return null; + }); + if (!asset) return; + file = holdPickedFile(asset); + } + // Close the screens above the tabs. With no tabs in the stack (a cold link before the anchor + // could add them), start the stack again from the tabs, so tools never pile up. + if (stack?.key && !stack.routes.some((route) => route.name === '(tabs)')) { + navigation.dispatch({ type: 'RESET', payload: { index: 0, routes: [{ name: '(tabs)' }] }, target: stack.key }); + } else if (stack?.key && stack.routes.length > 1) navigation.dispatch({ type: 'POP_TO_TOP', target: stack.key }); + if (target.kind === 'tab') router.navigate(target.href); + else if (target.kind === 'pickFile') router.push({ pathname: '/add-file', params: { file: file! } }); + else router.push({ pathname: target.pathname as never, params: { ...(from ? { from } : {}), ...target.params } }); + }; + + const toggleSection = (key: string) => { + haptics.select(); + toggle(key); + }; + + const motion = oneOff ? t('debate.oneOffActive') : current?.motion ?? t('mobile.sidebar.noDebate'); + + return ( + + + + + + DebateOwl + + + + + { + haptics.tap(); + router.push('/debates'); + }} + style={({ pressed }) => [styles.switcher, { backgroundColor: colors.tile, borderColor: colors.tileBorder }, pressed && styles.pressed]} + testID="sidebar-debate-switcher"> + + + {t('debate.current')} + + + {motion} + + + + + { + haptics.tap(); + router.push('/new-debate'); + }} + hitSlop={6} + style={({ pressed }) => [styles.newDebate, { backgroundColor: colors.primaryFill }, pressed && styles.pressed]} + testID="sidebar-new-debate"> + + + + + {SIDEBAR_SECTIONS.map((section) => ( + toggleSection(section.key)} + onOpen={(item) => void open(item)} + /> + ))} + + + ); +} + +function SidebarGroup({ + section, + expanded, + active, + onToggle, + onOpen, + loaded, +}: { + section: SidebarSection; + /** False until the closed sections are read: rows wait, so a closed section never flashes open. */ + loaded: boolean; + expanded: boolean; + active: string | null; + onToggle: () => void; + onOpen: (item: SidebarItem) => void; +}) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const reduce = useReduceMotion(); + const title = t(section.label); + // A closed section still shows which of its rows is open, as the dot after its title. + const holdsActive = section.items.some((item) => item.key === active); + // The chevron turns with a spring as the section opens or closes; the rows slide in and out + // and the sections below glide into place (Reanimated layout animations, still under Reduce Motion). + const turn = useSharedValue(expanded ? 1 : 0); + useEffect(() => { + turn.set(reduce ? (expanded ? 1 : 0) : withSpring(expanded ? 1 : 0, SPRING.pop)); + }, [expanded, reduce, turn]); + const chevron = useAnimatedStyle(() => ({ transform: [{ rotate: `${turn.value * 90}deg` }] })); + // Rows shown when the sidebar first appears are simply there; rows a tap opens slide in. + const [opened, setOpened] = useState(false); + useEffect(() => { + if (loaded) setOpened(true); + }, [loaded]); + return ( + + {/* A heading for VoiceOver's rotor that also opens and closes the section, as iPadOS sidebar headers do. */} + [styles.groupHeader, pressed && styles.pressed]} + testID={`sidebar-toggle-${section.key}`}> + + {title} + + {!expanded && holdsActive ? : null} + + + + + + {expanded && loaded + ? section.items.map((item, index) => { + const selected = item.key === active; + return ( + + onOpen(item)} + scaleTo={0.98} + style={[styles.row, selected && { backgroundColor: colors.primaryFill }]} + testID={`sidebar-item-${item.key}`}> + + + {t(item.label)} + + + + ); + }) + : null} + + ); +} + +const styles = StyleSheet.create({ + root: { borderRightWidth: StyleSheet.hairlineWidth }, + content: { paddingHorizontal: 12, gap: 6 }, + flex: { flex: 1 }, + brand: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 6, marginBottom: 6 }, + brandTitle: { fontSize: 23, letterSpacing: -0.4 }, + switcherRow: { flexDirection: 'row', alignItems: 'stretch', gap: 8, marginBottom: 8 }, + switcher: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 8, borderWidth: 2, borderRadius: 14, paddingHorizontal: 12, paddingVertical: 10 }, + switcherText: { flex: 1, gap: 2 }, + switcherLabel: { fontSize: 12.5 }, + switcherMotion: { fontSize: 15.5, lineHeight: 20 }, + newDebate: { width: 44, borderRadius: 14, alignItems: 'center', justifyContent: 'center' }, + group: { gap: 2, marginTop: 4 }, + groupHeader: { flexDirection: 'row', alignItems: 'center', gap: 8, minHeight: 40, paddingHorizontal: 10 }, + groupTitle: { fontSize: 15, letterSpacing: 0.1 }, + activeDot: { width: 7, height: 7, borderRadius: 4 }, + row: { flexDirection: 'row', alignItems: 'center', gap: 12, borderRadius: 11, paddingHorizontal: 12, paddingVertical: 9, minHeight: 44 }, + rowLabel: { flex: 1, fontSize: 16, lineHeight: 21 }, + pressed: { opacity: 0.7 }, +}); diff --git a/apps/mobile/src/components/app-tabs.tsx b/apps/mobile/src/components/app-tabs.tsx index ce5a32e43..3b4a0098b 100644 --- a/apps/mobile/src/components/app-tabs.tsx +++ b/apps/mobile/src/components/app-tabs.tsx @@ -3,16 +3,20 @@ import { useTranslation } from 'react-i18next'; import { TABS } from './tabs'; +import { useSidebarWidth } from '@/lib/layout'; + import { fontFamily } from '@/theme/fonts'; import { useTheme } from '@/theme/use-theme'; export default function AppTabs() { const { t } = useTranslation(); const { colors } = useTheme(); + // A full-screen iPad window shows the app's own sidebar (app-sidebar.tsx), which lists every + // tool, so the tab bar hides. Split View and narrower windows keep the tab bar. + const sidebar = useSidebarWidth() > 0; return ( - // On iPad (iPadOS 18+) the tabs become the system sidebar; iPhone keeps the tab bar.