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 @@ -16,11 +16,12 @@ 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`, full screen above the tabs. All are declared in the root `_layout.tsx`.
- `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/index.tsx` and `attack/[id].tsx`, full screen above the tabs. All are declared in the root `_layout.tsx`. `(prepare)/coming/[tool].tsx` and `(practice)/soon/[tool].tsx` stand in for 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), `ask-config.ts` reads the Ask model from `user_settings.feature_configs`, `ask-context.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.
- 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.
- One-off mode is session-only and never written, as on the website. 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
4 changes: 4 additions & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,18 @@
"aes-js": "^3.1.2",
"date-fns": "^3.6.0",
"expo": "~57.0.27",
"expo-clipboard": "~57.0.2",
"expo-constants": "~57.0.21",
"expo-crypto": "~57.0.3",
"expo-file-system": "~57.0.7",
"expo-font": "~57.0.4",
"expo-linear-gradient": "~57.0.2",
"expo-linking": "~57.0.12",
"expo-localization": "~57.0.2",
"expo-print": "~57.0.2",
"expo-router": "~57.0.25",
"expo-secure-store": "~57.0.4",
"expo-sharing": "~57.0.22",
"expo-splash-screen": "~57.0.9",
"expo-status-bar": "~57.0.1",
"expo-system-ui": "~57.0.4",
Expand Down
178 changes: 177 additions & 1 deletion apps/mobile/scripts/screenshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,17 @@
* fetch), answers the ai function with a fixed answer with citations, and
* answers chat and message writes locally. The Library screens read the
* account's real sources and write nothing.
*
* Generate and Attack screenshots never call a model and never save: the
* page answers the ai function with a fixed case or attack, holds it open
* for the "generating" shot, and answers case, attack and source writes
* locally. The PDF export's HTML (the web export cannot run expo-print) is
* rendered in a page of its own, and the "Copy as text" output is written
* next to the screenshots.
*/
import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page } from '@playwright/test';
import { createReadStream, existsSync, statSync } from 'node:fs';
import { mkdir } from 'node:fs/promises';
import { mkdir, writeFile } from 'node:fs/promises';
import { createServer, type Server } from 'node:http';
import { extname, join, normalize } from 'node:path';

Expand Down Expand Up @@ -199,6 +206,173 @@ async function fakeAsk(page: Page, hold: boolean) {
}
}

// A made-up case in GenerateOutput's shape, after mock 03: three arguments for a Standard case.
const FAKE_CASE = {
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.',
},
contentions: [
{
id: '1',
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.',
needsSource: false,
impact: 'Fewer asthma admissions among children who live and study in the centre.',
},
{
id: '2',
claim: 'Streets back for people',
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,
impact: 'Wider pavements, more seating and safer crossings for everyone.',
},
{
id: '3',
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.',
needsSource: false,
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.' },
],
metadata: {
side: 'pro',
format: 'custom',
generatedAt: '2026-10-09T16:43:00.000Z',
model: 'test-model',
provider: 'openrouter',
durationMs: 41200,
sourceUsage: [
{ key: 'https://example.org/clean-streets', title: 'Clean Streets Review', origin: 'https://example.org/clean-streets', importance: 3, strength: 3, probability: 0.9, judgment: 'used' },
{ key: 'https://example.org/street-space', title: 'Street Space Audit', origin: 'https://example.org/street-space', importance: 2, strength: 2, probability: 0.8, judgment: 'used' },
{ key: 'https://example.org/footfall', title: 'Footfall Study', origin: 'https://example.org/footfall', importance: 2, strength: 2, probability: 0.7, judgment: 'used' },
],
},
};
const FAKE_SOURCES = [
{ title: 'Clean Streets Review', url: 'https://example.org/clean-streets', content: 'Made up for screenshots.', qualityLabel: 'strong', qualityScore: 82 },
{ title: 'Footfall Study', url: 'https://example.org/footfall', content: 'Made up for screenshots.', qualityLabel: 'moderate', qualityScore: 64 },
];
// A made-up attack in AttackOutput's shape.
const FAKE_ATTACK = {
overallAssessment: {
score: 64,
summary: 'A clear case with strong health evidence, but it leans on exemptions it never defines.',
strengthAreas: ['Concrete health impact', 'Good use of the street space argument'],
},
weaknesses: [
{ id: 'w1', severity: 'critical', type: 'evidential', description: 'Exemptions are assumed, not shown', whyWeak: 'The case says carers and shift workers are exempt but never says how.', howOpponentAttacks: 'Who checks the exemptions, and what happens to the nurse at 5 a.m.?', improvedVersion: 'Name a real scheme and say how its exemptions work.', affectedQuote: 'Every real ban exempts carers' },
{ id: 'w2', severity: 'major', type: 'logical', description: 'Footfall gains may not transfer', whyWeak: 'One study of one city does not prove shops gain everywhere.', howOpponentAttacks: 'Your study is one city with a metro.', improvedVersion: 'Compare two centres of different sizes.' },
{ id: 'w3', severity: 'minor', type: 'structural', description: 'Impact of the third argument is thin', whyWeak: 'It restates the claim.', howOpponentAttacks: 'So what if buses are faster?', improvedVersion: 'Tie faster buses to who can reach jobs.' },
],
fallaciesDetected: [{ name: 'Hasty generalisation', location: 'Argument 2', explanation: 'One city stands in for all cities.' }],
crossExQuestions: [{ question: 'How many people would need an exemption?', purpose: 'Test the size of the carve-out', expectedTrap: 'A large number undercuts the health benefit' }],
weighingAnalysis: { currentWeighing: 'Health outweighs convenience.', missingComparisons: ['Cost to low-income drivers'], improvementSuggestions: ['Weigh by number of people affected'] },
};
const FAKE_ROW = (table: string) => ({
id: table === 'sources' ? '00000000-0000-4000-8000-0000000050c0' : '00000000-0000-4000-8000-00000000ca5e',
title: 'fake',
content: '',
type: 'url',
origin: null,
metadata: null,
created_at: new Date().toISOString(),
});

/** Fakes Generate's and Attack's AI calls and saves for this page (see the header). `hold` keeps Generate waiting. */
async function fakeResults(page: Page, hold: boolean) {
await page.route(`https://${STAGING_HOST}/functions/v1/ai`, async (route) => {
const feature = (route.request().postDataJSON() as { feature?: string } | null)?.feature;
if (feature === 'generate' && hold) return; // Never answered: the page shows its progress state.
if (feature === 'generate') return route.fulfill({ status: 200, headers: { 'access-control-allow-origin': '*' }, json: { output: FAKE_CASE, discoveredSources: FAKE_SOURCES } });
if (feature === 'attack') return route.fulfill({ status: 200, headers: { 'access-control-allow-origin': '*' }, json: { output: FAKE_ATTACK, discoveredSources: [] } });
return route.abort();
});
for (const table of ['cases', 'attacks', 'sources', 'source_folders']) {
await page.route(`${REST}/${table}*`, (route) =>
route.request().method() === 'POST' ? route.fulfill({ status: 201, json: FAKE_ROW(table) }) : route.continue(),
);
}
}

/** Prepare, Generate, the case document, Export and Attack, all from fixed outputs (see the header). */
async function shootResultScreens(context: BrowserContext, prefix: string, language: string, problems: string[]) {
const open = async (hold: boolean) => {
const page = await context.newPage();
watchErrors(page, problems, `${prefix} results`);
page.on('dialog', (dialog) => void dialog.dismiss());
await pinSettings(page, language);
await fakeResults(page, hold);
return page;
};

const waiting = await open(true);
await waiting.goto(`${ORIGIN}/prepare`);
await waiting.getByTestId('prepare-tool-generate').waitFor({ timeout: 30_000 });
await settle(waiting);
await shoot(waiting, `${prefix}-prepare`);
await waiting.getByTestId('prepare-tool-generate').click();
await waiting.getByTestId('generate-motion').waitFor({ timeout: 30_000 });
await waiting.waitForFunction(() => (document.querySelector('[data-testid="generate-motion"]') as HTMLTextAreaElement | null)?.value.trim());
await shoot(waiting, `${prefix}-generate-form`);
await waiting.getByRole('button', { name: /^(Generate Debate Case|Vitaeset Generálása|Generar Caso de Debate)$/ }).click();
await waiting.getByTestId('tool-progress').waitFor({ timeout: 15_000 });
await shoot(waiting, `${prefix}-generating`);
await waiting.close();

const page = await open(false);
await page.goto(`${ORIGIN}/generate?from=prepare`);
await page.getByTestId('generate-motion').waitFor({ timeout: 30_000 });
await page.waitForFunction(() => (document.querySelector('[data-testid="generate-motion"]') as HTMLTextAreaElement | null)?.value.trim());
await page.getByRole('button', { name: /^(Generate Debate Case|Vitaeset Generálása|Generar Caso de Debate)$/ }).click();
await page.getByTestId('case-document').waitFor({ timeout: 30_000 });
await page.waitForTimeout(400);
await shoot(page, `${prefix}-case`);
// Close the open argument, so every argument shows collapsed.
await page.getByTestId('document-item').first().click();
await shoot(page, `${prefix}-case-collapsed`);
await page.getByTestId('document-item').first().click();

await page.getByTestId('export-button').click();
await page.getByTestId('export-menu').waitFor();
await shoot(page, `${prefix}-export-menu`);
await page.getByTestId('export-menu').getByRole('menuitem').first().click();
await page.getByTestId('export-button').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('Export did not hand over the PDF HTML or the copied text');
await writeFile(join(OUT, `${prefix}-case-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}-case-pdf-html.png`), fullPage: true });
await pdf.close();
console.log(`${prefix}: PDF file name ${exported.fileName}, copied ${exported.copied.length} characters`);

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 shoot(page, `${prefix}-attack-form`);
await page.getByRole('button', { name: /^(Find Weaknesses|Gyengeségek Keresése|Encontrar Debilidades)$/ }).click();
await page.getByTestId('attack-document').waitFor({ timeout: 30_000 });
await page.waitForTimeout(400);
await shoot(page, `${prefix}-attack-result`);
await page.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 @@ -402,6 +576,8 @@ async function main() {
shots += 1;
await shootAskScreens(signedIn, prefix, language, problems);
shots += 3;
await shootResultScreens(signedIn, prefix, language, problems);
shots += 9;
await signedIn.close();
}
}
Expand Down
Loading
Loading