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
3 changes: 2 additions & 1 deletion apps/mobile/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,11 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp
- 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-<date>.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`). 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` and the Flowchart 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. 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`).
- 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.
- `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
34 changes: 28 additions & 6 deletions apps/mobile/scripts/screenshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@
* npm run -w @debatelab/mobile export:web
* PORT=<free port> npx tsx apps/mobile/scripts/screenshots.ts
*
* ONLY=iphone-light-en limits a run to the combinations whose name starts with it.
* ONLY=iphone-light-en limits a run to the combinations whose name starts with it;
* separate several with commas (ONLY=iphone-light-en,ipad-dark-hu).
* SCREENS=you,timer limits the signed-in screens to those groups: debate,
* library, prepare, practice, you, empty, ask, results, brainstorm, factoids,
* timer, crossfire, sparring, flowchart, one-off, history, announcements,
* account, model. Sign-in screens are always shot.
* ONLY=offline runs only the offline reading check (see shootOfflineScreens).
*
* The iPad sizes are portrait (1024x1366), landscape (1366x1024) and a 600-point
* window (Split View or Slide Over), which takes the phone layout.
*
* Signed-in screens need a staging session. With STAGING_DEMO_CODE set, the
* 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
Expand Down Expand Up @@ -89,7 +93,8 @@ const DIST = join(__dirname, '../dist');
const OUT = join(__dirname, '../../../.verify/mobile-shots');
const PORT = Number(process.env.PORT);
const DEMO_CODE = process.env.STAGING_DEMO_CODE?.trim();
const ONLY = process.env.ONLY?.trim() ?? '';
const ONLY = (process.env.ONLY ?? '').split(',').map((value) => value.trim()).filter((value) => value.length > 0);
const offlineOnly = ONLY.length === 1 && ONLY[0] === 'offline';
const SCREENS = process.env.SCREENS?.split(',').map((name) => name.trim()).filter(Boolean);
const want = (group: string) => !SCREENS?.length || SCREENS.includes(group);
const ORIGIN = `http://127.0.0.1:${PORT}`;
Expand All @@ -98,7 +103,9 @@ const SESSION_KEY = `sb-${STAGING_HOST.split('.')[0]}-auth-token`;

const DEVICES = {
iphone: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true },
ipad: { viewport: { width: 820, height: 1180 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true },
ipad: { viewport: { width: 1024, height: 1366 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true },
'ipad-landscape': { viewport: { width: 1366, height: 1024 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true },
'ipad-narrow': { viewport: { width: 600, height: 1366 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true },
} as const;
const SCHEMES = ['light', 'dark'] as const;
const LOCALES = { en: 'en-GB', hu: 'hu-HU' } as const;
Expand Down Expand Up @@ -447,6 +454,18 @@ async function shootResultScreens(context: BrowserContext, prefix: string, langu
await page.getByTestId('case-document').waitFor({ timeout: 30_000 });
await page.waitForTimeout(400);
await shoot(page, `${prefix}-case`);
// A wide iPad shows the outline rail: jumping to a part scrolls that part's heading to the top of the case.
const outline = page.getByTestId('document-outline');
if (await outline.isVisible()) {
const part = /^(Case Structure|Eset Struktúra|Estructura del Caso)$/;
const heading = page.getByTestId('case-document').getByRole('heading', { name: part });
const before = (await heading.boundingBox())?.y ?? 0;
await outline.getByRole('menuitem').filter({ hasText: part }).click();
await page.waitForTimeout(800);
const after = (await heading.boundingBox())?.y ?? 0;
if (!(after < before && after < 200)) throw new Error(`The outline did not scroll to Case Structure (heading at ${before}, then ${after})`);
await shoot(page, `${prefix}-case-outline`);
}
// Close the open argument, so every argument shows collapsed.
await page.getByTestId('document-item').first().click();
await shoot(page, `${prefix}-case-collapsed`);
Expand Down Expand Up @@ -618,7 +637,10 @@ async function shootFlowchart(context: BrowserContext, prefix: string, language:
await page.getByTestId('claim-link').click();
await page.getByTestId('link-targets').waitFor();
await shoot(page, `${prefix}-flowchart-link`);
await page.goBack();
// On iPad the claim is docked beside the map, so it closes instead of going back.
const docked = page.getByTestId('claim-close');
if (await docked.isVisible()) await docked.click();
else await page.goBack();
await page.getByTestId('flowchart-screen').waitFor();

await page.getByTestId('document-add').click();
Expand Down Expand Up @@ -1546,15 +1568,15 @@ async function main() {
try {
const session = DEMO_CODE ? await signIn(browser, problems) : null;
if (!session) console.log('STAGING_DEMO_CODE is not set: signed-in screens skipped');
else if (ONLY === 'offline') {
else if (offlineOnly) {
for (const [index, run] of OFFLINE_RUNS.entries()) shots += await shootOfflineScreens(browser, session, problems, run, index === 0);
} else await checkOfflineColdStart(browser, session, problems);

for (const [device, options] of Object.entries(DEVICES)) {
for (const scheme of SCHEMES) {
for (const [language, locale] of Object.entries(LOCALES)) {
const prefix = `${device}-${scheme}-${language}`;
if (!prefix.startsWith(ONLY)) continue;
if (ONLY.length > 0 && !ONLY.some((only) => prefix.startsWith(only))) continue;
const signedOut = await browser.newContext({ ...options, colorScheme: scheme, locale });
const page = await signedOut.newPage();
watchErrors(page, problems, prefix);
Expand Down
Loading
Loading