Skip to content

Commit 685f4fb

Browse files
Programcodeclaude
andcommitted
Screenshot the Library and Ask screens and document them
The screenshot script now captures the Library's plus menu, link and note sheets and a source, and Ask empty, mid-answer and answered with citations. Ask runs on a fake stream inside the page and a fixed answer, so screenshots never call a model or save a chat. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 3ac2f2f commit 685f4fb

4 files changed

Lines changed: 131 additions & 4 deletions

File tree

‎apps/mobile/AGENTS.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp
1616

1717
## Layout
1818

19-
- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; and the form sheets `debates.tsx` (switcher), `new-debate.tsx` and `delete-account.tsx`, declared in the root `_layout.tsx`.
19+
- `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`.
2020
- `src/components/`: shared UI. `app-tabs.tsx` uses native tabs; `app-tabs.web.tsx` draws the mock's tab bar for the web preview.
2121
- `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.
22+
- `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.
23+
- 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.
2224
- 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.
2325
- `src/theme/`: colours from the mocks and the website's dark palette, Inter fonts.
2426
- `scripts/screenshots.ts`: web-export screenshots. `scripts/render-tab-icons.ts` redraws the tab bar PNGs.

‎apps/mobile/scripts/screenshots.ts‎

Lines changed: 126 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,14 @@
2222
* the System theme; nothing is written. The Debate tab shows the account's
2323
* real Current Debate, and its empty state comes from answering the
2424
* current_debates read with no row. The Debate tab is shot once per side view.
25+
*
26+
* Ask screenshots never call a model and never save a chat: the page answers
27+
* ai-stream from a fake stream inside the page (expo/fetch uses the page's
28+
* fetch), answers the ai function with a fixed answer with citations, and
29+
* answers chat and message writes locally. The Library screens read the
30+
* account's real sources and write nothing.
2531
*/
26-
import { chromium, type Browser, type BrowserContextOptions, type Page } from '@playwright/test';
32+
import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page } from '@playwright/test';
2733
import { createReadStream, existsSync, statSync } from 'node:fs';
2834
import { mkdir } from 'node:fs/promises';
2935
import { createServer, type Server } from 'node:http';
@@ -48,7 +54,7 @@ const DEVICES = {
4854
} as const;
4955
const SCHEMES = ['light', 'dark'] as const;
5056
const LOCALES = { en: 'en-GB', hu: 'hu-HU' } as const;
51-
const SIGNED_IN_ROUTES = { prepare: '/prepare', practice: '/practice', library: '/library', you: '/you' };
57+
const SIGNED_IN_ROUTES = { prepare: '/prepare', practice: '/practice', you: '/you' };
5258
const REST = `https://${STAGING_HOST}/rest/v1`;
5359

5460
const TYPES: Record<string, string> = {
@@ -142,6 +148,117 @@ async function shootDebateScreens(page: Page, prefix: string) {
142148
await shoot(page, `${prefix}-new-debate`);
143149
}
144150

151+
// A made-up answer in the shape of mock 07, streamed a word at a time.
152+
const ASK_QUESTION = 'How do I answer "what about the nurse who drives in at 5 a.m."?';
153+
const ASK_STREAM = 'Concede the edge case, then shrink it. Three moves:\n\n1. **Exemptions exist.** Every real ban permits shift workers and carers.\n2. **Night buses follow demand.** Cities that closed their centres added routes in the first year.\n3. **Flip it.** The same nurse breathes the air on her walk from the car park.';
154+
const ASK_CITED = {
155+
response: 'Two of your sources carry it.\n\n**Exemptions exist.** Every real ban permits shift workers and carers.\n\n**Night buses follow demand.** Cities that closed their centres added routes in the first year.',
156+
citations: [
157+
{ claim: 'Every real ban permits shift workers and carers', source: 'City exemptions policy', verified: true },
158+
{ claim: 'Cities that closed their centres added routes in the first year', source: 'Transit report https://example.org/transit-report', verified: true },
159+
],
160+
followUpQuestions: [],
161+
uncertainties: [],
162+
};
163+
// The stream stops after this many words and stays open, so a shot can catch it mid-answer.
164+
const STREAM_HOLD_WORDS = 34;
165+
166+
/** Fakes Ask's AI calls and chat writes for this page (see the header). Call before the first navigation. */
167+
async function fakeAsk(page: Page, hold: boolean) {
168+
await page.addInitScript(({ text, holdAfter }) => {
169+
const realFetch = window.fetch.bind(window);
170+
window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
171+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
172+
if (!url.endsWith('/functions/v1/ai-stream')) return realFetch(input, init);
173+
const words = text.split(/(?<= )/);
174+
const encoder = new TextEncoder();
175+
const stream = new ReadableStream<Uint8Array>({
176+
async start(controller) {
177+
for (const word of holdAfter ? words.slice(0, holdAfter) : words) {
178+
await new Promise((resolve) => setTimeout(resolve, 15));
179+
controller.enqueue(encoder.encode(`event: delta\ndata: ${JSON.stringify({ text: word })}\n\n`));
180+
}
181+
if (holdAfter) return;
182+
const output = { response: text, followUpQuestions: [], uncertainties: [] };
183+
controller.enqueue(encoder.encode(`event: final\ndata: ${JSON.stringify({ output, meta: {} })}\n\n`));
184+
controller.close();
185+
},
186+
});
187+
return Promise.resolve(new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } }));
188+
};
189+
}, { text: ASK_STREAM, holdAfter: hold ? STREAM_HOLD_WORDS : 0 });
190+
await page.route(`https://${STAGING_HOST}/functions/v1/ai`, (route) =>
191+
route.fulfill({ status: 200, headers: { 'access-control-allow-origin': '*' }, json: { output: ASK_CITED } }),
192+
);
193+
for (const table of ['chats', 'messages']) {
194+
await page.route(`${REST}/${table}*`, (route) =>
195+
route.request().method() === 'POST'
196+
? route.fulfill({ status: 201, json: { id: '00000000-0000-4000-8000-00000000c4a7' } })
197+
: route.continue(),
198+
);
199+
}
200+
}
201+
202+
async function shootLibraryScreens(page: Page, prefix: string) {
203+
await page.goto(`${ORIGIN}/library`);
204+
await page.getByTestId('library-add').waitFor({ timeout: 30_000 });
205+
await settle(page);
206+
await shoot(page, `${prefix}-library`);
207+
208+
await page.getByTestId('library-add').click();
209+
await page.getByRole('menu').waitFor();
210+
await shoot(page, `${prefix}-library-menu`);
211+
await page.getByRole('menuitem').first().click();
212+
await page.getByTestId('add-link-sheet').waitFor();
213+
await page.waitForTimeout(700);
214+
await shoot(page, `${prefix}-add-link`);
215+
216+
await page.goto(`${ORIGIN}/new-note`);
217+
await page.getByTestId('new-note-sheet').waitFor({ timeout: 30_000 });
218+
await page.waitForTimeout(700);
219+
await shoot(page, `${prefix}-new-note`);
220+
221+
await page.goto(`${ORIGIN}/library`);
222+
await page.getByTestId('source-row').first().click({ timeout: 30_000 });
223+
await page.getByTestId('source-sheet').getByRole('heading').waitFor({ timeout: 30_000 });
224+
await page.waitForTimeout(700);
225+
await shoot(page, `${prefix}-source`);
226+
}
227+
228+
async function shootAskScreens(context: BrowserContext, prefix: string, language: string, problems: string[]) {
229+
const ask = async (hold: boolean) => {
230+
const page = await context.newPage();
231+
watchErrors(page, problems, `${prefix} ask`);
232+
await pinSettings(page, language);
233+
await fakeAsk(page, hold);
234+
await page.goto(`${ORIGIN}/ask`);
235+
await page.getByTestId('ask-input').waitFor({ timeout: 30_000 });
236+
await settle(page);
237+
return page;
238+
};
239+
240+
const empty = await ask(false);
241+
await shoot(empty, `${prefix}-ask-empty`);
242+
await empty.getByTestId('ask-input').fill(ASK_QUESTION);
243+
await empty.getByTestId('ask-send').click();
244+
await empty.getByTestId('ask-answer').waitFor({ timeout: 15_000 });
245+
await empty.getByRole('switch').first().click();
246+
await empty.getByTestId('ask-input').fill('Which of my sources back that up?');
247+
await empty.getByTestId('ask-send').click();
248+
await empty.getByTestId('ask-answer').nth(1).waitFor({ timeout: 15_000 });
249+
await empty.waitForTimeout(300);
250+
await shoot(empty, `${prefix}-ask-answer`);
251+
await empty.close();
252+
253+
const streaming = await ask(true);
254+
await streaming.getByTestId('ask-input').fill(ASK_QUESTION);
255+
await streaming.getByTestId('ask-send').click();
256+
await streaming.getByTestId('ask-streaming').waitFor({ timeout: 15_000 });
257+
await streaming.waitForTimeout(STREAM_HOLD_WORDS * 15 + 600);
258+
await shoot(streaming, `${prefix}-ask-streaming`);
259+
await streaming.close();
260+
}
261+
145262
async function shootEmptyDebate(page: Page, prefix: string) {
146263
await page.route(`${REST}/current_debates*`, (route) =>
147264
route.fulfill({ status: 200, contentType: 'application/json', json: [] }),
@@ -265,6 +382,11 @@ async function main() {
265382
throw error;
266383
});
267384
shots += 4;
385+
await shootLibraryScreens(tabPage, prefix).catch(async (error: unknown) => {
386+
await shoot(tabPage, `${prefix}-failed`);
387+
throw error;
388+
});
389+
shots += 5;
268390
for (const [name, route] of Object.entries(SIGNED_IN_ROUTES)) {
269391
await tabPage.goto(`${ORIGIN}${route}`);
270392
await tabPage.getByRole('tablist').first().waitFor();
@@ -278,6 +400,8 @@ async function main() {
278400
await pinSettings(emptyPage, language);
279401
await shootEmptyDebate(emptyPage, prefix);
280402
shots += 1;
403+
await shootAskScreens(signedIn, prefix, language, problems);
404+
shots += 3;
281405
await signedIn.close();
282406
}
283407
}

‎apps/mobile/src/app/(tabs)/(library)/library.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ function SourceRow({ source, first, onOpen, onDelete }: { source: Source; first:
228228
if (event.nativeEvent.actionName === 'delete') void remove();
229229
}}
230230
onPress={onOpen}
231+
testID="source-row"
231232
style={({ pressed }) => [styles.row, { backgroundColor: colors.card }, pressed && styles.pressed]}>
232233
<IconTile name={SOURCE_ICONS[source.type]} size={42} />
233234
<View style={styles.rowText}>

‎docs/development.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ npm run -w @debatelab/mobile export:web
108108
PORT=<free port> npx tsx apps/mobile/scripts/screenshots.ts
109109
```
110110

111-
Screenshots go to `.verify/mobile-shots/`: sign-in at iPhone and iPad sizes, light and dark, English and Hungarian. With `STAGING_DEMO_CODE` set, the script also signs in to staging, captures the five tabs (the Debate tab in both its In favor and Against views), the debate switcher, the new-debate form and the Debate tab's empty state, and checks that a cold start without a network keeps the user signed in, also after supabase-js's first auth event settles (about 30 seconds offline). Signed in, it answers the app's `user_settings` reads with the run's language and the System theme, because the account's saved choices would otherwise override them; nothing is written. `ONLY=iphone-light-en` limits a run to one combination. Use the seeded Pro code from `~/.config/debatelab/staging-access.env` (see [staging.md](staging.md)), load it inside the command without printing it, and serve on `PORT=18080`: the deployed Edge Functions only accept browser calls from allowed origins such as `http://127.0.0.1:18080`.
111+
Screenshots go to `.verify/mobile-shots/`: sign-in at iPhone and iPad sizes, light and dark, English and Hungarian. With `STAGING_DEMO_CODE` set, the script also signs in to staging, captures the five tabs (the Debate tab in both its In favor and Against views), the debate switcher, the new-debate form, the Debate tab's empty state, the Library's plus menu, link and note sheets and a source, and Ask empty, mid-answer and answered with citations, and checks that a cold start without a network keeps the user signed in, also after supabase-js's first auth event settles (about 30 seconds offline). Signed in, it answers the app's `user_settings` reads with the run's language and the System theme, because the account's saved choices would otherwise override them; nothing is written. Ask runs on a fake stream inside the page and a fixed answer with citations, so screenshots never call a model or save a chat. `ONLY=iphone-light-en` limits a run to one combination. Use the seeded Pro code from `~/.config/debatelab/staging-access.env` (see [staging.md](staging.md)), load it inside the command without printing it, and serve on `PORT=18080`: the deployed Edge Functions only accept browser calls from allowed origins such as `http://127.0.0.1:18080`.
112112

113113
### Expo Go on Adam's iPhone
114114

0 commit comments

Comments
 (0)