Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/mobile/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp

## 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`, `add-link.tsx`, `new-note.tsx` and `source/[id].tsx`; and `ask.tsx`, `generate.tsx`, `case/[id].tsx`, `attack/`, `brainstorm/` and `factoids/` (each a form `index.tsx` and a document `[id].tsx`), full screen above the tabs. All are declared in the root `_layout.tsx`. `(prepare)/coming/[tool].tsx` (Flowchart) and `(practice)/soon/[tool].tsx` stand in for tools not in the app yet.
- `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`, `add-link.tsx`, `new-note.tsx` and `source/[id].tsx`; and `ask.tsx`, `generate.tsx`, `case/[id].tsx`, `attack/`, `brainstorm/` and `factoids/` (each a form `index.tsx` and a document `[id].tsx`), and `flowchart/` (the screen `index.tsx` with its `card`, `claim` and `maps` form sheets), full screen above the tabs. All are declared in the root `_layout.tsx`. `(practice)/soon/[tool].tsx` stands in for Practice tools not in the app yet.
- `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`), 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 `addSource` and `removeSource` and `src/lib/source-import.ts`. The Library always shows the Current Debate, also in one-off mode, as on the website. Adding a file (the website parses files in the browser) and Scan pages are not in the app yet.
- 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`, `context-request.ts` builds the Current Debate context with the website's default selection and pins, 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`, checks the output with the website's transform (moved to `packages/core/src/ai/generate-output.ts`), keeps researched sources in the debate's Auto-saved folder (`packages/core/src/ai/discovered-sources.ts`), saves the server's source rankings onto the Library sources a result used (`packages/core/src/ai/source-assessments.ts`, also for Ask's whole answers), says when the server shortened the input, and saves rows to `cases` and `attacks`. `motion.ts` and `motion-gate.ts` are the website's motion gate. `open-results.ts` holds results made in this session, so one-off work and failed saves still open as documents. It and the last Generate and Attack forms live in `session-memory.ts`, which the auth provider clears on every account change, including sign-out. `components/document.tsx` draws results as documents (mock 03); Brainstorm and Factoids reuse it. Export renders `export-html.ts` to a PDF with expo-print and shares it (`export.ts`); the web preview cannot print, so `export.web.ts` keeps the HTML and copied text on `window` for the screenshot script. Word export and Google Docs are not in the app.
- Brainstorm and Factoids mirror `brainstorm` and `factoids` in `src/lib/ai/client.ts` and their pages, through the same `results.ts`: the brainstorm transform and the verified-factoid selection live in `packages/core/src/ai/brainstorm-output.ts` and `factoids-output.ts`, rows go to `brainstorms` and `factoids`. Brainstorm has the motion gate; Factoids, like Attack, follows the Current Debate and saves with side `neutral`. A factoid's bookmark saves it to the Current Debate's `Factoids` folder, and a search suggestion reopens the Factoids form with that query. `prepareToolHref` in `components/prepare-tools.ts` is where both tabs open a tool.
- One-off mode is session-only and never written, as on the website. 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 and Factoids forms (through `WorkingIn`). 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.
- Flowchart mirrors the website's `FlowchartPage.tsx` and the session store's `saveFlowchart` and `deleteSavedFlowchart` without React Flow: the saved `flowcharts` row keeps the website's React Flow node and edge JSON, and `packages/core/src/flowchart.ts` reads and edits it (starter map, edge kinds from the label or stroke the website writes, role columns, the tree, and edits that keep every field they do not touch). `flowcharts.ts` reads and writes the rows; Save over an opened map updates it, Save as inserts a row as the website's Save does. `flowchart-drafts.ts` holds each debate's open map and unsaved edits in session memory, shared by the screen and its sheets. Maps follow `activeDebateId`: one-off maps are saved with no debate, as on the website. The website's PNG export is a PDF of the Map tree here (`flowchartExportHtml`).
- 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 and Factoids 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.
- `src/theme/`: colours from the mocks and the website's dark palette, Inter fonts.
- `scripts/screenshots.ts`: web-export screenshots. `scripts/render-tab-icons.ts` redraws the tab bar PNGs.

Expand Down
150 changes: 150 additions & 0 deletions apps/mobile/scripts/screenshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@
* rendered in a page of its own, and the "Copy as text" output is written
* next to the screenshots. Brainstorm and Factoids are shot the same way,
* from a fixed brainstorm and fixed factoids, and save nothing.
*
* Flowchart screenshots read fixed saved maps: the page answers every
* flowcharts read with two made-up maps in the website's React Flow shape (or
* none for one-off work, which shows the starter map) and answers writes
* locally. The one-off shot turns one-off mode on in memory, as the switcher
* does, and never taps a debate row.
*/
import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page } from '@playwright/test';
import { createReadStream, existsSync, statSync } from 'node:fs';
Expand Down Expand Up @@ -495,6 +501,148 @@ async function shootTool(context: BrowserContext, prefix: string, language: stri
await page.close();
}

// Made-up saved maps in the website's React Flow shape, after mock A.
const FLOW_NODE = (id: string, x: number, y: number, label: string, type: string, strength?: string) => ({
id, type: 'custom', position: { x, y }, width: 220, height: 96,
data: { label, description: '', type, ...(strength ? { strength } : {}) },
});
const FAKE_FLOWCHART = {
nodes: [
FLOW_NODE('thesis', 400, 50, 'Ban private cars from city centres', 'thesis'),
FLOW_NODE('c1', 150, 200, 'Air quality is a health duty', 'contention', 'strong'),
FLOW_NODE('c2', 400, 200, 'Streets belong to people', 'contention', 'moderate'),
FLOW_NODE('c3', 650, 200, 'Fewer road deaths', 'dropped'),
FLOW_NODE('a1', 150, 350, 'Shift workers have no alternative', 'attack'),
FLOW_NODE('a2', 400, 350, 'Small shops lose customers', 'attack'),
FLOW_NODE('r1', 150, 500, 'Night buses follow demand', 'rebuttal'),
],
edges: [
{ id: 'e-thesis-c1', source: 'thesis', target: 'c1', animated: true, markerEnd: { type: 'arrowclosed' } },
{ id: 'e-thesis-c2', source: 'thesis', target: 'c2', animated: true, markerEnd: { type: 'arrowclosed' } },
{ id: 'e-thesis-c3', source: 'thesis', target: 'c3', animated: true, markerEnd: { type: 'arrowclosed' } },
{ id: 'reactflow__edge-c1-a1', source: 'c1', sourceHandle: null, target: 'a1', targetHandle: null, animated: true, label: 'Attack', style: { stroke: '#ef4444', strokeWidth: 2 }, markerEnd: { type: 'arrowclosed' } },
{ id: 'e-c2-a2', source: 'c2', target: 'a2', style: { stroke: '#ef4444' }, markerEnd: { type: 'arrowclosed' } },
{ id: 'reactflow__edge-a1-r1', source: 'a1', sourceHandle: null, target: 'r1', targetHandle: null, animated: true, label: 'Rebuttal', style: { stroke: '#3b82f6', strokeWidth: 2 }, markerEnd: { type: 'arrowclosed' } },
],
};
const FAKE_FLOWCHART_ROWS = [
{ id: '00000000-0000-4000-8000-00000000f10a', title: 'Car ban: first speech', created_at: '2026-10-09T09:12:00.000Z', ...FAKE_FLOWCHART },
{ id: '00000000-0000-4000-8000-00000000f10b', title: 'Practice round map', created_at: '2026-10-02T17:40:00.000Z', nodes: FAKE_FLOWCHART.nodes.slice(0, 3), edges: FAKE_FLOWCHART.edges.slice(0, 2) },
];

/** Answers flowcharts reads with the made-up maps, none for one-off work, and writes locally (see the header). */
async function fakeFlowcharts(page: Page) {
// The maps belong to the debate the last list was read for.
let debateId: string | null = null;
await page.route(`${REST}/flowcharts*`, (route) => {
const request = route.request();
const url = new URL(request.url());
const single = (request.headers().accept ?? '').includes('vnd.pgrst.object');
if (request.method() === 'DELETE') return route.fulfill({ status: 204 });
if (request.method() !== 'GET') {
const row = { ...FAKE_FLOWCHART_ROWS[0], id: '00000000-0000-4000-8000-00000000f1ff', debate_id: debateId };
return route.fulfill({ status: 201, json: single ? row : [row] });
}
const scope = url.searchParams.get('debate_id');
if (scope) debateId = scope === 'is.null' ? null : scope.replace(/^eq\./, '');
const id = url.searchParams.get('id')?.replace(/^eq\./, '');
const rows = (scope === 'is.null' ? [] : FAKE_FLOWCHART_ROWS)
.filter((row) => !id || row.id === id)
.map((row) => ({ ...row, debate_id: debateId }));
return route.fulfill({ status: 200, json: single ? rows[0] ?? null : rows });
});
}

/** Flow, Map, the claim sheet, adding a card, the saved maps, the menu and Export, from fixed maps. Nothing is saved. */
async function shootFlowchart(context: BrowserContext, prefix: string, language: string, problems: string[]) {
const page = await context.newPage();
watchErrors(page, problems, `${prefix} flowchart`);
page.on('dialog', (dialog) => void dialog.dismiss());
await pinSettings(page, language);
await fakeFlowcharts(page);
await page.goto(`${ORIGIN}/prepare`);
await page.getByTestId('prepare-tool-flowchart').waitFor({ timeout: 30_000 });
await settle(page);
await page.getByTestId('prepare-tool-flowchart').click();
await page.getByTestId('flow-card').first().waitFor({ timeout: 30_000 });
await settle(page);
await shoot(page, `${prefix}-flowchart-flow`);

await page.getByRole('tab', { name: /^(Map|Térkép|Mapa)$/ }).click();
await page.getByTestId('map-tree').waitFor();
await shoot(page, `${prefix}-flowchart-map`);

await page.getByTestId('map-card').filter({ hasText: 'Small shops lose customers' }).click();
await page.getByTestId('claim-sheet').waitFor();
await page.waitForTimeout(700);
await shoot(page, `${prefix}-flowchart-claim`);
await page.getByTestId('claim-link').click();
await page.getByTestId('link-targets').waitFor();
await shoot(page, `${prefix}-flowchart-link`);
await page.goBack();
await page.getByTestId('flowchart-screen').waitFor();

await page.getByTestId('document-add').click();
await page.getByTestId('card-sheet').waitFor();
await page.waitForTimeout(700);
await shoot(page, `${prefix}-flowchart-add-card`);
await page.goBack();

await page.getByTestId('flowchart-maps').click();
await page.getByTestId('maps-sheet').waitFor();
await page.getByTestId('saved-map').nth(1).waitFor({ timeout: 15_000 });
await page.waitForTimeout(700);
await shoot(page, `${prefix}-flowchart-maps`);
await page.goBack();

await page.getByTestId('document-more').click();
await page.getByRole('menu').waitFor();
await shoot(page, `${prefix}-flowchart-menu`);
// Save, Save as, Saved Flowcharts, Export, Delete map.
await page.getByRole('menuitem').nth(3).click();
await page.getByTestId('export-menu').waitFor();
await shoot(page, `${prefix}-flowchart-export-menu`);
await page.getByTestId('export-menu').getByRole('menuitem').first().click();
await page.getByTestId('document-more').click();
await page.getByRole('menuitem').nth(3).click();
await page.getByTestId('export-menu').getByRole('menuitem').nth(1).click();
const exported = await page.evaluate(() => (window as unknown as { __debatelabExport?: { html?: string; fileName?: string; copied?: string } }).__debatelabExport ?? {});
if (!exported.html || !exported.copied) throw new Error('Flowchart export did not hand over the PDF HTML or the copied text');
await writeFile(join(OUT, `${prefix}-flowchart-copy.txt`), exported.copied);
const pdf = await context.newPage();
await pdf.setViewportSize({ width: 794, height: 1123 });
await pdf.setContent(exported.html);
await pdf.screenshot({ path: join(OUT, `${prefix}-flowchart-pdf-html.png`), fullPage: true });
await pdf.close();
console.log(`${prefix} flowchart: PDF file name ${exported.fileName}, copied ${exported.copied.length} characters`);
await page.close();

// One-off work has its own maps; with none saved, the starter map shows under the one-off notice.
const oneOff = await context.newPage();
watchErrors(oneOff, problems, `${prefix} flowchart one-off`);
await pinSettings(oneOff, language);
await fakeFlowcharts(oneOff);
await oneOff.goto(`${ORIGIN}/`);
await oneOff.getByTestId('current-debate-motion').waitFor({ timeout: 30_000 });
await settle(oneOff);
await oneOff.getByTestId('current-debate-pill').click();
await oneOff.getByTestId('debates-sheet').waitFor();
await settle(oneOff);
const toggle = oneOff.getByTestId('debates-sheet').getByRole('switch');
await toggle.click();
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 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);
await shoot(oneOff, `${prefix}-flowchart-oneoff`);
await oneOff.close();
}

async function shootLibraryScreens(page: Page, prefix: string) {
await page.goto(`${ORIGIN}/library`);
await page.getByTestId('library-add').waitFor({ timeout: 30_000 });
Expand Down Expand Up @@ -751,6 +899,8 @@ async function main() {
await shootTool(signedIn, prefix, language, problems, tool);
shots += 6;
}
await shootFlowchart(signedIn, prefix, language, problems);
shots += 10;
const oneOffPage = await signedIn.newPage();
watchErrors(oneOffPage, problems, `${prefix} one-off tools`);
await pinSettings(oneOffPage, language);
Expand Down
13 changes: 0 additions & 13 deletions apps/mobile/src/app/(tabs)/(prepare)/coming/[tool].tsx

This file was deleted.

5 changes: 5 additions & 0 deletions apps/mobile/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ function Root() {
<Stack.Screen name="brainstorm/[id]" />
<Stack.Screen name="factoids/index" />
<Stack.Screen name="factoids/[id]" />
{/* Flowchart full screen above the tabs, its card, claim and saved-map sheets as form sheets, as in mock A. */}
<Stack.Screen name="flowchart/index" />
<Stack.Screen name="flowchart/card" options={sheet} />
<Stack.Screen name="flowchart/claim" options={sheet} />
<Stack.Screen name="flowchart/maps" options={sheet} />
</Stack.Protected>
<Stack.Protected guard={session === null}>
<Stack.Screen name="sign-in" />
Expand Down
Loading
Loading