From 35a61961c17257f857c0ea9136300842dcdc991b Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 13:07:20 +0000 Subject: [PATCH 1/7] Make the scroll view the root of every form sheet New debate opened as an empty sheet on the iPhone: the keyboard came up, but nothing else showed. react-native-screens sizes a form sheet's scroll view natively. A scroll view that is the sheet's direct child only has its size set. One nested in a KeyboardAvoidingView falls back to setFrame(screen.frame), which copies the sheet's slide-in offset and leaves the content below the visible sheet. The switcher, whose scroll view was the root, rendered fine. SheetScrollView is now the root of every sheet, with no KeyboardAvoidingView. iOS raises the sheet to its top detent for the keyboard, and automaticallyAdjustKeyboardInsets scrolls the fields clear of it. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/add-link.tsx | 127 +++++++++++------------ apps/mobile/src/app/delete-account.tsx | 91 ++++++++--------- apps/mobile/src/app/new-debate.tsx | 134 ++++++++++++------------- apps/mobile/src/app/new-note.tsx | 90 ++++++++--------- apps/mobile/src/app/source/[id].tsx | 14 +-- apps/mobile/src/components/sheet.tsx | 40 ++++++++ 6 files changed, 249 insertions(+), 247 deletions(-) create mode 100644 apps/mobile/src/components/sheet.tsx diff --git a/apps/mobile/src/app/add-link.tsx b/apps/mobile/src/app/add-link.tsx index 37ad6a44b..0d141eb40 100644 --- a/apps/mobile/src/app/add-link.tsx +++ b/apps/mobile/src/app/add-link.tsx @@ -2,12 +2,12 @@ import type { SourceImportOutput } from '@debatelab/core/ai/schemas/source-impor import { useRouter } from 'expo-router'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { StyleSheet, View } from 'react-native'; import { MainActionButton, PrimaryButton, TextButton } from '@/components/buttons'; import { Card } from '@/components/card'; import { Field } from '@/components/field'; +import { SheetScrollView } from '@/components/sheet'; import { Text } from '@/components/text'; import { useAuth } from '@/lib/auth'; import { useDebates } from '@/lib/debate-context'; @@ -22,7 +22,6 @@ import { useTheme } from '@/theme/use-theme'; export default function AddLinkSheet() { const { t } = useTranslation(); const router = useRouter(); - const insets = useSafeAreaInsets(); const { colors } = useTheme(); const { session } = useAuth(); const { current } = useDebates(); @@ -73,78 +72,72 @@ export default function AddLinkSheet() { }; return ( - - - - - {t('mobile.library.pasteLink')} - - router.back()} disabled={busy === 'save'} /> - + + + + {t('mobile.library.pasteLink')} + + router.back()} disabled={busy === 'save'} /> + - {!current ? ( - - {t('debate.libraryNeedsDebate')} - - ) : ( - <> - { - setUrl(next); - setDraft(null); - }} - keyboardType="url" - autoCapitalize="none" - autoCorrect={false} - autoFocus - returnKeyType="go" - onSubmitEditing={() => void runImport()} - editable={busy === null} - /> - {!draft ? ( - void runImport()} /> - ) : ( - <> - - {t('libraryPage.importPreview')} + {!current ? ( + + {t('debate.libraryNeedsDebate')} + + ) : ( + <> + { + setUrl(next); + setDraft(null); + }} + keyboardType="url" + autoCapitalize="none" + autoCorrect={false} + autoFocus + returnKeyType="go" + onSubmitEditing={() => void runImport()} + editable={busy === null} + /> + {!draft ? ( + void runImport()} /> + ) : ( + <> + + {t('libraryPage.importPreview')} + + + + + {`${draft.domain} · ${draft.wordCount} ${t('libraryPage.words')}`} - - - - {`${draft.domain} · ${draft.wordCount} ${t('libraryPage.words')}`} + {draft.excerpt ? ( + + {draft.excerpt} - {draft.excerpt ? ( - - {draft.excerpt} - - ) : null} - - void save()} /> - - )} - - )} + ) : null} + + void save()} /> + + )} + + )} - {error ? ( - - {error} - - ) : null} - - + {error ? ( + + {error} + + ) : null} + ); } const styles = StyleSheet.create({ - root: { flex: 1 }, - content: { paddingHorizontal: 20, paddingTop: 22, gap: 16, width: '100%', maxWidth: 640, alignSelf: 'center' }, + content: { gap: 16 }, 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 }, diff --git a/apps/mobile/src/app/delete-account.tsx b/apps/mobile/src/app/delete-account.tsx index 39a1d691c..d9a1fa9e9 100644 --- a/apps/mobile/src/app/delete-account.tsx +++ b/apps/mobile/src/app/delete-account.tsx @@ -2,11 +2,11 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { useRouter } from 'expo-router'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { StyleSheet, View } from 'react-native'; import { PrimaryButton, TextButton } from '@/components/buttons'; import { Field } from '@/components/field'; +import { SheetScrollView } from '@/components/sheet'; import { Text } from '@/components/text'; import { deleteAccount, deletionConfirmation } from '@/lib/account'; import { useAuth } from '@/lib/auth'; @@ -22,7 +22,6 @@ async function forgetAccount(userId: string) { export default function DeleteAccountSheet() { const { t } = useTranslation(); const router = useRouter(); - const insets = useSafeAreaInsets(); const { colors } = useTheme(); const { session, signOut } = useAuth(); const email = session?.user.email; @@ -51,59 +50,51 @@ export default function DeleteAccountSheet() { }; return ( - - - - - {t('settings.deleteAccountDialogTitle')} + + + + {t('settings.deleteAccountDialogTitle')} + + router.back()} disabled={deleting} /> + + + {t('settings.deleteAccountDialogDesc', { email: email || t('settings.unknownUser') })} + + void remove()} + /> + {failed ? ( + + + {t('settings.deleteAccountFailed')} + + + {t('mobile.deleteAccount.failedHint')} - router.back()} disabled={deleting} /> - - {t('settings.deleteAccountDialogDesc', { email: email || t('settings.unknownUser') })} - - void remove()} - /> - {failed ? ( - - - {t('settings.deleteAccountFailed')} - - - {t('mobile.deleteAccount.failedHint')} - - - ) : null} - void remove()} - /> - - + ) : null} + void remove()} + /> + ); } const styles = StyleSheet.create({ - root: { flex: 1 }, - content: { paddingHorizontal: 20, paddingTop: 22, gap: 18, width: '100%', maxWidth: 640, alignSelf: 'center' }, 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 }, diff --git a/apps/mobile/src/app/new-debate.tsx b/apps/mobile/src/app/new-debate.tsx index 3760d78f4..3d1f46a26 100644 --- a/apps/mobile/src/app/new-debate.tsx +++ b/apps/mobile/src/app/new-debate.tsx @@ -1,13 +1,13 @@ import { useRouter } from 'expo-router'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Pressable, StyleSheet, View } from 'react-native'; import { MainActionButton, TextButton } from '@/components/buttons'; import { Card } from '@/components/card'; import { Field } from '@/components/field'; import { Icon } from '@/components/icon'; +import { SheetScrollView } from '@/components/sheet'; import { Text } from '@/components/text'; import { useDebates } from '@/lib/debate-context'; import { DEBATE_FORMATS, MAX_MOTION_LENGTH, type DebateFormat } from '@/lib/debates'; @@ -17,7 +17,6 @@ import { useTheme } from '@/theme/use-theme'; export default function NewDebateSheet() { const { t } = useTranslation(); const router = useRouter(); - const insets = useSafeAreaInsets(); const { colors } = useTheme(); const { create } = useDebates(); const [motion, setMotion] = useState(''); @@ -43,84 +42,75 @@ export default function NewDebateSheet() { }; return ( - - - - - {t('debate.startNew')} - - router.back()} disabled={saving} /> - - - {t('debate.startNewDesc')} + + + + {t('debate.startNew')} + router.back()} disabled={saving} /> + + + {t('debate.startNewDesc')} + - + - - - {t('debate.format')} - - - {DEBATE_FORMATS.map((value, index) => { - const selected = value === format; - return ( - setFormat(value)} - style={({ pressed }) => [ - styles.option, - index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, - pressed && styles.pressed, - ]}> - - {t(`debate.formats.${value}`)} - - {selected ? : null} - - ); - })} - - + + + {t('debate.format')} + + + {DEBATE_FORMATS.map((value, index) => { + const selected = value === format; + return ( + setFormat(value)} + style={({ pressed }) => [ + styles.option, + index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, + pressed && styles.pressed, + ]}> + + {t(`debate.formats.${value}`)} + + {selected ? : null} + + ); + })} + + - {error ? ( - - {error} - - ) : null} + {error ? ( + + {error} + + ) : null} - void submit()} - /> - - + void submit()} + /> + ); } const styles = StyleSheet.create({ - root: { flex: 1 }, - content: { paddingHorizontal: 20, paddingTop: 22, gap: 18, width: '100%', maxWidth: 640, alignSelf: 'center' }, 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 }, diff --git a/apps/mobile/src/app/new-note.tsx b/apps/mobile/src/app/new-note.tsx index 4e89c5907..22874dd59 100644 --- a/apps/mobile/src/app/new-note.tsx +++ b/apps/mobile/src/app/new-note.tsx @@ -1,11 +1,11 @@ import { useRouter } from 'expo-router'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { StyleSheet, View } from 'react-native'; import { MainActionButton, TextButton } from '@/components/buttons'; import { Field } from '@/components/field'; +import { SheetScrollView } from '@/components/sheet'; import { Text } from '@/components/text'; import { useAuth } from '@/lib/auth'; import { useDebates } from '@/lib/debate-context'; @@ -16,7 +16,6 @@ import { useTheme } from '@/theme/use-theme'; export default function NewNoteSheet() { const { t } = useTranslation(); const router = useRouter(); - const insets = useSafeAreaInsets(); const { colors } = useTheme(); const { session } = useAuth(); const { current } = useDebates(); @@ -43,57 +42,50 @@ export default function NewNoteSheet() { }; return ( - - - - - {t('mobile.library.writeNote')} - - router.back()} disabled={saving} /> - + + + + {t('mobile.library.writeNote')} + + router.back()} disabled={saving} /> + - {!current ? ( - - {t('debate.libraryNeedsDebate')} - - ) : ( - <> - - - {error ? ( - - {error} - - ) : null} - void save()} /> - - )} - - + {!current ? ( + + {t('debate.libraryNeedsDebate')} + + ) : ( + <> + + + {error ? ( + + {error} + + ) : null} + void save()} /> + + )} + ); } const styles = StyleSheet.create({ - root: { flex: 1 }, - content: { paddingHorizontal: 20, paddingTop: 22, gap: 18, width: '100%', maxWidth: 640, alignSelf: 'center' }, 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 }, diff --git a/apps/mobile/src/app/source/[id].tsx b/apps/mobile/src/app/source/[id].tsx index a9dc498db..c15de2591 100644 --- a/apps/mobile/src/app/source/[id].tsx +++ b/apps/mobile/src/app/source/[id].tsx @@ -2,12 +2,12 @@ import { useLocalSearchParams, useRouter } from 'expo-router'; import * as WebBrowser from 'expo-web-browser'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, Pressable, ScrollView, StyleSheet, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; import { TextButton } from '@/components/buttons'; import { Card, IconTile } from '@/components/card'; import { Icon } from '@/components/icon'; +import { SheetScrollView } from '@/components/sheet'; import { SOURCE_ICONS, sourceMeta } from '@/components/source-display'; import { Text } from '@/components/text'; import { useAuth } from '@/lib/auth'; @@ -22,7 +22,6 @@ function webUrl(origin: string | null): string | null { export default function SourceSheet() { const { t } = useTranslation(); const router = useRouter(); - const insets = useSafeAreaInsets(); const { colors } = useTheme(); const { id } = useLocalSearchParams<{ id: string }>(); const { session } = useAuth(); @@ -49,10 +48,7 @@ export default function SourceSheet() { const url = webUrl(source?.origin ?? null); return ( - + {source ? ( @@ -106,12 +102,12 @@ export default function SourceSheet() { )} - + ); } const styles = StyleSheet.create({ - content: { paddingHorizontal: 20, paddingTop: 18, gap: 14, width: '100%', maxWidth: 640, alignSelf: 'center' }, + content: { paddingTop: 18, gap: 14 }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, headerText: { flex: 1 }, kind: { flexDirection: 'row', alignItems: 'center', gap: 9 }, diff --git a/apps/mobile/src/components/sheet.tsx b/apps/mobile/src/components/sheet.tsx new file mode 100644 index 000000000..1704df017 --- /dev/null +++ b/apps/mobile/src/components/sheet.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from 'react'; +import { ScrollView, StyleSheet, type StyleProp, type ViewStyle } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { useTheme } from '@/theme/use-theme'; + +/** + * The scrolling body of a form sheet. It must be the sheet's root view: react-native-screens + * sizes a form sheet's scroll view natively, and only a direct child keeps its position. Wrapped + * in a KeyboardAvoidingView or any other view, it falls back to `setFrame(screen.frame)`, which + * copies the sheet's slide-in offset and leaves the content below the visible sheet. + * The sheet rises to its top detent when the keyboard opens; the insets scroll fields clear of it. + */ +export function SheetScrollView({ + children, + contentContainerStyle, + testID, +}: { + children: ReactNode; + contentContainerStyle?: StyleProp; + testID?: string; +}) { + const insets = useSafeAreaInsets(); + const { colors } = useTheme(); + return ( + + {children} + + ); +} + +const styles = StyleSheet.create({ + content: { paddingHorizontal: 20, paddingTop: 22, gap: 18, width: '100%', maxWidth: 640, alignSelf: 'center' }, +}); From ac450c9cd2ded6db6c77c6051fb8cf3293dcb2fa Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 13:07:29 +0000 Subject: [PATCH 2/7] Let one-off mode turn on without a Current Debate The switcher's one-off switch was disabled without a Current Debate, so on an account with no current debate it was greyed out and did nothing. The website lets you turn one-off mode on at any time. The switch now always works. OneOffNotice shows the website's one-off copy on the Debate tab (also in its empty state), in Ask and in the Library, with "Return to debate" only when there is a debate to return to, as in CurrentDebateStrip. The Library says only that the mode is on, because new sources still go to the Current Debate there, as on the website. The debates switcher also moves to SheetScrollView. The screenshot run toggles the switch with and without a Current Debate, shoots both states, and checks that Return turns the mode off. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/scripts/screenshots.ts | 43 +++++++++++++++-- apps/mobile/src/app/(tabs)/(debate)/index.tsx | 30 ++---------- .../src/app/(tabs)/(library)/library.tsx | 3 ++ apps/mobile/src/app/ask.tsx | 2 + apps/mobile/src/app/debates.tsx | 16 ++----- apps/mobile/src/components/one-off-notice.tsx | 46 +++++++++++++++++++ 6 files changed, 101 insertions(+), 39 deletions(-) create mode 100644 apps/mobile/src/components/one-off-notice.tsx diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index 8a36f209e..06ed930e7 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -21,7 +21,9 @@ * therefore answer the app's user_settings reads with the run's language and * the System theme; nothing is written. The Debate tab shows the account's * real Current Debate, and its empty state comes from answering the - * current_debates read with no row. The Debate tab is shot once per side view. + * current_debates read with no row. The Debate tab is shot once per side view, + * and with one-off mode on, with and without a Current Debate. One-off mode is + * in memory only; the script toggles it and never taps a debate row. * * Ask screenshots never call a model and never save a chat: the page answers * ai-stream from a fake stream inside the page (expo/fetch uses the page's @@ -142,12 +144,35 @@ async function shootDebateScreens(page: Page, prefix: string) { await page.waitForTimeout(700); await shoot(page, `${prefix}-debates-sheet`); + // One-off mode lives in memory only, so toggling it writes nothing. Never tap a debate row + // here: switching saves the pointer and last activity to staging. + await turnOnOneOff(page); + await shoot(page, `${prefix}-debates-oneoff`); + await closeSwitcher(page); + await page.getByTestId('one-off-notice').waitFor(); + await shoot(page, `${prefix}-debate-oneoff`); + await page.getByTestId('one-off-notice').getByRole('button').click(); + await page.getByTestId('one-off-notice').waitFor({ state: 'detached' }); + + await page.getByTestId('current-debate-pill').click(); + await page.getByTestId('debates-sheet').waitFor(); await page.getByRole('button', { name: /^(New debate|Új vita|Nuevo debate)$/ }).click(); await page.getByTestId('new-debate-sheet').waitFor(); await page.waitForTimeout(700); await shoot(page, `${prefix}-new-debate`); } +async function turnOnOneOff(page: Page) { + const toggle = page.getByTestId('debates-sheet').getByRole('switch'); + await toggle.click(); + if (!(await toggle.isChecked())) throw new Error('The one-off switch did not turn on'); +} + +async function closeSwitcher(page: Page) { + await page.getByTestId('debates-sheet').getByRole('button', { name: /^(Done|Kész|Listo)$/ }).click(); + await page.getByTestId('debates-sheet').waitFor({ state: 'detached' }); +} + // A made-up answer in the shape of mock 07, streamed a word at a time. const ASK_QUESTION = 'How do I answer "what about the nurse who drives in at 5 a.m."?'; 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.'; @@ -267,6 +292,18 @@ async function shootEmptyDebate(page: Page, prefix: string) { await page.getByTestId('current-debate-pill').waitFor({ timeout: 30_000 }); await settle(page); await shoot(page, `${prefix}-debate-empty`); + + // One-off mode works without a Current Debate too; there is nothing to return to. + await page.getByTestId('current-debate-pill').click(); + await page.getByTestId('debates-sheet').waitFor(); + await settle(page); + await turnOnOneOff(page); + await closeSwitcher(page); + await page.getByTestId('one-off-notice').waitFor(); + if ((await page.getByTestId('one-off-notice').getByRole('button').count()) > 0) { + throw new Error('One-off mode offered a return without a Current Debate'); + } + await shoot(page, `${prefix}-debate-empty-oneoff`); } async function signIn(browser: Browser, problems: string[]): Promise { @@ -381,7 +418,7 @@ async function main() { await shoot(tabPage, `${prefix}-failed`); throw error; }); - shots += 4; + shots += 6; await shootLibraryScreens(tabPage, prefix).catch(async (error: unknown) => { await shoot(tabPage, `${prefix}-failed`); throw error; @@ -399,7 +436,7 @@ async function main() { watchErrors(emptyPage, problems, `${prefix} empty`); await pinSettings(emptyPage, language); await shootEmptyDebate(emptyPage, prefix); - shots += 1; + shots += 2; await shootAskScreens(signedIn, prefix, language, problems); shots += 3; await signedIn.close(); diff --git a/apps/mobile/src/app/(tabs)/(debate)/index.tsx b/apps/mobile/src/app/(tabs)/(debate)/index.tsx index ff1759905..2f61cb136 100644 --- a/apps/mobile/src/app/(tabs)/(debate)/index.tsx +++ b/apps/mobile/src/app/(tabs)/(debate)/index.tsx @@ -8,6 +8,7 @@ import { MainActionButton, TextButton } from '@/components/buttons'; import { Card, IconTile } from '@/components/card'; import { Icon } from '@/components/icon'; import type { IconName } from '@/components/icon-paths'; +import { OneOffNotice } from '@/components/one-off-notice'; import { Screen } from '@/components/screen'; import { SegmentedControl } from '@/components/segmented-control'; import { Text } from '@/components/text'; @@ -52,7 +53,7 @@ const PRACTICE_KINDS = new Set(['attack', 'crossfire', 'sparring export default function DebateScreen() { const { t } = useTranslation(); const router = useRouter(); - const { status, current, oneOff, setOneOff, refresh } = useDebates(); + const { status, current, refresh } = useDebates(); // Debates switched on the website show up when the tab comes back into view. const focusedBefore = useRef(false); @@ -90,6 +91,7 @@ export default function DebateScreen() { return ( + {t('debate.noCurrentTitle')} @@ -106,7 +108,7 @@ export default function DebateScreen() { return ( - {oneOff ? setOneOff(false)} /> : null} + {current.motion} @@ -133,19 +135,6 @@ function SwitcherPill({ onPress }: { onPress: () => void }) { ); } -function OneOffNotice({ onReturn }: { onReturn: () => void }) { - const { t } = useTranslation(); - const { colors } = useTheme(); - return ( - - - {t('debate.oneOffStrip')} - - - - ); -} - /** * Counts, the main action, the Prepare tools and recent work for one debate. * Students prepare both sides, so the toggle only chooses which side's work is @@ -361,16 +350,7 @@ const styles = StyleSheet.create({ marginTop: 4, }, pillLabel: { fontSize: 15 }, - notice: { - marginTop: 12, - borderWidth: 1, - borderStyle: 'dashed', - borderRadius: radius.field, - paddingHorizontal: 14, - paddingTop: 10, - alignItems: 'flex-start', - }, - noticeText: { fontSize: 14, lineHeight: 19 }, + notice: { marginTop: 12 }, emptyText: { gap: 8 }, emptyTitle: { fontSize: 28, lineHeight: 34, letterSpacing: -0.6 }, motion: { fontSize: 27, lineHeight: 32, letterSpacing: -0.6, marginTop: 12, marginBottom: 15 }, diff --git a/apps/mobile/src/app/(tabs)/(library)/library.tsx b/apps/mobile/src/app/(tabs)/(library)/library.tsx index efb12a582..43a74edba 100644 --- a/apps/mobile/src/app/(tabs)/(library)/library.tsx +++ b/apps/mobile/src/app/(tabs)/(library)/library.tsx @@ -9,6 +9,7 @@ import { Card, IconTile } from '@/components/card'; import { Chip } from '@/components/chip'; import { Icon } from '@/components/icon'; import { LibraryHeader, type AddSourceKind } from '@/components/library-header'; +import { OneOffNotice } from '@/components/one-off-notice'; import { Screen } from '@/components/screen'; import { SearchField } from '@/components/search-field'; import { SOURCE_ICONS, sourceMeta } from '@/components/source-display'; @@ -47,6 +48,8 @@ export default function LibraryScreen() { return ( + {/* 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 ? ( diff --git a/apps/mobile/src/app/ask.tsx b/apps/mobile/src/app/ask.tsx index 3ba8333a9..dc49ad148 100644 --- a/apps/mobile/src/app/ask.tsx +++ b/apps/mobile/src/app/ask.tsx @@ -11,6 +11,7 @@ import { TextButton } from '@/components/buttons'; import { Chip } from '@/components/chip'; import { Icon } from '@/components/icon'; import type { IconName } from '@/components/icon-paths'; +import { OneOffNotice } from '@/components/one-off-notice'; import { Text } from '@/components/text'; import { askOnce, @@ -336,6 +337,7 @@ export default function AskScreen() { keyboardShouldPersistTaps="handled" keyboardDismissMode="interactive" onContentSizeChange={() => scroll.current?.scrollToEnd({ animated: true })}> + {messages.length === 0 && streaming === null && !busy ? ( diff --git a/apps/mobile/src/app/debates.tsx b/apps/mobile/src/app/debates.tsx index fd2f11f17..753e3baab 100644 --- a/apps/mobile/src/app/debates.tsx +++ b/apps/mobile/src/app/debates.tsx @@ -1,12 +1,12 @@ import { useRouter } from 'expo-router'; import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Switch, TextInput, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { ActivityIndicator, Pressable, StyleSheet, Switch, TextInput, View } from 'react-native'; import { PrimaryButton, TextButton } from '@/components/buttons'; import { Card } from '@/components/card'; import { Icon } from '@/components/icon'; +import { SheetScrollView } from '@/components/sheet'; import { Text } from '@/components/text'; import { useAuth } from '@/lib/auth'; import { useDebates } from '@/lib/debate-context'; @@ -24,7 +24,6 @@ function normalize(text: string) { export default function DebatesSheet() { const { t } = useTranslation(); const router = useRouter(); - const insets = useSafeAreaInsets(); const { colors } = useTheme(); const { status, debates, current, oneOff, setOneOff, switchTo, refresh } = useDebates(); const { session } = useAuth(); @@ -76,11 +75,7 @@ export default function DebatesSheet() { }; return ( - + {t('debate.yourDebates')} @@ -180,7 +175,6 @@ export default function DebatesSheet() { router.push('/new-debate')} /> - + ); } const styles = StyleSheet.create({ - content: { paddingHorizontal: 20, paddingTop: 22, gap: 14, width: '100%', maxWidth: 640, alignSelf: 'center' }, + content: { gap: 14 }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, title: { fontSize: 21, lineHeight: 26, letterSpacing: -0.3, flexShrink: 1 }, search: { diff --git a/apps/mobile/src/components/one-off-notice.tsx b/apps/mobile/src/components/one-off-notice.tsx new file mode 100644 index 000000000..dedd8f784 --- /dev/null +++ b/apps/mobile/src/components/one-off-notice.tsx @@ -0,0 +1,46 @@ +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native'; + +import { TextButton } from '@/components/buttons'; +import { Text } from '@/components/text'; +import { useDebates } from '@/lib/debate-context'; +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +/** + * Shown while one-off mode is on, like the website's CurrentDebateStrip: the message always, + * and "Return to debate" only when there is a Current Debate to return to. + */ +export function OneOffNotice({ + message = 'debate.oneOffStrip', + style, +}: { + message?: 'debate.oneOffStrip' | 'debate.oneOffActive'; + style?: StyleProp; +}) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const { oneOff, current, setOneOff } = useDebates(); + if (!oneOff) return null; + return ( + + + {t(message)} + + {current ? setOneOff(false)} /> : null} + + ); +} + +const styles = StyleSheet.create({ + notice: { + borderWidth: 1, + borderStyle: 'dashed', + borderRadius: radius.field, + paddingHorizontal: 14, + paddingTop: 10, + alignItems: 'flex-start', + }, + noAction: { paddingBottom: 10 }, + text: { fontSize: 14, lineHeight: 19 }, +}); From 8bc7c586904867444152294f581c1aa302594767 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 13:07:43 +0000 Subject: [PATCH 3/7] Polyfill Intl.PluralRules so plural strings render on Hermes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes in Expo Go has no Intl.PluralRules. i18next logged a red LogBox error on the phone and fell back to v3 plural handling, which looks for `_plural` keys. Keys with only `_one` and `_other` then rendered as the raw key ("mobile.debates.sourceCount"), and the rest always used the plural form ("1 messages"). @formatjs/intl-pluralrules (pure JS, with en, es and hu data) now loads before i18next initialises. It installs only where PluralRules is missing, so the web keeps the browser's. Hermes already has Intl.getCanonicalLocales, which the polyfill needs, and Intl.DateTimeFormat, the only other Intl API the app calls. date-fns formats relative dates itself. Checked in Node with Intl.PluralRules and Intl.Locale deleted: without the polyfill the error and raw keys reproduce; with it en, es and hu give 1 source / 2 sources, 1 fuente / 2 fuentes, 1 forrás / 2 forrás. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/package.json | 1 + apps/mobile/src/lib/i18n.ts | 8 ++++++++ package-lock.json | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 5cb31b52e..9dca55511 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -11,6 +11,7 @@ "dependencies": { "@debatelab/core": "*", "@expo-google-fonts/inter": "^0.4.2", + "@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", diff --git a/apps/mobile/src/lib/i18n.ts b/apps/mobile/src/lib/i18n.ts index b41fa428b..d8de4c7d3 100644 --- a/apps/mobile/src/lib/i18n.ts +++ b/apps/mobile/src/lib/i18n.ts @@ -1,3 +1,11 @@ +// Hermes has no Intl.PluralRules, and without it i18next cannot pick `_one`/`_other` keys +// and shows the raw key. The polyfill only installs where it is missing, so the web keeps +// the browser's. It must load before i18next initialises below. +import '@formatjs/intl-pluralrules/polyfill.js'; +import '@formatjs/intl-pluralrules/locale-data/en.js'; +import '@formatjs/intl-pluralrules/locale-data/es.js'; +import '@formatjs/intl-pluralrules/locale-data/hu.js'; + import { isAppLanguage, type AppLanguage } from '@debatelab/core/languages'; import { getLocales } from 'expo-localization'; import i18n from 'i18next'; diff --git a/package-lock.json b/package-lock.json index 3f22ef75d..15806885a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -120,6 +120,7 @@ "dependencies": { "@debatelab/core": "*", "@expo-google-fonts/inter": "^0.4.2", + "@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", @@ -3720,6 +3721,37 @@ "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, + "node_modules/@formatjs/bigdecimal": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@formatjs/bigdecimal/-/bigdecimal-0.2.7.tgz", + "integrity": "sha512-B4+XY/e4u5v261r3a37Kq2ax91gPZSKGyVpx8X5dygAggLlUz+6MeBpmAhaBFGB8WQ4JB1qD+QtKHk9aJkEb9Q==", + "license": "MIT" + }, + "node_modules/@formatjs/fast-memoize": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.7.tgz", + "integrity": "sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==", + "license": "MIT" + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.9.0.tgz", + "integrity": "sha512-Z7TD3ssNry1Av1dVDoq/mzpxGyxqWwU0ClWEo+BB3k0JQTeQvUGmeK9WZGYvlqStS0UqkaKXk3dfjsb6NaHAxw==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "3.1.7" + } + }, + "node_modules/@formatjs/intl-pluralrules": { + "version": "6.3.15", + "resolved": "https://registry.npmjs.org/@formatjs/intl-pluralrules/-/intl-pluralrules-6.3.15.tgz", + "integrity": "sha512-/K6Up6SrvLcTP5mWVHp58A9Uk7I4jYbDYqtYdgKDeSY1HG0bhBMS4eVtqK2nFx8mWWdcHbt9ZqHBcCto573PNg==", + "license": "MIT", + "dependencies": { + "@formatjs/bigdecimal": "0.2.7", + "@formatjs/intl-localematcher": "0.9.0" + } + }, "node_modules/@gilbarbara/deep-equal": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/@gilbarbara/deep-equal/-/deep-equal-0.3.1.tgz", From 971811526f9330ede947cdf4a6e34c46552631a0 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 13:07:43 +0000 Subject: [PATCH 4/7] Drop the header blur on iOS 26 and allow 120 Hz in dev builds On iOS 26, react-native-screens warned that the tab headers used both a blur effect and scroll edge effects. iOS 26 draws its own scroll edge effect under a transparent header, so the blur now applies only on earlier iOS, which has no edge effect. CADisableMinimumFrameDurationOnPhone lets ProMotion iPhones run at 120 Hz. Expo Go's Info.plist cannot change, so this takes effect from the first development build. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/app.json | 5 ++++- apps/mobile/src/components/tab-stack.tsx | 7 ++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 13caf6d20..c68036b1a 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -9,7 +9,10 @@ "userInterfaceStyle": "automatic", "ios": { "bundleIdentifier": "com.adam.debatelab", - "supportsTablet": true + "supportsTablet": true, + "infoPlist": { + "CADisableMinimumFrameDurationOnPhone": true + } }, "android": { "adaptiveIcon": { diff --git a/apps/mobile/src/components/tab-stack.tsx b/apps/mobile/src/components/tab-stack.tsx index 5b2b075ea..4bb6e1cf7 100644 --- a/apps/mobile/src/components/tab-stack.tsx +++ b/apps/mobile/src/components/tab-stack.tsx @@ -1,8 +1,13 @@ import { Stack } from 'expo-router'; +import { Platform } from 'react-native'; import { fontFamily } from '@/theme/fonts'; import { useTheme } from '@/theme/use-theme'; +// iOS 26 draws its own scroll edge effect under a transparent header; a blur on top of it +// doubles the effect (react-native-screens warns). Earlier iOS has no edge effect, so it keeps the blur. +const legacyHeaderBlur = Platform.OS === 'ios' && Number.parseInt(String(Platform.Version), 10) < 26; + /** Each tab is a native stack with an iOS large title, so later screens can push onto it. */ export function TabStack({ largeTitle = true }: { largeTitle?: boolean }) { const { colors } = useTheme(); @@ -12,7 +17,7 @@ export function TabStack({ largeTitle = true }: { largeTitle?: boolean }) { headerShown: largeTitle, headerLargeTitleEnabled: true, headerTransparent: true, - headerBlurEffect: 'systemMaterial', + headerBlurEffect: legacyHeaderBlur ? 'systemMaterial' : 'none', headerShadowVisible: false, headerLargeTitleShadowVisible: false, headerLargeStyle: { backgroundColor: 'transparent' }, From 82ac868df35ba314087a86e3e58e6f205a56174c Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 13:07:43 +0000 Subject: [PATCH 5/7] Skip re-renders on unchanged debate refreshes and smooth Ask's scrolling The debate list refreshes on every Debate tab focus, on returning to the app and when the switcher opens. Each refresh stored a new array, which re-rendered every mounted tab that reads the debates even when nothing had changed. An unchanged list now keeps its identity. Ask scrolled to the end with an animation on every 30 ms paint of a streamed answer, restarting the animation each time. It now jumps while streaming and animates only for whole messages. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/ask.tsx | 3 ++- apps/mobile/src/lib/debate-context.tsx | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/app/ask.tsx b/apps/mobile/src/app/ask.tsx index dc49ad148..fc8676e20 100644 --- a/apps/mobile/src/app/ask.tsx +++ b/apps/mobile/src/app/ask.tsx @@ -336,7 +336,8 @@ export default function AskScreen() { contentContainerStyle={styles.messagesContent} keyboardShouldPersistTaps="handled" keyboardDismissMode="interactive" - onContentSizeChange={() => scroll.current?.scrollToEnd({ animated: true })}> + // A streamed answer grows every 30 ms; restarting an animated scroll each time stutters. + onContentSizeChange={() => scroll.current?.scrollToEnd({ animated: streaming === null })}> {messages.length === 0 && streaming === null && !busy ? ( diff --git a/apps/mobile/src/lib/debate-context.tsx b/apps/mobile/src/lib/debate-context.tsx index bda43d62f..2cbc13234 100644 --- a/apps/mobile/src/lib/debate-context.tsx +++ b/apps/mobile/src/lib/debate-context.tsx @@ -29,6 +29,11 @@ type DebateContextValue = { const DebateContext = createContext(null); +/** Refreshes run on every Debate tab focus; an unchanged list keeps its identity so the tabs do not re-render. */ +function keepIfSame(previous: Debate[], next: Debate[]): Debate[] { + return JSON.stringify(previous) === JSON.stringify(next) ? previous : next; +} + export function DebateProvider({ children }: { children: ReactNode }) { const { session } = useAuth(); const userId = session?.user.id ?? null; @@ -54,7 +59,7 @@ export function DebateProvider({ children }: { children: ReactNode }) { try { const next = await fetchDebates(forUser); if (owner.current !== forUser || refreshes.current !== request || writes.current !== writesBefore) return; - setDebates(next.debates); + setDebates((previous) => keepIfSame(previous, next.debates)); setCurrentId(next.currentId); setStatus('ready'); } catch (cause) { From 5961a026a6e52d16672f1b0fd64b1380f613a98f Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 13:07:43 +0000 Subject: [PATCH 6/7] Use the tailnet IP for Expo Go and document form sheets and one-off mode Metro's CLI crashed with getaddrinfo ENOTFOUND during Adam's session: the VPS cannot resolve its own MagicDNS name. The Expo Go runbook now puts the tailnet IP in REACT_NATIVE_PACKAGER_HOSTNAME and keeps the name only as a note. It also says that `npx expo login` runs from apps/mobile and that its browser login calls back to localhost on the VPS. apps/mobile/AGENTS.md records that a form sheet's root view must be SheetScrollView, and where one-off mode shows. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 3 ++- docs/development.md | 10 ++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 06e080bc9..a9b2b68ac 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -13,6 +13,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp - Google sign-in needs a development build, because Expo Go cannot receive OAuth redirects. Until then, sign-in is email code or demo code. - Native code has no DOM. eslint rejects `window`, `document`, `localStorage`, `sessionStorage` and `location` outside `*.web.ts(x)` files. A `.web.tsx` file exists only for the web preview. - `ios/` and `android/` are generated and gitignored. Configure native behaviour in `app.json`. +- A form sheet's root view is `SheetScrollView` (`src/components/sheet.tsx`), with nothing around it: no `KeyboardAvoidingView`, no wrapping `View`. react-native-screens positions only a scroll view that is the sheet's direct child; a nested one lands below the visible sheet and the sheet looks empty. ## Layout @@ -21,7 +22,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp - `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. -- 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. +- 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, Ask and Library. 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. diff --git a/docs/development.md b/docs/development.md index a1e306018..f6ba1c2a1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -116,20 +116,22 @@ The phone loads the app from a Metro dev server on the VPS over the tailnet. Sta ```bash PREVIEW=~/.claude/skills/tailnet-preview/scripts/preview -HOST=$(tailscale status --self --json | jq -r .Self.DNSName | sed 's/\.$//') +HOST=$(tailscale status --self --json | jq -r '.Self.TailscaleIPs[0]') cd apps/mobile $PREVIEW run 9230 "debatelab mobile expo-go" -- env REACT_NATIVE_PACKAGER_HOSTNAME=$HOST CI=1 npx expo start --port 9230 ``` -`REACT_NATIVE_PACKAGER_HOSTNAME` makes Metro put the tailnet name in the manifest, so Expo Go fetches the bundle from `http://:9230`. The firewall accepts all traffic on `tailscale0`, so the phone reaches that port directly. In Expo Go, enter `exp://:9230` as the URL, or open that link in Safari. `CI=1` stops Expo waiting for keyboard input in the background. To check the server without a phone: +`REACT_NATIVE_PACKAGER_HOSTNAME` makes Metro put the VPS's tailnet IP in the manifest, so Expo Go fetches the bundle from `http://:9230`. iOS allows plain HTTP to IP addresses on local networks, and the firewall accepts all traffic on `tailscale0`, so the phone reaches that port directly. In Expo Go, enter `exp://:9230` as the URL, or open that link in Safari. `CI=1` stops Expo waiting for keyboard input in the background. To check the server without a phone: ```bash curl -s -H 'expo-platform: ios' http://127.0.0.1:9230/ | jq -r .launchAsset.url ``` -The URL must start with `http://:9230/`. +The URL must start with `http://:9230/`. -If iOS refuses the plain-HTTP bundle URL because of App Transport Security, there are two fallbacks. Set `REACT_NATIVE_PACKAGER_HOSTNAME` to the tailnet IP (`tailscale status --self --json | jq -r '.Self.TailscaleIPs[0]'`), since iOS allows plain HTTP to IP addresses on local networks. Or use the HTTPS URL that `run` publishes (`https://:91xx`) by also setting `EXPO_PACKAGER_PROXY_URL=https://:91xx`, so the manifest points at the HTTPS proxy. +Do not use the MagicDNS name (`codex-remote..ts.net`) as the hostname. The VPS cannot resolve its own MagicDNS name, and Metro's CLI looks the hostname up while it runs: it crashes with `getaddrinfo ENOTFOUND` a few seconds after the phone connects. If you need HTTPS, use the URL that `run` publishes (`https://:91xx`) by also setting `EXPO_PACKAGER_PROXY_URL=https://:91xx`, so the manifest points at the HTTPS proxy. + +To sign the CLI in to an Expo account, run `npx expo login` from `apps/mobile`, where the Expo CLI is installed. Its `--browser` and `--sso` options hand the login back to a callback on `localhost` on the VPS, so a login opened in a laptop's browser cannot finish. Use the username and password prompt (`-u `, then the password on stdin with `-p -`). Stop it with `$PREVIEW down "debatelab mobile expo-go"` when you are done. From 3ac60bb8009ef0ec9b586dbccaf753f3ab0cd012 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 14:31:50 +0000 Subject: [PATCH 7/7] Show one-off mode on Prepare, Generate and Attack, and keep Prepare's list on refresh The phone-test fixes put OneOffNotice on the Debate tab, Ask and the Library. The merged Prepare tab and the Generate and Attack forms had their own one-off text with no way back. They now show the same notice, with "Return to debate" when there is a Current Debate, as the website's CurrentDebateStrip does. Prepare keeps listing the Current Debate's saved results in one-off mode, like the Debate tab and the website's home. Prepare rereads its saved results on every focus. An unchanged list now keeps its identity, the way the debate list does, through a shared keepIfSame. The screenshot run turns one-off mode on, shoots Prepare and Generate, and checks that Return turns it off again. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 2 +- apps/mobile/scripts/screenshots.ts | 39 ++++++++++++++++++- .../src/app/(tabs)/(prepare)/prepare.tsx | 13 +++++-- apps/mobile/src/components/tool-form.tsx | 13 +++---- apps/mobile/src/lib/debate-context.tsx | 7 +--- apps/mobile/src/lib/keep-if-same.ts | 4 ++ 6 files changed, 58 insertions(+), 20 deletions(-) create mode 100644 apps/mobile/src/lib/keep-if-same.ts diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 33222eee7..72e5f9ba0 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -23,7 +23,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp - `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. -- 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, Ask and Library. 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. +- 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, Generate and Attack (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. - `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. diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index ce040202f..c206daf5b 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -22,8 +22,9 @@ * the System theme; nothing is written. The Debate tab shows the account's * real Current Debate, and its empty state comes from answering the * current_debates read with no row. The Debate tab is shot once per side view, - * and with one-off mode on, with and without a Current Debate. One-off mode is - * in memory only; the script toggles it and never taps a debate row. + * and with one-off mode on, with and without a Current Debate; Prepare and + * Generate are shot in one-off mode too. One-off mode is in memory only; the + * script toggles it and never taps a debate row. * * Ask screenshots never call a model and never save a chat: the page answers * ai-stream from a fake stream inside the page (expo/fetch uses the page's @@ -458,6 +459,34 @@ async function shootAskScreens(context: BrowserContext, prefix: string, language await streaming.close(); } +/** Prepare and Generate in one-off mode, reached in the app so the in-memory mode survives. Nothing is sent or saved. */ +async function shootOneOffTools(page: Page, prefix: string) { + await page.goto(`${ORIGIN}/`); + // Opening the switcher takes the tab out of focus, which drops a load still running; let it finish first. + await page.getByTestId('current-debate-motion').waitFor({ timeout: 30_000 }); + await page.waitForFunction(() => !document.querySelector('[data-testid="debate-counts"]')?.textContent?.includes('–')); + await settle(page); + await page.getByTestId('current-debate-pill').click(); + await page.getByTestId('debates-sheet').waitFor(); + 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`); + await page.getByTestId('prepare-tool-generate').click(); + await page.getByTestId('generate-motion').waitFor({ timeout: 30_000 }); + const notice = page.getByTestId('generate-screen').getByTestId('one-off-notice'); + await notice.waitFor(); + await shoot(page, `${prefix}-generate-oneoff`); + // Return to debate turns the mode off in place, and the strip says where work goes again. + await notice.getByRole('button').click(); + await page.getByTestId('generate-screen').getByTestId('working-in').waitFor(); +} + async function shootEmptyDebate(page: Page, prefix: string) { await page.route(`${REST}/current_debates*`, (route) => route.fulfill({ status: 200, contentType: 'application/json', json: [] }), @@ -615,6 +644,12 @@ async function main() { shots += 3; await shootResultScreens(signedIn, prefix, language, problems); shots += 9; + const oneOffPage = await signedIn.newPage(); + watchErrors(oneOffPage, problems, `${prefix} one-off tools`); + await pinSettings(oneOffPage, language); + await shootOneOffTools(oneOffPage, prefix); + await oneOffPage.close(); + shots += 2; await signedIn.close(); } } diff --git a/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx b/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx index b3da5f601..5885cfe17 100644 --- a/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx +++ b/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx @@ -6,6 +6,7 @@ import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; import { TextButton } from '@/components/buttons'; import { Card, IconTile } from '@/components/card'; import { Icon } from '@/components/icon'; +import { OneOffNotice } from '@/components/one-off-notice'; import { PREPARE_TOOLS, type PrepareTool } from '@/components/prepare-tools'; import { Screen } from '@/components/screen'; import { ScreenTitle } from '@/components/screen-title'; @@ -14,6 +15,7 @@ import { WorkRow, workHref } from '@/components/work-row'; import { useAuth } from '@/lib/auth'; import { useDebates } from '@/lib/debate-context'; import { fetchPrepareWork, type DebateItem } from '@/lib/debates'; +import { keepIfSame } from '@/lib/keep-if-same'; import { useTheme } from '@/theme/use-theme'; const SAVED_LIMIT = 30; @@ -25,7 +27,7 @@ export default function PrepareScreen() { const { colors } = useTheme(); const { session } = useAuth(); const userId = session?.user.id ?? null; - const { current, oneOff } = useDebates(); + const { current } = useDebates(); const debateId = current?.id ?? null; // Each load belongs to the debate it was read for, so another debate's results never show, or open, under this one. const [loaded, setLoaded] = useState<{ debateId: string; items: DebateItem[] | null; failed: boolean } | null>(null); @@ -45,7 +47,8 @@ export default function PrepareScreen() { try { const next = await fetchPrepareWork(userId, forDebate, SAVED_LIMIT); if (!isCurrent()) return; - setLoaded({ debateId: forDebate, items: next, failed: false }); + // Every focus rereads the list; an unchanged one keeps its identity, so the rows do not re-render. + setLoaded((previous) => keepIfSame(previous, { debateId: forDebate, items: next, failed: false })); } catch (cause) { if (!isCurrent()) return; console.warn('Could not load saved results:', cause instanceof Error ? cause.message : cause); @@ -66,6 +69,7 @@ export default function PrepareScreen() { return ( + {PREPARE_TOOLS.map((tool, index) => ( {t('mobile.prepare.saved')} - {oneOff || !debateId ? ( + {/* The Current Debate's results stay listed in one-off mode, as on the Debate tab and the website's home. */} + {!debateId ? ( - {oneOff ? t('debate.oneOffStrip') : t('debate.noCurrentStrip')} + {t('debate.noCurrentStrip')} ) : failed ? ( diff --git a/apps/mobile/src/components/tool-form.tsx b/apps/mobile/src/components/tool-form.tsx index 9dce46cda..e100c4eab 100644 --- a/apps/mobile/src/components/tool-form.tsx +++ b/apps/mobile/src/components/tool-form.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { ActivityIndicator, StyleSheet, View } from 'react-native'; import { TextButton } from './buttons'; +import { OneOffNotice } from './one-off-notice'; import { Text } from './text'; import type { AIErrorKind } from '@/lib/ai-request'; @@ -25,18 +26,15 @@ export function ToolTitle({ title, subtitle }: { title: string; subtitle: string ); } -/** Where new work will be saved, as the website's CurrentDebateStrip says it. */ +/** Where new work will be saved, as the website's CurrentDebateStrip says it. In one-off mode it offers the way back to the Current Debate. */ export function WorkingIn() { const { t } = useTranslation(); const { colors } = useTheme(); const { current, oneOff } = useDebates(); + if (oneOff) return ; return ( - - {oneOff ? ( - - {t('debate.oneOffStrip')} - - ) : current ? ( + + {current ? ( {`${t('debate.workingIn')} `} @@ -108,7 +106,6 @@ const styles = StyleSheet.create({ title: { fontSize: 30, lineHeight: 36, letterSpacing: -0.8 }, subtitle: { fontSize: 16, lineHeight: 22 }, strip: { borderWidth: 1, borderRadius: radius.field, paddingHorizontal: 14, paddingVertical: 10 }, - dashed: { borderStyle: 'dashed' }, stripText: { fontSize: 14.5, lineHeight: 20 }, error: { borderWidth: 1, borderRadius: radius.card, padding: 14, gap: 4 }, errorTitle: { fontSize: 16 }, diff --git a/apps/mobile/src/lib/debate-context.tsx b/apps/mobile/src/lib/debate-context.tsx index 2cbc13234..40b013f72 100644 --- a/apps/mobile/src/lib/debate-context.tsx +++ b/apps/mobile/src/lib/debate-context.tsx @@ -10,6 +10,7 @@ import { type Debate, type DebateSettings, } from './debates'; +import { keepIfSame } from './keep-if-same'; type Status = 'loading' | 'ready' | 'error'; @@ -29,11 +30,6 @@ type DebateContextValue = { const DebateContext = createContext(null); -/** Refreshes run on every Debate tab focus; an unchanged list keeps its identity so the tabs do not re-render. */ -function keepIfSame(previous: Debate[], next: Debate[]): Debate[] { - return JSON.stringify(previous) === JSON.stringify(next) ? previous : next; -} - export function DebateProvider({ children }: { children: ReactNode }) { const { session } = useAuth(); const userId = session?.user.id ?? null; @@ -59,6 +55,7 @@ export function DebateProvider({ children }: { children: ReactNode }) { try { const next = await fetchDebates(forUser); if (owner.current !== forUser || refreshes.current !== request || writes.current !== writesBefore) return; + // Refreshes run on every Debate tab focus; an unchanged list keeps its identity so the tabs do not re-render. setDebates((previous) => keepIfSame(previous, next.debates)); setCurrentId(next.currentId); setStatus('ready'); diff --git a/apps/mobile/src/lib/keep-if-same.ts b/apps/mobile/src/lib/keep-if-same.ts new file mode 100644 index 000000000..1c14e942e --- /dev/null +++ b/apps/mobile/src/lib/keep-if-same.ts @@ -0,0 +1,4 @@ +/** Focus refreshes usually reread unchanged data; returning the previous value keeps its identity, so nothing re-renders. */ +export function keepIfSame(previous: T, next: T): T { + return JSON.stringify(previous) === JSON.stringify(next) ? previous : next; +}