From 59def2514a48b557ccde9394b92f0fec75a5ac0c Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 13:09:53 +0000 Subject: [PATCH 01/12] Shoot iPad portrait, landscape and a 600-point window, and filter shots by screen ONLY takes several combinations and SCREENS picks groups of screens, so a layout change shoots only what it touched. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/scripts/screenshots.ts | 139 ++++++++++++++++++----------- 1 file changed, 86 insertions(+), 53 deletions(-) diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index fe86734ea..539cc12db 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -7,7 +7,14 @@ * npm run -w @debatelab/mobile export:web * 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=library,ask limits it to those groups of screens: sign-in, debate, + * library, tabs (Prepare, Practice, You), ask, results (Generate and Attack), + * tools (Brainstorm and Factoids), timer, crossfire, sparring, flowchart. + * + * 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 @@ -73,14 +80,18 @@ 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()); +const SCREENS = process.env.SCREENS?.split(',').map((value) => value.trim()).filter(Boolean) ?? null; +const wants = (group: string) => !SCREENS || SCREENS.includes(group); const ORIGIN = `http://127.0.0.1:${PORT}`; const STAGING_HOST = new URL(SUPABASE_PROJECTS.staging.url).host; 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; @@ -1139,44 +1150,50 @@ async function main() { let shots = 0; try { const session = DEMO_CODE ? await signIn(browser, problems) : null; - if (session) await checkOfflineColdStart(browser, session, problems); + if (session && wants('sign-in')) await checkOfflineColdStart(browser, session, problems); else console.log('STAGING_DEMO_CODE is not set: signed-in screens skipped'); 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; - const signedOut = await browser.newContext({ ...options, colorScheme: scheme, locale }); - const page = await signedOut.newPage(); - watchErrors(page, problems, prefix); - await page.goto(`${ORIGIN}/sign-in`); - await page.getByRole('tablist').waitFor(); - await shoot(page, `${prefix}-sign-in`); - await page.getByRole('button').first().click(); - await page.getByRole('alert').waitFor(); - await shoot(page, `${prefix}-sign-in-error`); - await page.getByRole('tab').nth(1).click(); - await shoot(page, `${prefix}-sign-in-demo`); - shots += 3; - await signedOut.close(); + if (!ONLY.some((only) => prefix.startsWith(only))) continue; + if (wants('sign-in')) { + const signedOut = await browser.newContext({ ...options, colorScheme: scheme, locale }); + const page = await signedOut.newPage(); + watchErrors(page, problems, prefix); + await page.goto(`${ORIGIN}/sign-in`); + await page.getByRole('tablist').waitFor(); + await shoot(page, `${prefix}-sign-in`); + await page.getByRole('button').first().click(); + await page.getByRole('alert').waitFor(); + await shoot(page, `${prefix}-sign-in-error`); + await page.getByRole('tab').nth(1).click(); + await shoot(page, `${prefix}-sign-in-demo`); + shots += 3; + await signedOut.close(); + } if (!session) continue; const signedIn = await browser.newContext({ ...options, colorScheme: scheme, locale, storageState: session }); const tabPage = await signedIn.newPage(); watchErrors(tabPage, problems, `${prefix} signed in`); await pinSettings(tabPage, language); - await shootDebateScreens(tabPage, prefix).catch(async (error: unknown) => { - await shoot(tabPage, `${prefix}-failed`); - throw error; - }); - shots += 6; - await shootLibraryScreens(tabPage, prefix).catch(async (error: unknown) => { - await shoot(tabPage, `${prefix}-failed`); - throw error; - }); - shots += 5; - for (const [name, route] of Object.entries(SIGNED_IN_ROUTES)) { + if (wants('debate')) { + await shootDebateScreens(tabPage, prefix).catch(async (error: unknown) => { + await shoot(tabPage, `${prefix}-failed`); + throw error; + }); + shots += 6; + } + if (wants('library')) { + await shootLibraryScreens(tabPage, prefix).catch(async (error: unknown) => { + await shoot(tabPage, `${prefix}-failed`); + throw error; + }); + shots += 5; + } + for (const [name, route] of wants('tabs') ? Object.entries(SIGNED_IN_ROUTES) : []) { await tabPage.goto(`${ORIGIN}${route}`); await tabPage.getByRole('tablist').first().waitFor(); if (name === 'you') await tabPage.getByTestId('subscription-tier').waitFor({ timeout: 30_000 }); @@ -1184,33 +1201,49 @@ async function main() { await shoot(tabPage, `${prefix}-${name}`); shots += 1; } - const emptyPage = await signedIn.newPage(); - watchErrors(emptyPage, problems, `${prefix} empty`); - await pinSettings(emptyPage, language); - await shootEmptyDebate(emptyPage, prefix); - shots += 2; - await shootAskScreens(signedIn, prefix, language, problems); - shots += 3; - await shootResultScreens(signedIn, prefix, language, problems); - shots += 9; - for (const tool of ['brainstorm', 'factoids'] as const) { + if (wants('debate')) { + const emptyPage = await signedIn.newPage(); + watchErrors(emptyPage, problems, `${prefix} empty`); + await pinSettings(emptyPage, language); + await shootEmptyDebate(emptyPage, prefix); + shots += 2; + } + if (wants('ask')) { + await shootAskScreens(signedIn, prefix, language, problems); + shots += 3; + } + if (wants('results')) { + await shootResultScreens(signedIn, prefix, language, problems); + shots += 9; + } + for (const tool of wants('tools') ? (['brainstorm', 'factoids'] as const) : []) { await shootTool(signedIn, prefix, language, problems, tool); shots += 6; } - await shootTimerScreens(signedIn, prefix, language, problems); - shots += 3; - await shootCrossfireScreens(signedIn, prefix, language, problems); - shots += 3; - await shootSparringScreens(signedIn, prefix, language, problems); - shots += 6; - await shootFlowchart(signedIn, prefix, language, problems); - shots += 10; - const oneOffPage = await signedIn.newPage(); - watchErrors(oneOffPage, problems, `${prefix} one-off tools`); - await pinSettings(oneOffPage, language); - await shootOneOffTools(oneOffPage, prefix); - await oneOffPage.close(); - shots += 2; + if (wants('timer')) { + await shootTimerScreens(signedIn, prefix, language, problems); + shots += 3; + } + if (wants('crossfire')) { + await shootCrossfireScreens(signedIn, prefix, language, problems); + shots += 3; + } + if (wants('sparring')) { + await shootSparringScreens(signedIn, prefix, language, problems); + shots += 6; + } + if (wants('flowchart')) { + await shootFlowchart(signedIn, prefix, language, problems); + shots += 10; + } + if (wants('tools')) { + 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(); } } From 5b2dbee03a3f74016638a80c766e7b97be193008 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 13:33:22 +0000 Subject: [PATCH 02/12] Add the iPad layout strings in English, Spanish and Hungarian Co-Authored-By: Claude Opus 5.5 --- src/locales/en.json | 9 +++++++-- src/locales/es.json | 9 +++++++-- src/locales/hu.json | 9 +++++++-- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index 0446df86f..84eb0e3f8 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2422,7 +2422,8 @@ "pickUp": "Pick up where you left off", "itemMeta": "{{kind}}, {{when}}", "loadFailed": "Could not load your debate.", - "viewAria": "Show preparation for" + "viewAria": "Show preparation for", + "recentSources": "Recent sources" }, "common": { "done": "Done" @@ -2471,7 +2472,8 @@ "deleteFailed": "Could not delete the source. Try again.", "saveFailed": "Could not save the source. Try again.", "sourceMissing": "This source is no longer in your Library.", - "openPage": "Opens the page in the browser" + "openPage": "Opens the page in the browser", + "pickSource": "Pick a source to read it here." }, "ask": { "placeholder": "Ask your coach", @@ -2629,6 +2631,9 @@ "discardTitle": "Start a new map?", "discardDesc": "Your unsaved map will be replaced by the starter map.", "startNew": "Start new" + }, + "document": { + "onThisPage": "On this page" } } } diff --git a/src/locales/es.json b/src/locales/es.json index 122d92072..0713fa923 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2422,7 +2422,8 @@ "pickUp": "Continúa donde lo dejaste", "itemMeta": "{{kind}}, {{when}}", "loadFailed": "No se pudo cargar tu debate.", - "viewAria": "Mostrar la preparación de" + "viewAria": "Mostrar la preparación de", + "recentSources": "Fuentes recientes" }, "common": { "done": "Listo" @@ -2471,7 +2472,8 @@ "deleteFailed": "No se pudo eliminar la fuente. Inténtalo de nuevo.", "saveFailed": "No se pudo guardar la fuente. Inténtalo de nuevo.", "sourceMissing": "Esta fuente ya no está en tu biblioteca.", - "openPage": "Abre la página en el navegador" + "openPage": "Abre la página en el navegador", + "pickSource": "Elige una fuente para leerla aquí." }, "ask": { "placeholder": "Pregunta a tu coach", @@ -2629,6 +2631,9 @@ "discardTitle": "¿Empezar un mapa nuevo?", "discardDesc": "Tu mapa sin guardar se sustituirá por el mapa inicial.", "startNew": "Empezar nuevo" + }, + "document": { + "onThisPage": "En esta página" } } } diff --git a/src/locales/hu.json b/src/locales/hu.json index 012f7814f..f0bd46275 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2422,7 +2422,8 @@ "pickUp": "Folytasd, ahol abbahagytad", "itemMeta": "{{kind}}, {{when}}", "loadFailed": "Nem sikerült betölteni a vitát.", - "viewAria": "Felkészülés megjelenítése" + "viewAria": "Felkészülés megjelenítése", + "recentSources": "Legutóbbi források" }, "common": { "done": "Kész" @@ -2471,7 +2472,8 @@ "deleteFailed": "Nem sikerült törölni a forrást. Próbáld újra.", "saveFailed": "Nem sikerült menteni a forrást. Próbáld újra.", "sourceMissing": "Ez a forrás már nincs a könyvtáradban.", - "openPage": "Megnyitja az oldalt a böngészőben" + "openPage": "Megnyitja az oldalt a böngészőben", + "pickSource": "Válassz egy forrást, hogy itt elolvasd." }, "ask": { "placeholder": "Kérdezd az edződet", @@ -2629,6 +2631,9 @@ "discardTitle": "Új térképet kezdesz?", "discardDesc": "A mentetlen térképed helyére a kezdő térkép kerül.", "startNew": "Új kezdése" + }, + "document": { + "onThisPage": "Ezen az oldalon" } } } From 87091bc25bb67d174650add1aa25e177d20c6694 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 13:33:22 +0000 Subject: [PATCH 03/12] Add useLayoutClass and the iPad sidebar One hook turns the window width into phone, regular (700 to 1100 points) or wide (1100 and up); iPhones are always phone. NativeTabs becomes the iPadOS 18 sidebar with sidebarAdaptable, and the web preview draws the sidebar when wide and a top tab bar in between. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/components/app-tabs.tsx | 2 + apps/mobile/src/components/app-tabs.web.tsx | 90 ++++++++++++++++++++- apps/mobile/src/lib/layout.ts | 62 ++++++++++++++ 3 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/src/lib/layout.ts diff --git a/apps/mobile/src/components/app-tabs.tsx b/apps/mobile/src/components/app-tabs.tsx index 6da3c8225..d0949ad20 100644 --- a/apps/mobile/src/components/app-tabs.tsx +++ b/apps/mobile/src/components/app-tabs.tsx @@ -10,7 +10,9 @@ export default function AppTabs() { const { t } = useTranslation(); const { colors } = useTheme(); return ( + // On iPad (iPadOS 18+) the tabs become the system sidebar; iPhone keeps the tab bar. + + + {t('home.title')} + + {TABS.map((tab) => ( + + + + ))} + + + + ); + } + + if (layout === 'regular') { + return ( + + {/* Tabs reads its triggers from a TabList that is its own child, so the bar is the TabList itself. */} + + {TABS.map((tab) => ( + + + + ))} + + + + ); + } + return ( @@ -54,10 +97,55 @@ const TabButton = forwardRef(function SidebarButton( + { icon, label, isFocused, ...props }, + ref, +) { + const { colors } = useTheme(); + const color = isFocused ? colors.onPrimary : colors.foreground; + return ( + + + + {label} + + + ); +}); + +const TopTabButton = forwardRef(function TopTabButton({ label, isFocused, ...props }, ref) { + const { colors } = useTheme(); + return ( + + + {label} + + + ); +}); + const styles = StyleSheet.create({ root: { flex: 1 }, + row: { flexDirection: 'row' }, slot: { flex: 1 }, bar: { flexDirection: 'row', justifyContent: 'space-around', paddingTop: 8, borderTopWidth: StyleSheet.hairlineWidth }, tab: { flex: 1, alignItems: 'center', gap: 3, minHeight: 44 }, label: { fontSize: 10.5 }, + sidebar: { width: SIDEBAR_WIDTH, flexDirection: 'column', justifyContent: 'flex-start', gap: 4, paddingHorizontal: 12, borderRightWidth: StyleSheet.hairlineWidth }, + sidebarTitle: { fontSize: 22, letterSpacing: -0.4, marginLeft: 8, marginBottom: 14 }, + sidebarRow: { flexDirection: 'row', alignItems: 'center', gap: 12, borderRadius: 10, paddingHorizontal: 12, minHeight: 40 }, + sidebarLabel: { fontSize: 16.5 }, + topTabs: { alignSelf: 'center', marginBottom: 6, flexDirection: 'row', justifyContent: 'center', gap: 2, borderWidth: StyleSheet.hairlineWidth, borderRadius: 999, padding: 4 }, + topTab: { borderRadius: 999, paddingHorizontal: 16, minHeight: 36, justifyContent: 'center' }, + topLabel: { fontSize: 15 }, }); diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts new file mode 100644 index 000000000..c4c7007f8 --- /dev/null +++ b/apps/mobile/src/lib/layout.ts @@ -0,0 +1,62 @@ +import { Platform, useWindowDimensions } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +/** + * The iPad layouts (plans: iPad mock A, with mock C's docked claim inspector). + * Every width the layouts depend on lives here, so screens ask for a layout + * class instead of checking widths themselves. + * + * - phone: iPhones at any width, and iPad windows under 700 points (Split View, + * Slide Over, small Stage Manager windows). Today's phone layout, unchanged. + * - regular: iPad windows from 700 to 1100 points (portrait, two-thirds Split View). + * - wide: iPad windows from 1100 points (landscape full screen). + */ +export type LayoutClass = 'phone' | 'regular' | 'wide'; + +export const BREAKPOINTS = { regular: 700, wide: 1100 } as const; + +/** A docked pane beside the main content, such as the Flowchart's claim inspector. */ +export const PANE_WIDTH = { regular: 360, wide: 400 } as const; + +/** The list beside a detail pane: the Library's sources and Ask's conversations. */ +export const LIST_WIDTH = { library: 340, libraryRoomy: 380, ask: 320 } as const; +/** The Library list widens once the window has this much room. */ +export const LIBRARY_ROOMY_WIDTH = 960; + +/** The sidebar the web preview draws in the wide layout; iPadOS draws its own. */ +export const SIDEBAR_WIDTH = 300; + +/** The Debate home splits into two columns from this content width. */ +export const HOME_COLUMNS_WIDTH = 760; +export const HOME_MAX_WIDTH = 1120; + +/** Documents show the outline rail from this window width. */ +export const RAIL_MIN_WIDTH = 900; +export const RAIL_WIDTH = 250; + +export function layoutClassFor(width: number, isPad: boolean): LayoutClass { + // An iPhone in landscape can be wider than 700 points; it keeps the phone layout. + if (Platform.OS === 'ios' && !isPad) return 'phone'; + if (width >= BREAKPOINTS.wide) return 'wide'; + if (width >= BREAKPOINTS.regular) return 'regular'; + return 'phone'; +} + +/** The layout class for the current window; it changes with rotation and Split View. */ +export function useLayoutClass(): LayoutClass { + const { width } = useWindowDimensions(); + return layoutClassFor(width, Platform.OS === 'ios' && Platform.isPad); +} + +/** The height of iOS's navigation bar under the status bar. */ +const NAV_BAR_HEIGHT = 52; + +/** + * How much of a screen's top a translucent native header covers, for docked + * panes that sit beside a scroll view instead of in it. The web preview draws + * its bar above the content, so nothing is covered there. + */ +export function useHeaderCover(): number { + const insets = useSafeAreaInsets(); + return Platform.OS === 'ios' ? insets.top + NAV_BAR_HEIGHT : 0; +} From 13efa667dfa7bce4ea99b36bf89e7429c48250c9 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 13:33:22 +0000 Subject: [PATCH 04/12] Open a Library source beside the list on iPad The source sheet's body becomes SourceView, which the sheet and the iPad detail pane share. The selection belongs to the account and debate it was made in, and deleting the open source clears it. Co-Authored-By: Claude Opus 5.5 --- .../src/app/(tabs)/(library)/library.tsx | 113 +++++++++++++++- apps/mobile/src/app/source/[id].tsx | 110 +--------------- apps/mobile/src/components/source-view.tsx | 122 ++++++++++++++++++ 3 files changed, 232 insertions(+), 113 deletions(-) create mode 100644 apps/mobile/src/components/source-view.tsx diff --git a/apps/mobile/src/app/(tabs)/(library)/library.tsx b/apps/mobile/src/app/(tabs)/(library)/library.tsx index 43a74edba..faa7ac53f 100644 --- a/apps/mobile/src/app/(tabs)/(library)/library.tsx +++ b/apps/mobile/src/app/(tabs)/(library)/library.tsx @@ -1,7 +1,7 @@ import { useFocusEffect, useRouter } from 'expo-router'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { ActivityIndicator, Pressable, ScrollView, StyleSheet, View, useWindowDimensions } from 'react-native'; import ReanimatedSwipeable, { type SwipeableMethods } from 'react-native-gesture-handler/ReanimatedSwipeable'; import { TextButton } from '@/components/buttons'; @@ -13,10 +13,12 @@ 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'; +import { SourceView } from '@/components/source-view'; import { Text } from '@/components/text'; import { useAuth } from '@/lib/auth'; import { confirmDestructive } from '@/lib/confirm'; import { useDebates } from '@/lib/debate-context'; +import { LIBRARY_ROOMY_WIDTH, LIST_WIDTH, SIDEBAR_WIDTH, useLayoutClass } from '@/lib/layout'; import { fetchSources, removeSource, subscribeSourcesChanged, type Source, type SourceType } from '@/lib/sources'; import { useTheme } from '@/theme/use-theme'; @@ -36,15 +38,21 @@ function matches(source: Source, query: string): boolean { return !needle || [source.title, source.content, source.origin ?? ''].some((value) => value.toLocaleLowerCase().includes(needle)); } -/** The Current Debate's sources, as in mock 04. It shows the Current Debate even in one-off mode, like the website. */ +/** + * The Current Debate's sources, as in mock 04. It shows the Current Debate even in one-off mode, like the website. + * On iPad the list and the open source sit side by side (iPad mock A). + */ export default function LibraryScreen() { const { t } = useTranslation(); const router = useRouter(); const { status, current } = useDebates(); const debateId = current?.id ?? null; + const layout = useLayoutClass(); const onAdd = (kind: AddSourceKind) => router.push(kind === 'link' ? '/add-link' : '/new-note'); + if (layout !== 'phone') return ; + return ( @@ -64,7 +72,77 @@ export default function LibraryScreen() { ); } -function SourceList({ debateId }: { debateId: string }) { +/** The iPad Library: the list on the left, the source it opened on the right, in place of the source sheet. */ +function LibrarySplit({ wide, onAdd }: { wide: boolean; onAdd: (kind: AddSourceKind) => void }) { + const { t } = useTranslation(); + const router = useRouter(); + const { colors } = useTheme(); + const { width } = useWindowDimensions(); + const { session } = useAuth(); + const { status, current } = useDebates(); + const userId = session?.user.id ?? null; + const debateId = current?.id ?? null; + // The selection belongs to the account and debate it was made in; switching either clears it. + const [selected, setSelected] = useState<{ userId: string; debateId: string; id: string } | null>(null); + const shown = selected && selected.userId === userId && selected.debateId === debateId ? selected.id : null; + const listWidth = width - (wide ? SIDEBAR_WIDTH : 0) >= LIBRARY_ROOMY_WIDTH ? LIST_WIDTH.libraryRoomy : LIST_WIDTH.library; + + return ( + + + + + {status === 'loading' ? : null} + {status !== 'loading' && !debateId ? ( + + + {t('debate.libraryNeedsDebate')} + + router.push('/debates')} /> + + ) : null} + {debateId && userId ? ( + setSelected({ userId, debateId, id: source.id })} + onRemoved={(id) => setSelected((previous) => (previous?.id === id ? null : previous))} + /> + ) : null} + + + {shown && userId ? ( + + ) : ( + + + + {t('mobile.library.pickSource')} + + + )} + + + ); +} + +function SourceList({ + debateId, + selectedId, + onOpen, + onRemoved, +}: { + debateId: string; + /** The source open beside the list on iPad. */ + selectedId?: string | null; + /** Opens a source beside the list instead of in the source sheet. */ + onOpen?: (source: Source) => void; + onRemoved?: (id: string) => void; +}) { const { t } = useTranslation(); const router = useRouter(); const { session } = useAuth(); @@ -118,6 +196,7 @@ function SourceList({ debateId }: { debateId: string }) { try { await removeSource(userId, source.id); setSources((previous) => previous?.filter((item) => item.id !== source.id) ?? previous); + onRemoved?.(source.id); return true; } catch (cause) { console.warn('Could not delete the source:', cause instanceof Error ? cause.message : cause); @@ -184,7 +263,8 @@ function SourceList({ debateId }: { debateId: string }) { key={source.id} source={source} first={index === 0} - onOpen={() => router.push({ pathname: '/source/[id]', params: { id: source.id } })} + selected={selectedId === source.id} + onOpen={() => (onOpen ? onOpen(source) : router.push({ pathname: '/source/[id]', params: { id: source.id } }))} onDelete={() => remove(source)} /> ))} @@ -195,7 +275,19 @@ function SourceList({ debateId }: { debateId: string }) { } /** A source row; swipe left to delete, with a confirmation, as on iOS. */ -function SourceRow({ source, first, onOpen, onDelete }: { source: Source; first: boolean; onOpen: () => void; onDelete: () => Promise }) { +function SourceRow({ + source, + first, + selected = false, + onOpen, + onDelete, +}: { + source: Source; + first: boolean; + selected?: boolean; + onOpen: () => void; + onDelete: () => Promise; +}) { const { t } = useTranslation(); const { colors } = useTheme(); const swipeable = useRef(null); @@ -226,16 +318,17 @@ function SourceRow({ source, first, onOpen, onDelete }: { source: Source; first: )}> { if (event.nativeEvent.actionName === 'delete') void remove(); }} onPress={onOpen} testID="source-row" - style={({ pressed }) => [styles.row, { backgroundColor: colors.card }, pressed && styles.pressed]}> + style={({ pressed }) => [styles.row, { backgroundColor: selected ? colors.tile : colors.card }, pressed && styles.pressed]}> - + {source.title || t('libraryPage.untitledSource')} @@ -261,4 +354,10 @@ const styles = StyleSheet.create({ pressed: { opacity: 0.7 }, deleteAction: { width: 92, alignItems: 'center', justifyContent: 'center', gap: 3 }, deleteLabel: { color: '#ffffff', fontSize: 13 }, + split: { flex: 1, flexDirection: 'row' }, + listPane: { flexGrow: 0, flexShrink: 0, borderRightWidth: StyleSheet.hairlineWidth }, + listContent: { paddingHorizontal: 20, paddingBottom: 32, gap: 16 }, + detailPane: { flex: 1 }, + detailContent: { width: '100%', maxWidth: 700, alignSelf: 'center', paddingHorizontal: 36, paddingTop: 14, paddingBottom: 48, gap: 14 }, + pick: { alignItems: 'center', gap: 10, paddingTop: 120 }, }); diff --git a/apps/mobile/src/app/source/[id].tsx b/apps/mobile/src/app/source/[id].tsx index c15de2591..8f4f63e82 100644 --- a/apps/mobile/src/app/source/[id].tsx +++ b/apps/mobile/src/app/source/[id].tsx @@ -1,122 +1,20 @@ 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, StyleSheet, View } from 'react-native'; +import { StyleSheet } 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'; -import { fetchSource, type Source } from '@/lib/sources'; -import { useTheme } from '@/theme/use-theme'; +import { SourceView } from '@/components/source-view'; -function webUrl(origin: string | null): string | null { - return origin && /^https?:\/\//i.test(origin) ? origin : null; -} - -/** A source, read-only: its title, where it came from and its text. */ +/** A source, read-only, in a form sheet. The iPad Library shows the same view beside its list. */ export default function SourceSheet() { - const { t } = useTranslation(); const router = useRouter(); - const { colors } = useTheme(); const { id } = useLocalSearchParams<{ id: string }>(); - const { session } = useAuth(); - const userId = session?.user.id ?? null; - const [source, setSource] = useState(undefined); - const [failed, setFailed] = useState(false); - - useEffect(() => { - if (!userId) return; - let active = true; - fetchSource(userId, id) - .then((next) => { - if (active) setSource(next); - }) - .catch((cause: unknown) => { - console.warn('Could not load the source:', cause instanceof Error ? cause.message : cause); - if (active) setFailed(true); - }); - return () => { - active = false; - }; - }, [userId, id]); - - const url = webUrl(source?.origin ?? null); - return ( - - - {source ? ( - - - - {sourceMeta(t, source)} - - - ) : null} - - router.back()} /> - - - {failed ? ( - - {t('mobile.library.loadFailed')} - - ) : source === undefined ? ( - - ) : source === null ? ( - - {t('mobile.library.sourceMissing')} - - ) : ( - <> - - {source.title || t('libraryPage.untitledSource')} - - {url ? ( - void WebBrowser.openBrowserAsync(url)} - style={({ pressed }) => [styles.origin, { backgroundColor: colors.card, borderColor: colors.border }, pressed && styles.pressed]}> - - - {url.replace(/^https?:\/\/(www\.)?/i, '')} - - - - ) : source.origin ? ( - - {source.origin} - - ) : null} - - - {source.content} - - - - )} + router.back()} /> ); } const styles = StyleSheet.create({ 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 }, - meta: { fontSize: 14, flexShrink: 1 }, - loading: { marginTop: 32 }, - body: { fontSize: 15, lineHeight: 21 }, - title: { fontSize: 23, lineHeight: 29, letterSpacing: -0.4 }, - origin: { flexDirection: 'row', alignItems: 'center', gap: 9, borderWidth: 1, borderRadius: 12, paddingHorizontal: 13, minHeight: 46 }, - originText: { flex: 1, fontSize: 15 }, - pressed: { opacity: 0.7 }, - text: { fontSize: 16, lineHeight: 24 }, }); diff --git a/apps/mobile/src/components/source-view.tsx b/apps/mobile/src/components/source-view.tsx new file mode 100644 index 000000000..0e1cffea4 --- /dev/null +++ b/apps/mobile/src/components/source-view.tsx @@ -0,0 +1,122 @@ +import * as WebBrowser from 'expo-web-browser'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; + +import { TextButton } from './buttons'; +import { Card, IconTile } from './card'; +import { Icon } from './icon'; +import { SOURCE_ICONS, sourceMeta } from './source-display'; +import { Text } from './text'; + +import { useAuth } from '@/lib/auth'; +import { fetchSource, type Source } from '@/lib/sources'; +import { useTheme } from '@/theme/use-theme'; + +function webUrl(origin: string | null): string | null { + return origin && /^https?:\/\//i.test(origin) ? origin : null; +} + +/** + * A source, read-only: its title, where it came from and its text. The source + * sheet shows it with a Done button; the iPad Library shows it beside the list. + * Mount it with a key per source, so a new selection never shows the last one. + */ +export function SourceView({ id, onDone }: { id: string; onDone?: () => void }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const [source, setSource] = useState(undefined); + const [failed, setFailed] = useState(false); + + useEffect(() => { + if (!userId) return; + let active = true; + fetchSource(userId, id) + .then((next) => { + if (active) setSource(next); + }) + .catch((cause: unknown) => { + console.warn('Could not load the source:', cause instanceof Error ? cause.message : cause); + if (active) setFailed(true); + }); + return () => { + active = false; + }; + }, [userId, id]); + + const url = webUrl(source?.origin ?? null); + + return ( + <> + + + {source ? ( + + + + {sourceMeta(t, source)} + + + ) : null} + + {onDone ? : null} + + + {failed ? ( + + {t('mobile.library.loadFailed')} + + ) : source === undefined ? ( + + ) : source === null ? ( + + {t('mobile.library.sourceMissing')} + + ) : ( + <> + + {source.title || t('libraryPage.untitledSource')} + + {url ? ( + void WebBrowser.openBrowserAsync(url)} + style={({ pressed }) => [styles.origin, { backgroundColor: colors.card, borderColor: colors.border }, pressed && styles.pressed]}> + + + {url.replace(/^https?:\/\/(www\.)?/i, '')} + + + + ) : source.origin ? ( + + {source.origin} + + ) : null} + + + {source.content} + + + + )} + + ); +} + +const styles = StyleSheet.create({ + header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, + headerText: { flex: 1 }, + kind: { flexDirection: 'row', alignItems: 'center', gap: 9 }, + meta: { fontSize: 14, flexShrink: 1 }, + loading: { marginTop: 32 }, + body: { fontSize: 15, lineHeight: 21 }, + title: { fontSize: 23, lineHeight: 29, letterSpacing: -0.4 }, + origin: { flexDirection: 'row', alignItems: 'center', gap: 9, borderWidth: 1, borderRadius: 12, paddingHorizontal: 13, minHeight: 46 }, + originText: { flex: 1, fontSize: 15 }, + pressed: { opacity: 0.7 }, + text: { fontSize: 16, lineHeight: 24 }, +}); From 7c4cd9d432030d30c7719f6717daaae19855e8b8 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 13:33:22 +0000 Subject: [PATCH 05/12] List Ask conversations beside the chat on iPad The saved-chats rows move into ChatRows, shared by the phone's history sheet and the iPad list, which reloads when the open chat is saved and never shows another debate's chats while the next list loads. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/ask.tsx | 87 +++++++--- apps/mobile/src/components/ask-chat-list.tsx | 169 +++++++++++++++++++ apps/mobile/src/components/ask-history.tsx | 52 +----- 3 files changed, 238 insertions(+), 70 deletions(-) create mode 100644 apps/mobile/src/components/ask-chat-list.tsx diff --git a/apps/mobile/src/app/ask.tsx b/apps/mobile/src/app/ask.tsx index 9b9678d0c..3318db8d4 100644 --- a/apps/mobile/src/app/ask.tsx +++ b/apps/mobile/src/app/ask.tsx @@ -6,6 +6,7 @@ import { ActivityIndicator, KeyboardAvoidingView, Platform, Pressable, ScrollVie import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { AskAnswer } from '@/components/ask-answer'; +import { AskChatPane } from '@/components/ask-chat-list'; import { AskHistory } from '@/components/ask-history'; import { TextButton } from '@/components/buttons'; import { Chip } from '@/components/chip'; @@ -20,6 +21,7 @@ import { fetchFeatureConfig, type FeatureConfig } from '@/lib/feature-config'; import { useAuth } from '@/lib/auth'; import { ChatSaveError, fetchChat, saveChat, type ChatMessage, type ChatRecord } from '@/lib/chats'; import { useDebates } from '@/lib/debate-context'; +import { LIST_WIDTH, useLayoutClass } from '@/lib/layout'; import { keepSourceAssessments } from '@/lib/results'; import { useSettings } from '@/lib/settings'; import { fetchSources, type Source } from '@/lib/sources'; @@ -46,9 +48,13 @@ const RETRYABLE_ERRORS = new Set(['network', 'timeout', 'failed', ' let localId = 0; const nextId = () => `local-${Date.now()}-${(localId += 1)}`; -/** Ask, as in mock 07: a coaching chat that streams, grounded in the Current Debate. */ +/** + * Ask, as in mock 07: a coaching chat that streams, grounded in the Current Debate. + * On iPad the debate's conversations stay listed beside the chat (iPad mock A). + */ export default function AskScreen() { const { t } = useTranslation(); + const split = useLayoutClass() !== 'phone'; const router = useRouter(); const { prompt } = useLocalSearchParams<{ prompt?: string }>(); const insets = useSafeAreaInsets(); @@ -320,16 +326,23 @@ export default function AskScreen() { const subtitle = oneOff ? t('debate.oneOffActive') : current?.motion ?? ''; const busy = loading || opening; + // Beside the list, the chat is titled by its first question, as its saved row is. + const question = messages.find((message) => message.role === 'user')?.content; + const title = split ? question ?? t('askChat.newChat') : t('nav.ask'); - return ( - + const chat = ( + <> - router.back()} style={styles.headerButton}> - - + {split ? ( + + ) : ( + router.back()} style={styles.headerButton}> + + + )} - - {t('nav.ask')} + + {title} {subtitle ? ( @@ -337,15 +350,19 @@ export default function AskScreen() { ) : null} - setHistoryOpen(true)} - style={styles.headerButton} - testID="ask-history-button"> - - + {split ? ( + + ) : ( + setHistoryOpen(true)} + style={styles.headerButton} + testID="ask-history-button"> + + + )} + + ); + const newChat = () => { + setHistoryOpen(false); + startConversation([], NEW_CHAT); + }; + + if (split) { + return ( + + + router.back()} + onPick={(chatId) => void openChat(chatId)} + onNewChat={newChat} + /> + {chat} + + + ); + } + + return ( + + {chat} setHistoryOpen(false)} onPick={(chatId) => void openChat(chatId)} - onNewChat={() => { - setHistoryOpen(false); - startConversation([], NEW_CHAT); - }} + onNewChat={newChat} /> ); @@ -482,6 +525,8 @@ function Thinking() { const styles = StyleSheet.create({ root: { flex: 1 }, + split: { flex: 1, flexDirection: 'row' }, + chatPane: { flex: 1 }, header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingBottom: 10, borderBottomWidth: StyleSheet.hairlineWidth }, headerButton: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, headerText: { flex: 1, alignItems: 'center' }, diff --git a/apps/mobile/src/components/ask-chat-list.tsx b/apps/mobile/src/components/ask-chat-list.tsx new file mode 100644 index 000000000..4fc23f296 --- /dev/null +++ b/apps/mobile/src/components/ask-chat-list.tsx @@ -0,0 +1,169 @@ +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 { Card } from './card'; +import { Icon } from './icon'; +import { Text } from './text'; + +import { useAuth } from '@/lib/auth'; +import { fetchChats, type ChatSummary } from '@/lib/chats'; +import { formatDate } from '@/lib/dates'; +import { useTheme } from '@/theme/use-theme'; + +/** Saved chats as rows, with their loading, failed and empty states. */ +export function ChatRows({ + chats, + failed, + openChatId, + onPick, + highlightOpen = false, +}: { + chats: ChatSummary[] | null; + failed: boolean; + openChatId: string | null; + onPick: (chatId: string) => void; + /** The iPad list marks the open chat with a tinted row instead of a check. */ + highlightOpen?: boolean; +}) { + const { t } = useTranslation(); + const { colors } = useTheme(); + if (failed && !chats) { + return ( + + {t('mobile.ask.historyFailed')} + + ); + } + if (chats === null) return ; + if (chats.length === 0) { + return ( + + + {t('askChat.noSavedChats')} + + + {t('askChat.noSavedChatsDesc')} + + + ); + } + return ( + + {chats.map((chat, index) => { + const open = chat.id === openChatId; + return ( + onPick(chat.id)} + testID="ask-chat-row" + style={({ pressed }) => [ + styles.row, + index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, + highlightOpen && open && { backgroundColor: colors.tile }, + pressed && styles.pressed, + ]}> + + + {chat.title} + + + {`${formatDate(new Date(chat.createdAt).toISOString())} · ${t('askChat.messageCount', { count: chat.messageCount })}`} + + + {open && !highlightOpen ? : null} + + ); + })} + + ); +} + +/** + * Ask's conversations beside the chat on iPad (mock A, like Messages): the + * Current Debate's saved chats, or the unsorted ones in one-off mode. A list + * from another account or debate is never shown while the next one loads. + */ +export function AskChatPane({ + debateId, + openChatId, + reloadKey, + width, + onBack, + onPick, + onNewChat, +}: { + debateId: string | null; + openChatId: string | null; + /** Changes when the open chat is saved, so its row and count show up. */ + reloadKey: string; + width: number; + onBack: () => void; + onPick: (chatId: string) => void; + onNewChat: () => void; +}) { + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const { colors } = useTheme(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const identity = userId ? `${userId}:${debateId ?? ''}` : null; + const [state, setState] = useState<{ identity: string | null; chats: ChatSummary[] | null; failed: boolean }>({ identity: null, chats: null, failed: false }); + + useEffect(() => { + if (!userId) return; + const key = `${userId}:${debateId ?? ''}`; + let active = true; + fetchChats(userId, debateId) + .then((chats) => { + if (active) setState({ identity: key, chats, failed: false }); + }) + .catch((cause: unknown) => { + console.warn('Could not load chats:', cause instanceof Error ? cause.message : cause); + // A reload that fails keeps the list it already shows for this debate. + if (active) setState((previous) => ({ identity: key, chats: previous.identity === key ? previous.chats : null, failed: true })); + }); + return () => { + active = false; + }; + }, [userId, debateId, reloadKey]); + + const mine = state.identity === identity; + return ( + + + + + + + + + + + + {t('nav.ask')} + + + + + ); +} + +const styles = StyleSheet.create({ + body: { fontSize: 15, lineHeight: 21 }, + loading: { marginTop: 24 }, + list: { padding: 0, gap: 0 }, + row: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 13, paddingHorizontal: 16 }, + rowText: { flex: 1, gap: 3 }, + rowTitle: { fontSize: 16, lineHeight: 21 }, + meta: { fontSize: 14 }, + pressed: { opacity: 0.6 }, + pane: { borderRightWidth: StyleSheet.hairlineWidth }, + paneTop: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 12, paddingBottom: 4 }, + iconButton: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, + paneContent: { paddingHorizontal: 18, gap: 14 }, + paneTitle: { fontSize: 32, lineHeight: 38, letterSpacing: -0.9 }, +}); diff --git a/apps/mobile/src/components/ask-history.tsx b/apps/mobile/src/components/ask-history.tsx index 8d8fb4c75..45537ec13 100644 --- a/apps/mobile/src/components/ask-history.tsx +++ b/apps/mobile/src/components/ask-history.tsx @@ -1,16 +1,14 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, Modal, Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { Modal, ScrollView, StyleSheet, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { ChatRows } from './ask-chat-list'; import { PrimaryButton, TextButton } from './buttons'; -import { Card } from './card'; -import { Icon } from './icon'; import { Text } from './text'; import { useAuth } from '@/lib/auth'; import { fetchChats, type ChatSummary } from '@/lib/chats'; -import { formatDate } from '@/lib/dates'; import { useTheme } from '@/theme/use-theme'; /** Past Ask chats in this debate (or the unsorted ones in one-off mode), in a native page sheet. */ @@ -68,43 +66,7 @@ export function AskHistory({ - {failed ? ( - - {t('mobile.ask.historyFailed')} - - ) : chats === null ? ( - - ) : chats.length === 0 ? ( - - - {t('askChat.noSavedChats')} - - - {t('askChat.noSavedChatsDesc')} - - - ) : ( - - {chats.map((chat, index) => ( - onPick(chat.id)} - style={({ pressed }) => [styles.row, index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> - - - {chat.title} - - - {`${formatDate(new Date(chat.createdAt).toISOString())} · ${t('askChat.messageCount', { count: chat.messageCount })}`} - - - {chat.id === openChatId ? : null} - - ))} - - )} + ); @@ -114,12 +76,4 @@ const styles = StyleSheet.create({ content: { paddingHorizontal: 20, paddingTop: 22, gap: 14, 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 }, - loading: { marginTop: 24 }, - list: { padding: 0, gap: 0 }, - row: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 13, paddingHorizontal: 16 }, - rowText: { flex: 1, gap: 3 }, - rowTitle: { fontSize: 16, lineHeight: 21 }, - meta: { fontSize: 14 }, - pressed: { opacity: 0.6 }, }); From a3c48311777ed0b69d91c0092e0abfad9aab03bb Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 13:33:22 +0000 Subject: [PATCH 06/12] Lay out the Debate home as a two-column dashboard on iPad The left column keeps the side toggle, counts, Spar and Prepare; the right lists five recent items and the three newest sources. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/(tabs)/(debate)/index.tsx | 134 ++++++++++++++++-- apps/mobile/src/components/screen.tsx | 12 +- 2 files changed, 132 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/app/(tabs)/(debate)/index.tsx b/apps/mobile/src/app/(tabs)/(debate)/index.tsx index 5e9f94c68..d253b2eba 100644 --- a/apps/mobile/src/app/(tabs)/(debate)/index.tsx +++ b/apps/mobile/src/app/(tabs)/(debate)/index.tsx @@ -12,6 +12,7 @@ import { OneOffNotice } from '@/components/one-off-notice'; import { PREPARE_TOOLS, prepareToolHref } from '@/components/prepare-tools'; import { Screen } from '@/components/screen'; import { SegmentedControl } from '@/components/segmented-control'; +import { SOURCE_ICONS, sourceMeta } from '@/components/source-display'; import { Text } from '@/components/text'; import { WorkRow, workHref } from '@/components/work-row'; import { useAuth } from '@/lib/auth'; @@ -23,16 +24,22 @@ import { type DebateItem, type DebateSide, } from '@/lib/debates'; +import { HOME_COLUMNS_WIDTH, useLayoutClass } from '@/lib/layout'; +import { fetchSources, type Source } from '@/lib/sources'; import { mainActionGradient, radius } from '@/theme/colors'; import { useTheme } from '@/theme/use-theme'; const RECENT_LIMIT = 3; +/** The iPad dashboard lists more work, and the newest sources beside it (iPad mock A). */ +const DASHBOARD_RECENT_LIMIT = 5; +const DASHBOARD_SOURCES = 3; /** Home: the Current Debate, as in mock 01. */ export default function DebateScreen() { const { t } = useTranslation(); const router = useRouter(); const { status, current, refresh } = useDebates(); + const dashboard = useLayoutClass() !== 'phone'; // Debates switched on the website show up when the tab comes back into view. const focusedBefore = useRef(false); @@ -84,6 +91,19 @@ export default function DebateScreen() { ); } + if (dashboard) { + return ( + + + + + {current.motion} + + + + ); + } + return ( @@ -118,8 +138,9 @@ function SwitcherPill({ onPress }: { onPress: () => void }) { * 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 * shown. It starts on In favor for each debate and is never stored. + * On iPad (`dashboard`) they sit in two columns, with the newest sources. */ -function DebateDetails({ debateId }: { debateId: string }) { +function DebateDetails({ debateId, dashboard = false }: { debateId: string; dashboard?: boolean }) { const { t } = useTranslation(); const router = useRouter(); const { session } = useAuth(); @@ -127,27 +148,32 @@ function DebateDetails({ debateId }: { debateId: string }) { const [view, setView] = useState('pro'); const [counts, setCounts] = useState(null); const [recent, setRecent] = useState | null>(null); + const [sources, setSources] = useState(null); const [failed, setFailed] = useState(false); + const [width, setWidth] = useState(0); // Both sides load together, so switching the view is instant. const load = useCallback(async (isCurrent: () => boolean) => { if (!userId) return; + const limit = dashboard ? DASHBOARD_RECENT_LIMIT : RECENT_LIMIT; try { - const [nextCounts, pro, con] = await Promise.all([ + const [nextCounts, pro, con, nextSources] = await Promise.all([ fetchDebateCounts(userId, debateId), - fetchRecentWork(userId, debateId, 'pro', RECENT_LIMIT), - fetchRecentWork(userId, debateId, 'con', RECENT_LIMIT), + fetchRecentWork(userId, debateId, 'pro', limit), + fetchRecentWork(userId, debateId, 'con', limit), + dashboard ? fetchSources(userId, debateId) : null, ]); if (!isCurrent()) return; setCounts(nextCounts); setRecent({ pro, con }); + setSources(nextSources?.slice(0, DASHBOARD_SOURCES) ?? null); setFailed(false); } catch (cause) { if (!isCurrent()) return; console.warn('Could not load the debate:', cause instanceof Error ? cause.message : cause); setFailed(true); } - }, [userId, debateId]); + }, [userId, debateId, dashboard]); // Reload on focus, so work saved on the website shows up. useFocusEffect( @@ -163,7 +189,7 @@ function DebateDetails({ debateId }: { debateId: string }) { const results = counts?.results[view]; const items = recent?.[view] ?? null; - return ( + const sideAndStats = ( <> @@ -182,8 +208,11 @@ function DebateDetails({ debateId }: { debateId: string }) { - router.push({ pathname: '/sparring', params: { from: 'debate' } })} /> - + + ); + const spar = router.push({ pathname: '/sparring', params: { from: 'debate' } })} />; + const prepare = ( + <> {t('mobile.tabs.prepare')} @@ -197,8 +226,12 @@ function DebateDetails({ debateId }: { debateId: string }) { /> ))} - - + + ); + const twoColumns = dashboard && width >= HOME_COLUMNS_WIDTH; + const pickUp = ( + <> + {t('mobile.debate.pickUp')} {failed ? ( @@ -225,6 +258,78 @@ function DebateDetails({ debateId }: { debateId: string }) { )} ); + + if (!dashboard) { + return ( + <> + {sideAndStats} + {spar} + {prepare} + {pickUp} + + ); + } + + const recentSources = failed ? null : sources === null ? ( + + ) : sources.length === 0 ? ( + + + {t('libraryPage.emptyDesc')} + + + ) : ( + + {sources.map((source, index) => ( + router.push({ pathname: '/source/[id]', params: { id: source.id } })} /> + ))} + + ); + + return ( + setWidth(event.nativeEvent.layout.width)} testID="debate-dashboard"> + + {sideAndStats} + {spar} + {prepare} + + + {pickUp} + {failed ? null : ( + <> + + {t('mobile.debate.recentSources')} + + {recentSources} + + )} + + + ); +} + +/** A source in the dashboard's Recent sources; it opens in the source sheet. */ +function RecentSource({ source, first, onPress }: { source: Source; first: boolean; onPress: () => void }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + return ( + [styles.sourceRow, !first && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> + + + + {source.title || t('libraryPage.untitledSource')} + + + {sourceMeta(t, source)} + + + + + ); } function Stat({ value, label }: { value: number | undefined; label: string }) { @@ -344,4 +449,13 @@ const styles = StyleSheet.create({ }, tileLabel: { flex: 1, fontSize: 15.5 }, list: { padding: 0, gap: 0 }, + motionWide: { fontSize: 34, lineHeight: 40, letterSpacing: -0.9 }, + columns: { flexDirection: 'row', alignItems: 'flex-start', gap: 32 }, + column: { flex: 1, minWidth: 0 }, + columnTop: { marginTop: 0 }, + sideColumn: { maxWidth: 420 }, + sourceRow: { flexDirection: 'row', alignItems: 'center', gap: 13, paddingVertical: 12, paddingHorizontal: 14 }, + sourceText: { flex: 1, gap: 2 }, + sourceTitle: { fontSize: 16 }, + sourceMeta: { fontSize: 14 }, }); diff --git a/apps/mobile/src/components/screen.tsx b/apps/mobile/src/components/screen.tsx index e747b1e27..003dc3fe0 100644 --- a/apps/mobile/src/components/screen.tsx +++ b/apps/mobile/src/components/screen.tsx @@ -2,22 +2,26 @@ import type { ReactNode } from 'react'; import { ScrollView, StyleSheet } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { HOME_MAX_WIDTH } from '@/lib/layout'; + /** * A tab's scrolling content. On iOS the native header and tab bar adjust the - * insets automatically; `safeTop` is for screens without a header. + * insets automatically; `safeTop` is for screens without a header. `wide` is + * an iPad dashboard's width instead of the phone-width column. */ -export function Screen({ children, safeTop, gap }: { children: ReactNode; safeTop?: boolean; gap?: number }) { +export function Screen({ children, safeTop, gap, wide }: { children: ReactNode; safeTop?: boolean; gap?: number; wide?: boolean }) { const insets = useSafeAreaInsets(); return ( + contentContainerStyle={[styles.content, wide && styles.wide, safeTop && { paddingTop: insets.top + 12 }, gap !== undefined && { gap }]}> {children} ); } const styles = StyleSheet.create({ - // Content stays phone-width on iPad until the iPad layouts land. + // Tabs without an iPad layout keep a phone-width column on iPad. content: { paddingHorizontal: 20, paddingBottom: 32, gap: 16, width: '100%', maxWidth: 640, alignSelf: 'center' }, + wide: { paddingHorizontal: 36, paddingBottom: 48, maxWidth: HOME_MAX_WIDTH }, }); From 944b87a0bdc0f83c0e9f0278f8c0fee6f70f2eeb Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 13:33:22 +0000 Subject: [PATCH 07/12] Show an outline rail beside saved documents on wide iPad windows Cases, attacks, brainstorms, factoids and Sparring scorecards list their headed parts in an "On this page" rail from 900 points wide; tapping one scrolls to it. Without a rail the parts register nothing. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/attack/[id].tsx | 1 + apps/mobile/src/app/brainstorm/[id].tsx | 1 + apps/mobile/src/app/case/[id].tsx | 1 + apps/mobile/src/app/factoids/[id].tsx | 1 + apps/mobile/src/components/case-document.tsx | 2 +- apps/mobile/src/components/document.tsx | 100 ++++++++++- apps/mobile/src/components/outline.tsx | 156 ++++++++++++++++++ apps/mobile/src/components/sparring-views.tsx | 1 + 8 files changed, 256 insertions(+), 7 deletions(-) create mode 100644 apps/mobile/src/components/outline.tsx diff --git a/apps/mobile/src/app/attack/[id].tsx b/apps/mobile/src/app/attack/[id].tsx index 69817e897..038db2f7f 100644 --- a/apps/mobile/src/app/attack/[id].tsx +++ b/apps/mobile/src/app/attack/[id].tsx @@ -69,6 +69,7 @@ export default function AttackResultScreen() { return ( run(() => shareText(title, text()))} actions={actions} />} actions={ diff --git a/apps/mobile/src/app/brainstorm/[id].tsx b/apps/mobile/src/app/brainstorm/[id].tsx index 980bac6a5..aba4e01f9 100644 --- a/apps/mobile/src/app/brainstorm/[id].tsx +++ b/apps/mobile/src/app/brainstorm/[id].tsx @@ -72,6 +72,7 @@ export default function BrainstormResultScreen() { return ( run(() => shareText(title, brainstormToText(output, t)))} actions={actions} />} // The website shows only the questions when the model needs more, with no export or discussion. diff --git a/apps/mobile/src/app/case/[id].tsx b/apps/mobile/src/app/case/[id].tsx index 699221708..1d7ecf878 100644 --- a/apps/mobile/src/app/case/[id].tsx +++ b/apps/mobile/src/app/case/[id].tsx @@ -71,6 +71,7 @@ export default function CaseScreen() { return ( run(() => shareText(title, caseToText(output)))} actions={actions} />} actions={ diff --git a/apps/mobile/src/app/factoids/[id].tsx b/apps/mobile/src/app/factoids/[id].tsx index 9ccf523e7..1559b80be 100644 --- a/apps/mobile/src/app/factoids/[id].tsx +++ b/apps/mobile/src/app/factoids/[id].tsx @@ -119,6 +119,7 @@ export default function FactoidsResultScreen() { return ( run(() => shareText(title, factoidsToText(output, t)))} actions={actions} />} actions={ diff --git a/apps/mobile/src/components/case-document.tsx b/apps/mobile/src/components/case-document.tsx index 01a80fbda..beed4e20e 100644 --- a/apps/mobile/src/components/case-document.tsx +++ b/apps/mobile/src/components/case-document.tsx @@ -136,7 +136,7 @@ export function CaseDocument({ ) : null} - + {t('generateResults.burdenOfProof')} diff --git a/apps/mobile/src/components/document.tsx b/apps/mobile/src/components/document.tsx index 6af82b916..1b60dd266 100644 --- a/apps/mobile/src/components/document.tsx +++ b/apps/mobile/src/components/document.tsx @@ -1,13 +1,15 @@ -import { Children, Fragment, isValidElement, useState, type ReactNode } from 'react'; -import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { Children, Fragment, isValidElement, useRef, useState, type ReactNode } from 'react'; +import { Pressable, ScrollView, StyleSheet, View, useWindowDimensions } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Chip, CiteBadge } from './chip'; import type { CitationTarget } from './citation-target'; import { Icon } from './icon'; import { MarkdownView } from './markdown-view'; +import { OutlineProvider, OutlineRail, useOutlineAnchor, useOutlineItems, useOutlineRail } from './outline'; import { Text } from './text'; +import { RAIL_MIN_WIDTH, RAIL_WIDTH, useHeaderCover, useLayoutClass } from '@/lib/layout'; import { radius } from '@/theme/colors'; import { useTheme } from '@/theme/use-theme'; @@ -29,6 +31,7 @@ export function DocumentScreen({ actionWidths, notice, testID, + outline = false, }: { header: ReactNode; children: ReactNode; @@ -37,10 +40,21 @@ export function DocumentScreen({ actionWidths?: number[]; notice?: string; testID?: string; + /** A saved document: on a wide iPad window its headed parts are listed in a rail beside it (iPad mock A). */ + outline?: boolean; }) { const { colors } = useTheme(); const insets = useSafeAreaInsets(); + const layout = useLayoutClass(); + const { width } = useWindowDimensions(); const bottom = Math.max(insets.bottom - 6, 14); + if (outline && layout !== 'phone' && width >= RAIL_MIN_WIDTH) { + return ( + + {children} + + ); + } return ( {header} @@ -70,6 +84,68 @@ export function DocumentScreen({ ); } +/** The document with its outline rail on the left, the readable column beside it. */ +function RailDocument({ + header, + children, + actions, + actionWidths, + notice, + testID, +}: { + header: ReactNode; + children: ReactNode; + actions?: ReactNode; + actionWidths?: number[]; + notice?: string; + testID?: string; +}) { + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + const bottom = Math.max(insets.bottom - 6, 14); + const scroll = useRef(null); + const content = useRef(null); + const rail = useOutlineRail(content, scroll, useHeaderCover()); + return ( + + {header} + + + + + + rail.onScroll(event.nativeEvent.contentOffset.y)} + onContentSizeChange={rail.measure}> + + {children} + + + {notice ? ( + + {notice} + + ) : null} + {actions ? ( + + + {Children.map(unwrapFragment(actions), (action, index) => (action ? {action} : null))} + + + ) : null} + + + + ); +} + function unwrapFragment(node: ReactNode): ReactNode { return isValidElement<{ children?: ReactNode }>(node) && node.type === Fragment ? node.props.children : node; } @@ -92,8 +168,9 @@ export function DocumentTitle({ title, meta }: { title: string; meta?: string }) /** The pale-blue card the document opens with. */ export function FramingCard({ blocks }: { blocks: { label: string; text: string }[] }) { const { colors } = useTheme(); + const anchor = useOutlineAnchor(blocks[0]?.label); return ( - + {blocks.map((block, index) => ( @@ -119,9 +196,11 @@ export interface NumberedItem { /** * Numbered items that open one at a time or together. The first is open. An * open item gets its own card; neighbouring closed items share one, as in the mock. + * With `outline`, each item is listed in the iPad document rail. */ -export function NumberedItems({ items, testID }: { items: NumberedItem[]; testID?: string }) { +export function NumberedItems({ items, testID, outline = false }: { items: NumberedItem[]; testID?: string; outline?: boolean }) { const { colors } = useTheme(); + const anchorFor = useOutlineItems(items, outline); const [open, setOpen] = useState>(() => new Set(items[0] ? [items[0].key] : [])); const toggle = (key: string) => setOpen((previous) => { @@ -148,6 +227,7 @@ export function NumberedItems({ items, testID }: { items: NumberedItem[]; testID {index > 0 ? : null} toggle(item.key)} @@ -227,8 +307,9 @@ export function SourceChips({ chips, onCite }: { chips: CitationTarget[]; onCite } export function SectionHeading({ title, description }: { title: string; description?: string }) { + const anchor = useOutlineAnchor(title); return ( - + {title} @@ -244,8 +325,9 @@ export function SectionHeading({ title, description }: { title: string; descript /** A titled part of the document, in the same card style as the arguments. */ export function DocSection({ title, description, children, testID }: { title: string; description?: string; children: ReactNode; testID?: string }) { const { colors } = useTheme(); + const anchor = useOutlineAnchor(title); return ( - + {title} @@ -327,6 +409,12 @@ const styles = StyleSheet.create({ pinned: { position: 'absolute', left: 0, right: 0, alignItems: 'center' }, pinnedRow: { flexDirection: 'row', gap: 10, width: '100%', maxWidth: 680, paddingHorizontal: 14 }, pinnedItem: { flex: 1 }, + railRow: { flex: 1, flexDirection: 'row' }, + railPane: { width: RAIL_WIDTH, flexGrow: 0, flexShrink: 0 }, + railDocument: { flex: 1 }, + contentRail: { alignSelf: 'flex-start', paddingLeft: 20, paddingRight: 28 }, + pinnedRail: { alignItems: 'flex-start' }, + pinnedRowRail: { paddingLeft: 16, paddingRight: 24 }, pressed: { opacity: 0.7 }, notice: { position: 'absolute', left: 24, right: 24, borderRadius: 14, paddingHorizontal: 16, paddingVertical: 11, alignSelf: 'center', maxWidth: 560 }, noticeText: { fontSize: 15, lineHeight: 20, textAlign: 'center' }, diff --git a/apps/mobile/src/components/outline.tsx b/apps/mobile/src/components/outline.tsx new file mode 100644 index 000000000..a830ad487 --- /dev/null +++ b/apps/mobile/src/components/outline.tsx @@ -0,0 +1,156 @@ +import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, type ReactNode, type RefObject } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; + +import { Text } from './text'; + +import { useTheme } from '@/theme/use-theme'; + +/** + * The iPad document outline (mock A's "On this page" rail). Headed parts of a + * document register themselves while a rail is shown; without one they do + * nothing, so the phone document is untouched. + */ + +interface Anchor { + id: string; + label: string; + number?: number; + view: () => View | null; +} + +interface Outline { + register: (anchor: Anchor) => () => void; +} + +const OutlineContext = createContext(null); + +/** A ref for a headed part's root view, or undefined when no rail is shown. */ +export function useOutlineAnchor(label: string | undefined): RefObject | undefined { + const outline = useContext(OutlineContext); + const ref = useRef(null); + const id = useId(); + useEffect(() => { + if (!outline || !label) return; + return outline.register({ id, label, view: () => ref.current }); + }, [outline, label, id]); + return outline ? ref : undefined; +} + +/** Ref callbacks for a list of numbered items, or undefined when no rail is shown. */ +export function useOutlineItems(items: { key: string; title: string }[], enabled: boolean): ((key: string) => (view: View | null) => void) | undefined { + const outline = useContext(OutlineContext); + const views = useRef(new Map()); + const id = useId(); + const active = outline && enabled ? outline : null; + const signature = items.map((item) => `${item.key}\u0000${item.title}`).join('\u0001'); + useEffect(() => { + if (!active) return; + const removals = items.map((item, index) => + active.register({ id: `${id}-${item.key}`, label: item.title, number: index + 1, view: () => views.current.get(item.key) ?? null }), + ); + return () => removals.forEach((remove) => remove()); + // The signature stands for the items' keys and titles. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active, id, signature]); + if (!active) return undefined; + return (key) => (view) => { + views.current.set(key, view); + }; +} + +/** + * The rail beside the document and the state behind it. `content` is the view + * that holds the document inside its scroll view; positions are measured from it. + * `covered` is how much of the page's top a translucent bar hides. + */ +export function useOutlineRail(content: RefObject, scroll: RefObject, covered: number) { + const [anchors, setAnchors] = useState>(new Map()); + const [positions, setPositions] = useState>(new Map()); + const [scrollY, setScrollY] = useState(0); + + const outline = useMemo( + () => ({ + register: (anchor) => { + setAnchors((previous) => new Map(previous).set(anchor.id, anchor)); + return () => + setAnchors((previous) => { + const next = new Map(previous); + next.delete(anchor.id); + return next; + }); + }, + }), + [], + ); + + const measure = useCallback(() => { + const root = content.current; + if (!root) return; + anchors.forEach((anchor) => { + const view = anchor.view(); + view?.measureLayout(root, (_x, y) => + setPositions((previous) => (previous.get(anchor.id) === y ? previous : new Map(previous).set(anchor.id, y))), + ); + }); + }, [anchors, content]); + + useEffect(measure, [measure]); + + const entries = [...anchors.values()].sort((a, b) => (positions.get(a.id) ?? Infinity) - (positions.get(b.id) ?? Infinity)); + // The part whose heading has scrolled to the top of the page, or the first. + const current = entries.reduce((found, anchor) => ((positions.get(anchor.id) ?? Infinity) <= scrollY + covered + 32 ? anchor.id : found), entries[0]?.id); + + const jump = (anchor: Anchor) => { + const root = content.current; + const view = anchor.view(); + if (!root || !view) return; + view.measureLayout(root, (_x, y) => scroll.current?.scrollTo({ y: y - covered - 12, animated: true })); + }; + + return { outline, entries, current, measure, jump, onScroll: setScrollY }; +} + +export function OutlineProvider({ outline, children }: { outline: Outline; children: ReactNode }) { + return {children}; +} + +export function OutlineRail({ entries, current, onJump }: { entries: Anchor[]; current: string | undefined; onJump: (anchor: Anchor) => void }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + if (entries.length === 0) return null; + return ( + + + {t('mobile.document.onThisPage')} + + {entries.map((anchor) => { + const on = anchor.id === current; + return ( + onJump(anchor)} + style={({ pressed }) => [styles.entry, on && { backgroundColor: colors.tile }, pressed && styles.pressed]}> + + {anchor.number ?? ''} + + + {anchor.label} + + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + rail: { paddingHorizontal: 18, paddingTop: 10, gap: 2 }, + heading: { fontSize: 13.5, marginBottom: 8, marginLeft: 8 }, + entry: { flexDirection: 'row', gap: 8, borderRadius: 10, paddingVertical: 7, paddingHorizontal: 8 }, + number: { width: 14, fontSize: 14, lineHeight: 19 }, + label: { flex: 1, fontSize: 14.5, lineHeight: 19 }, + pressed: { opacity: 0.7 }, +}); diff --git a/apps/mobile/src/components/sparring-views.tsx b/apps/mobile/src/components/sparring-views.tsx index 247f5c034..5b73d1ac3 100644 --- a/apps/mobile/src/components/sparring-views.tsx +++ b/apps/mobile/src/components/sparring-views.tsx @@ -97,6 +97,7 @@ export function SparringScorecardScreen({ return ( Date: Wed, 14 Oct 2026 13:33:22 +0000 Subject: [PATCH 08/12] Dock the Flowchart's claim beside the map on iPad The claim sheet's body becomes ClaimPanel. On iPad it opens in an inspector (360 points, 400 when wide) that stays open while the student moves between cards, highlights the open card, and closes when the map or debate on screen changes. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/scripts/screenshots.ts | 5 +- apps/mobile/src/app/flowchart/claim.tsx | 265 +------------- apps/mobile/src/app/flowchart/index.tsx | 237 +++++++------ apps/mobile/src/components/claim-panel.tsx | 322 ++++++++++++++++++ .../mobile/src/components/flowchart-views.tsx | 13 +- 5 files changed, 477 insertions(+), 365 deletions(-) create mode 100644 apps/mobile/src/components/claim-panel.tsx diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index 539cc12db..3a20d7347 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -603,7 +603,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(); diff --git a/apps/mobile/src/app/flowchart/claim.tsx b/apps/mobile/src/app/flowchart/claim.tsx index 669c6a687..66bddc957 100644 --- a/apps/mobile/src/app/flowchart/claim.tsx +++ b/apps/mobile/src/app/flowchart/claim.tsx @@ -1,268 +1,11 @@ -import { - canDeleteCard, - canLink, - cardLinks, - deleteCard, - editCard, - findNode, - flowNodes, - linkCards, - unlinkCards, - type FlowEdgeKind, - type FlowNodeType, -} from '@debatelab/core/flowchart'; import { useLocalSearchParams, useRouter } from 'expo-router'; -import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Pressable, StyleSheet, View } from 'react-native'; -import { ActionMenu } from '@/components/action-menu'; -import { TextButton } from '@/components/buttons'; -import { Chip } from '@/components/chip'; -import { CardForm } from '@/components/flowchart-card-form'; -import { useFlowColors } from '@/components/flowchart-views'; -import { Icon } from '@/components/icon'; -import { SegmentedControl } from '@/components/segmented-control'; +import { ClaimPanel } from '@/components/claim-panel'; import { SheetScrollView } from '@/components/sheet'; -import { Text } from '@/components/text'; -import { confirmDestructive } from '@/lib/confirm'; -import { findDraft, updateDraft, useFlowchartDrafts } from '@/lib/flowchart-drafts'; -import { cardKind, linkLine, linkSummary, typeLabel } from '@/lib/flowchart-text'; -import { radius } from '@/theme/colors'; -import { useTheme } from '@/theme/use-theme'; -const TYPES: FlowNodeType[] = ['contention', 'attack', 'rebuttal', 'dropped']; - -/** The kind a new link starts on: what this card's type usually does. */ -function defaultKind(type: FlowNodeType): FlowEdgeKind { - return type === 'attack' ? 'attack' : type === 'rebuttal' ? 'rebuttal' : 'support'; -} - -/** - * A card's sheet, as mock A: its type, text and links, and the website's - * actions on a node: link it, edit it, change its type, or delete it. Every - * change lands in the map on screen at once and stays unsaved until Save. - */ +/** A card's claim in a form sheet, as mock A. iPad docks the same panel beside the map instead. */ export default function ClaimSheet() { - const { t } = useTranslation(); const router = useRouter(); - const { colors } = useTheme(); - const flow = useFlowColors(); - const { scope, map, node: nodeId } = useLocalSearchParams<{ scope: string; map: string; node: string }>(); - useFlowchartDrafts(); - // `map` is the map's handle, so a save that changes its key while the sheet is open keeps it. - const held = scope && map ? findDraft(scope, map) : null; - const draft = held?.draft; - const node = draft && nodeId ? findNode(draft.doc, nodeId) : undefined; - const [mode, setMode] = useState<'view' | 'link' | 'edit'>('view'); - const [kind, setKind] = useState(() => defaultKind(node?.data.type ?? 'contention')); - const [typeMenu, setTypeMenu] = useState(false); - - if (!held || !draft || !node) { - return ( - - - {t('mobile.flowchart.cardGone')} - - router.back()} /> - - ); - } - - // Found again when it runs: a save while a confirm was open may have moved the map to a new key. - const change = (edit: Parameters[2]) => { - const now = findDraft(scope, map); - if (now) updateDraft(scope, now.key, edit); - }; - const { responds, answeredBy } = cardLinks(draft.doc, node.id); - const links = [...responds.map((link) => ({ link, direction: 'responds' as const })), ...answeredBy.map((link) => ({ link, direction: 'answeredBy' as const }))]; - const deletable = canDeleteCard(node.id); - const isThesis = node.data.type === 'thesis'; - - const remove = async () => { - const confirmed = await confirmDestructive({ - title: t('flowchart.deleteNodeTitle'), - message: t('flowchart.deleteNodeDescription', { label: node.data.label }), - confirm: t('flowchart.deleteNodeConfirm'), - cancel: t('flowchart.deleteNodeCancel'), - }); - if (!confirmed) return; - change((current) => ({ ...current, doc: deleteCard(current.doc, node.id), edited: true })); - router.back(); - }; - - const header = ( - - - - {cardKind(t, node)} - - - {node.data.label} - - - router.back()} /> - - ); - - if (mode === 'edit') { - return ( - - {header} - - {t('flowchart.editNode')} - - { - change((current) => ({ ...current, doc: editCard(current.doc, node.id, fields), edited: true })); - setMode('view'); - }} - /> - setMode('view')} /> - - ); - } - - if (mode === 'link') { - const others = flowNodes(draft.doc).filter((other) => other.id !== node.id); - return ( - - {header} - - - {t('mobile.flowchart.linkTitle')} - - - {t('mobile.flowchart.linkHint')} - - - - - {others.map((other, index) => { - const allowed = canLink(draft.doc, other.id, node.id); - return ( - { - change((current) => ({ ...current, doc: linkCards(current.doc, other.id, node.id, kind), edited: true })); - setMode('view'); - }} - style={({ pressed }) => [styles.row, index > 0 && { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.line }, pressed && styles.pressed, !allowed && styles.disabled]} - testID="link-target"> - - - - {other.data.label} - - - {allowed ? typeLabel(t, other.data.type) : t('mobile.flowchart.alreadyLinked')} - - - - ); - })} - - setMode('view')} /> - - ); - } - - return ( - - {header} - {node.data.description ? ( - - {node.data.description} - - ) : null} - - {linkSummary(t, draft.doc, node)} - - - setMode('link')} testID="claim-link" /> - setMode('edit')} testID="claim-edit" /> - {isThesis ? null : setTypeMenu(true)} testID="claim-type" />} - - {links.length > 0 ? ( - - - {t('mobile.flowchart.linksTitle')} - - - {links.map(({ link, direction }, index) => { - const line = linkLine(t, link.kind, direction, link.other); - return ( - 0 && { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.line }]}> - - {line} - change((current) => ({ ...current, doc: unlinkCards(current.doc, link.edgeId), edited: true }))} - style={({ pressed }) => [styles.remove, pressed && styles.pressed]} - testID="remove-link"> - - - - ); - })} - - - ) : null} - {deletable ? void remove()} /> : null} - type !== node.data.type).map((type) => ({ key: type, label: typeLabel(t, type) }))} - cancelLabel={t('common.cancel')} - onClose={() => setTypeMenu(false)} - onSelect={(type) => - change((current) => ({ - ...current, - doc: editCard(current.doc, node.id, { - label: node.data.label, - description: node.data.description ?? '', - type: type as FlowNodeType, - strength: node.data.strength ?? 'moderate', - }), - edited: true, - })) - } - /> - - ); + const { scope, map, node } = useLocalSearchParams<{ scope: string; map: string; node: string }>(); + return router.back()} />; } - -const styles = StyleSheet.create({ - top: { flexDirection: 'row', alignItems: 'flex-start', gap: 12 }, - topText: { flex: 1, gap: 4 }, - who: { fontSize: 14 }, - title: { fontSize: 21, lineHeight: 27, letterSpacing: -0.3 }, - body: { fontSize: 15.5, lineHeight: 22 }, - section: { fontSize: 16 }, - block: { gap: 8 }, - chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, - list: { borderWidth: 1, borderRadius: radius.field, overflow: 'hidden' }, - row: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 14, paddingVertical: 11, minHeight: 50 }, - rowText: { flex: 1, gap: 2 }, - rowLabel: { fontSize: 15.5, lineHeight: 21 }, - rowMeta: { fontSize: 13, lineHeight: 17 }, - dot: { width: 10, height: 10, borderRadius: 3 }, - remove: { minWidth: 32, minHeight: 32, alignItems: 'center', justifyContent: 'center' }, - pressed: { opacity: 0.7 }, - disabled: { opacity: 0.45 }, -}); diff --git a/apps/mobile/src/app/flowchart/index.tsx b/apps/mobile/src/app/flowchart/index.tsx index 7393d4863..cf7dbb91e 100644 --- a/apps/mobile/src/app/flowchart/index.tsx +++ b/apps/mobile/src/app/flowchart/index.tsx @@ -7,6 +7,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { ActionMenu } from '@/components/action-menu'; import { TextButton } from '@/components/buttons'; +import { ClaimInspector } from '@/components/claim-panel'; import { DocumentHeader, type HeaderAction } from '@/components/document-header'; import { FlowColumnsView, FlowLegend, MapTreeView } from '@/components/flowchart-views'; import { Icon } from '@/components/icon'; @@ -35,6 +36,7 @@ import { } from '@/lib/flowchart-drafts'; import { flowchartToText } from '@/lib/flowchart-text'; import { deleteFlowchart, fetchFlowchart, fetchFlowchartList, insertFlowchart, updateFlowchart } from '@/lib/flowcharts'; +import { PANE_WIDTH, useLayoutClass } from '@/lib/layout'; import { useTheme } from '@/theme/use-theme'; const NOTICE_MS = 3500; @@ -42,8 +44,9 @@ const NOTICE_MS = 3500; /** * The website's Flowchart in mock A's phone look: the Current Debate's newest * saved map (or the starter map), as role columns (Flow) or a tree (Map). - * Tapping a card opens its claim sheet; "+" adds a card; "..." saves, saves - * as, deletes and exports. Edits stay in session memory until saved. + * Tapping a card opens its claim sheet, or on iPad its claim docked beside the + * map (iPad mock C); "+" adds a card; "..." saves, saves as, deletes and + * exports. Edits stay in session memory until saved. */ export default function FlowchartScreen() { const { t } = useTranslation(); @@ -67,6 +70,9 @@ export default function FlowchartScreen() { const [busy, setBusy] = useState(false); const [menu, setMenu] = useState<'export' | null>(null); const [notice, setNoticeState] = useState(''); + const layout = useLayoutClass(); + // The card docked in the iPad inspector, with the map it was opened on. + const [claim, setClaim] = useState<{ scope: string; map: string; node: string } | null>(null); const live = useRef(true); const owner = useRef(userId); @@ -139,8 +145,14 @@ export default function FlowchartScreen() { const openCard = (node: FlowNode) => { if (!scope || !open) return; + if (layout !== 'phone') { + setClaim({ scope, map: open.draft.handle, node: node.id }); + return; + } router.push({ pathname: '/flowchart/claim', params: { scope, map: open.draft.handle, node: node.id } }); }; + // The inspector shows only a card of the map on screen; another map or debate closes it. + const docked = layout !== 'phone' && claim && claim.scope === scope && claim.map === open?.draft.handle ? claim : null; /** Runs a save or delete for the map on screen, as the account and map were when the student chose it. */ const act = async (work: (forUser: string, at: { scope: string; key: string; draft: FlowchartDraft }) => Promise) => { @@ -261,112 +273,141 @@ export default function FlowchartScreen() { const shownFailure = failure && failure.scope === (requestedId ? null : scopeKey(activeDebateId)) ? failure.kind : null; - return ( - - router.push({ pathname: '/flowchart/card', params: { scope, map: open.draft.handle } }) }] : []} - /> - - - - {t('nav.flowchart')} + const header = ( + router.push({ pathname: '/flowchart/card', params: { scope, map: open.draft.handle } }) }] : []} + /> + ); + const page = ( + + + + {t('nav.flowchart')} + + {draft && scope ? ( + router.push({ pathname: '/flowchart/maps', params: { scope } })} + style={({ pressed }) => [styles.mapRow, pressed && styles.pressed]} + testID="flowchart-maps"> + + + {mapTitle} + + + {draft.edited + ? t('mobile.flowchart.edited') + : draft.createdAt + ? t('mobile.flowchart.savedWhen', { when: formatWhen(draft.createdAt) }) + : t('mobile.flowchart.notSaved')} + + + {busy ? : } + + ) : null} + {oneOff ? : null} + + + {draft ? ( + view === 'flow' ? ( + <> + + + + ) : ( + + ) + ) : shownFailure ? ( + + + {shownFailure === 'missing' ? t('mobile.flowchart.mapMissing') : t('mobile.flowchart.loadFailed')} - {draft && scope ? ( - router.push({ pathname: '/flowchart/maps', params: { scope } })} - style={({ pressed }) => [styles.mapRow, pressed && styles.pressed]} - testID="flowchart-maps"> - - - {mapTitle} - - - {draft.edited - ? t('mobile.flowchart.edited') - : draft.createdAt - ? t('mobile.flowchart.savedWhen', { when: formatWhen(draft.createdAt) }) - : t('mobile.flowchart.notSaved')} - - - {busy ? : } - - ) : null} - {oneOff ? : null} - - - {draft ? ( - view === 'flow' ? ( - <> - - - + {shownFailure === 'missing' ? ( + setRequestedId(null)} /> ) : ( - - ) - ) : shownFailure ? ( - - - {shownFailure === 'missing' ? t('mobile.flowchart.mapMissing') : t('mobile.flowchart.loadFailed')} - - {shownFailure === 'missing' ? ( - setRequestedId(null)} /> - ) : ( - setAttempt((n) => n + 1)} /> - )} - - ) : ( - - + setAttempt((n) => n + 1)} /> + )} + + ) : ( + + + + )} + + ); + const toast = notice ? ( + + {notice} + + ) : null; + const exportMenu = ( + setMenu(null)} + onSelect={(choice) => { + if (!draft) return; + const meta = t('mobile.debate.itemMeta', { kind: t('debate.kinds.flowchart'), when: formatWhen(draft.createdAt ?? Date.now()) }); + if (choice === 'pdf') runExport(() => sharePdf(flowchartExportHtml(mapTitle, meta, draft.doc, t), FLOWCHART_EXPORT_FILE_NAME, mapTitle)); + if (choice === 'copy') + runExport(async () => { + await copyText(flowchartToText(mapTitle, draft.doc, t)); + setNotice(t('generateResults.copiedToClipboard')); + }); + }} + /> + ); + + if (layout !== 'phone') { + return ( + + {header} + + + {page} + {toast} - )} - - {notice ? ( - - {notice} + {docked ? setClaim(null)} /> : null} - ) : null} - setMenu(null)} - onSelect={(choice) => { - if (!draft) return; - const meta = t('mobile.debate.itemMeta', { kind: t('debate.kinds.flowchart'), when: formatWhen(draft.createdAt ?? Date.now()) }); - if (choice === 'pdf') runExport(() => sharePdf(flowchartExportHtml(mapTitle, meta, draft.doc, t), FLOWCHART_EXPORT_FILE_NAME, mapTitle)); - if (choice === 'copy') - runExport(async () => { - await copyText(flowchartToText(mapTitle, draft.doc, t)); - setNotice(t('generateResults.copiedToClipboard')); - }); - }} - /> + {exportMenu} + + ); + } + + return ( + + {header} + {page} + {toast} + {exportMenu} ); } const styles = StyleSheet.create({ root: { flex: 1 }, + split: { flex: 1, flexDirection: 'row' }, + canvas: { flex: 1 }, content: { paddingTop: 4, gap: 14 }, head: { paddingHorizontal: 16, gap: 12, width: '100%', maxWidth: 980, alignSelf: 'center' }, title: { fontSize: 32, lineHeight: 38, letterSpacing: -0.9 }, diff --git a/apps/mobile/src/components/claim-panel.tsx b/apps/mobile/src/components/claim-panel.tsx new file mode 100644 index 000000000..d9c890713 --- /dev/null +++ b/apps/mobile/src/components/claim-panel.tsx @@ -0,0 +1,322 @@ +import { + canDeleteCard, + canLink, + cardLinks, + deleteCard, + editCard, + findNode, + flowNodes, + linkCards, + unlinkCards, + type FlowEdgeKind, + type FlowNodeType, +} from '@debatelab/core/flowchart'; +import { useState, type ComponentType, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; + +import { ActionMenu } from './action-menu'; +import { TextButton } from './buttons'; +import { Chip } from './chip'; +import { CardForm } from './flowchart-card-form'; +import { useFlowColors } from './flowchart-views'; +import { Icon } from './icon'; +import { SegmentedControl } from './segmented-control'; +import { Text } from './text'; + +import { confirmDestructive } from '@/lib/confirm'; +import { findDraft, updateDraft, useFlowchartDrafts } from '@/lib/flowchart-drafts'; +import { cardKind, linkLine, linkSummary, typeLabel } from '@/lib/flowchart-text'; +import { useHeaderCover } from '@/lib/layout'; +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +const TYPES: FlowNodeType[] = ['contention', 'attack', 'rebuttal', 'dropped']; + +/** The kind a new link starts on: what this card's type usually does. */ +function defaultKind(type: FlowNodeType): FlowEdgeKind { + return type === 'attack' ? 'attack' : type === 'rebuttal' ? 'rebuttal' : 'support'; +} + +/** The scroll view the claim sits in: the form sheet's on a phone, the docked inspector's on iPad. */ +export type ClaimContainer = ComponentType<{ children: ReactNode; testID?: string }>; + +/** + * A card's claim, as mock A: its type, text and links, and the website's + * actions on a node: link it, edit it, change its type, or delete it. Every + * change lands in the map on screen at once and stays unsaved until Save. + * A phone shows it in a form sheet; iPad docks it beside the map (mock C), + * where the inspector's own header closes it. Mount it with a key per card. + */ +export function ClaimPanel({ + scope, + map, + nodeId, + container: Container, + onClose, + docked = false, +}: { + scope: string; + map: string; + nodeId: string; + container: ClaimContainer; + onClose: () => void; + docked?: boolean; +}) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const flow = useFlowColors(); + useFlowchartDrafts(); + // `map` is the map's handle, so a save that changes its key while the sheet is open keeps it. + const held = scope && map ? findDraft(scope, map) : null; + const draft = held?.draft; + const node = draft && nodeId ? findNode(draft.doc, nodeId) : undefined; + const [mode, setMode] = useState<'view' | 'link' | 'edit'>('view'); + const [kind, setKind] = useState(() => defaultKind(node?.data.type ?? 'contention')); + const [typeMenu, setTypeMenu] = useState(false); + + if (!held || !draft || !node) { + return ( + + + {t('mobile.flowchart.cardGone')} + + + + ); + } + + // Found again when it runs: a save while a confirm was open may have moved the map to a new key. + const change = (edit: Parameters[2]) => { + const now = findDraft(scope, map); + if (now) updateDraft(scope, now.key, edit); + }; + const { responds, answeredBy } = cardLinks(draft.doc, node.id); + const links = [...responds.map((link) => ({ link, direction: 'responds' as const })), ...answeredBy.map((link) => ({ link, direction: 'answeredBy' as const }))]; + const deletable = canDeleteCard(node.id); + const isThesis = node.data.type === 'thesis'; + + const remove = async () => { + const confirmed = await confirmDestructive({ + title: t('flowchart.deleteNodeTitle'), + message: t('flowchart.deleteNodeDescription', { label: node.data.label }), + confirm: t('flowchart.deleteNodeConfirm'), + cancel: t('flowchart.deleteNodeCancel'), + }); + if (!confirmed) return; + change((current) => ({ ...current, doc: deleteCard(current.doc, node.id), edited: true })); + onClose(); + }; + + const header = ( + + + + {cardKind(t, node)} + + + {node.data.label} + + + {docked ? null : } + + ); + + if (mode === 'edit') { + return ( + + {header} + + {t('flowchart.editNode')} + + { + change((current) => ({ ...current, doc: editCard(current.doc, node.id, fields), edited: true })); + setMode('view'); + }} + /> + setMode('view')} /> + + ); + } + + if (mode === 'link') { + const others = flowNodes(draft.doc).filter((other) => other.id !== node.id); + return ( + + {header} + + + {t('mobile.flowchart.linkTitle')} + + + {t('mobile.flowchart.linkHint')} + + + + + {others.map((other, index) => { + const allowed = canLink(draft.doc, other.id, node.id); + return ( + { + change((current) => ({ ...current, doc: linkCards(current.doc, other.id, node.id, kind), edited: true })); + setMode('view'); + }} + style={({ pressed }) => [styles.row, index > 0 && { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.line }, pressed && styles.pressed, !allowed && styles.disabled]} + testID="link-target"> + + + + {other.data.label} + + + {allowed ? typeLabel(t, other.data.type) : t('mobile.flowchart.alreadyLinked')} + + + + ); + })} + + setMode('view')} /> + + ); + } + + return ( + + {header} + {node.data.description ? ( + + {node.data.description} + + ) : null} + + {linkSummary(t, draft.doc, node)} + + + setMode('link')} testID="claim-link" /> + setMode('edit')} testID="claim-edit" /> + {isThesis ? null : setTypeMenu(true)} testID="claim-type" />} + + {links.length > 0 ? ( + + + {t('mobile.flowchart.linksTitle')} + + + {links.map(({ link, direction }, index) => { + const line = linkLine(t, link.kind, direction, link.other); + return ( + 0 && { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.line }]}> + + {line} + change((current) => ({ ...current, doc: unlinkCards(current.doc, link.edgeId), edited: true }))} + style={({ pressed }) => [styles.remove, pressed && styles.pressed]} + testID="remove-link"> + + + + ); + })} + + + ) : null} + {deletable ? void remove()} /> : null} + type !== node.data.type).map((type) => ({ key: type, label: typeLabel(t, type) }))} + cancelLabel={t('common.cancel')} + onClose={() => setTypeMenu(false)} + onSelect={(type) => + change((current) => ({ + ...current, + doc: editCard(current.doc, node.id, { + label: node.data.label, + description: node.data.description ?? '', + type: type as FlowNodeType, + strength: node.data.strength ?? 'moderate', + }), + edited: true, + })) + } + /> + + ); +} + +/** The claim's scroll view in the docked inspector. */ +function DockScrollView({ children, testID }: { children: ReactNode; testID?: string }) { + return ( + + {children} + + ); +} + +/** + * The claim docked beside the map on iPad (iPad mock C), in place of the form + * sheet. It stays open while the student moves between cards; each card gets + * a fresh panel, so a half-made link or edit never carries over. + */ +export function ClaimInspector({ claim, width, onClose }: { claim: { scope: string; map: string; node: string }; width: number; onClose: () => void }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const covered = useHeaderCover(); + return ( + + + + {t('export.claim')} + + + + + + + + ); +} + +const styles = StyleSheet.create({ + inspector: { flexGrow: 0, flexShrink: 0, borderLeftWidth: StyleSheet.hairlineWidth }, + inspectorHead: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingLeft: 20, paddingRight: 12, minHeight: 50, borderBottomWidth: StyleSheet.hairlineWidth }, + inspectorTitle: { fontSize: 17 }, + dockContent: { paddingHorizontal: 20, paddingTop: 18, paddingBottom: 32, gap: 18 }, + top: { flexDirection: 'row', alignItems: 'flex-start', gap: 12 }, + topText: { flex: 1, gap: 4 }, + who: { fontSize: 14 }, + title: { fontSize: 21, lineHeight: 27, letterSpacing: -0.3 }, + body: { fontSize: 15.5, lineHeight: 22 }, + section: { fontSize: 16 }, + block: { gap: 8 }, + chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, + list: { borderWidth: 1, borderRadius: radius.field, overflow: 'hidden' }, + row: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 14, paddingVertical: 11, minHeight: 50 }, + rowText: { flex: 1, gap: 2 }, + rowLabel: { fontSize: 15.5, lineHeight: 21 }, + rowMeta: { fontSize: 13, lineHeight: 17 }, + dot: { width: 10, height: 10, borderRadius: 3 }, + remove: { minWidth: 32, minHeight: 32, alignItems: 'center', justifyContent: 'center' }, + pressed: { opacity: 0.7 }, + disabled: { opacity: 0.45 }, +}); diff --git a/apps/mobile/src/components/flowchart-views.tsx b/apps/mobile/src/components/flowchart-views.tsx index 2b26ac1a1..e7ddcdbf5 100644 --- a/apps/mobile/src/components/flowchart-views.tsx +++ b/apps/mobile/src/components/flowchart-views.tsx @@ -46,7 +46,7 @@ const SIDE = 16; /** From this width every column fits, as on iPad. */ const WIDE = 600; -export function FlowCard({ node, meta, onPress, testID }: { node: FlowNode; meta: string | null; onPress: () => void; testID?: string }) { +export function FlowCard({ node, meta, onPress, testID, selected = false }: { node: FlowNode; meta: string | null; onPress: () => void; testID?: string; selected?: boolean }) { const { t } = useTranslation(); const { colors } = useTheme(); const flow = useFlowColors(); @@ -54,11 +54,14 @@ export function FlowCard({ node, meta, onPress, testID }: { node: FlowNode; meta [ styles.card, { backgroundColor: flow.cardFill, borderColor: colors.border, borderLeftColor: flow.type[node.data.type] }, + // The card open in the iPad inspector. + selected && { backgroundColor: colors.tile, borderColor: colors.chipSelectedBorder, borderLeftColor: flow.type[node.data.type] }, pressed && styles.pressed, ]}> @@ -98,7 +101,7 @@ export function FlowLegend() { } /** The Flow tab: your case, their attacks and your rebuttals side by side, snapping column by column on a phone. */ -export function FlowColumnsView({ doc, onOpen }: { doc: FlowchartDocument; onOpen: (node: FlowNode) => void }) { +export function FlowColumnsView({ doc, onOpen, selectedId }: { doc: FlowchartDocument; onOpen: (node: FlowNode) => void; selectedId?: string }) { const { t } = useTranslation(); const flow = useFlowColors(); const { width } = useWindowDimensions(); @@ -119,7 +122,7 @@ export function FlowColumnsView({ doc, onOpen }: { doc: FlowchartDocument; onOpe {t('mobile.flowchart.emptyColumn')} ) : ( - column.cards.map((node, index) => onOpen(node)} testID="flow-card" />) + column.cards.map((node, index) => onOpen(node)} testID="flow-card" selected={node.id === selectedId} />) )} )); @@ -141,7 +144,7 @@ const INDENT = 18; const MAX_INDENT = 4; /** The Map tab: from the thesis down through what supports, attacks and rebuts it. */ -export function MapTreeView({ doc, onOpen }: { doc: FlowchartDocument; onOpen: (node: FlowNode) => void }) { +export function MapTreeView({ doc, onOpen, selectedId }: { doc: FlowchartDocument; onOpen: (node: FlowNode) => void; selectedId?: string }) { const { t } = useTranslation(); const flow = useFlowColors(); return ( @@ -153,7 +156,7 @@ export function MapTreeView({ doc, onOpen }: { doc: FlowchartDocument; onOpen: ( {`↳ ${viaLabel(t, via)}`} ) : null} - onOpen(node)} testID="map-card" /> + onOpen(node)} testID="map-card" selected={node.id === selectedId} /> ))} From a6dd3fd66c6854b1932f5a789dc4d31aa521bc60 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 13:34:03 +0000 Subject: [PATCH 09/12] Describe the iPad layouts in the mobile agent guide Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 4ee041d69..6faa2cdf4 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -29,6 +29,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp - Sparring is the website's typed mode: `sparring.ts` sends the opponent's turns and the judge as `generateSparringTurn` and `judgeSparringSession` in `src/lib/ai/client.ts` do (the Sparring model settings, a transcript-only debate context), and the round's moves (turn order, the deadline clock, judging, saved rows) are `packages/core/src/sparring/session.ts`, shared with the website's sparring store. The server saves the judged round to `sparring_sessions` when it scores it; the app then files it in its debate with `attach_to_debate` and the student's side, as the store does. The side is asked when the round starts. The unfinished round is a session slot (`sparringDraft`), so leaving the screen keeps it, and a request cut off by leaving is offered again. The flow button opens the Flowchart on the Current Debate's map, above the round, which keeps running. Practice lists the Current Debate's judged rounds and the Debate tab lists them by side; both open `sparring/[id]`, which can delete them. Voice rounds need the development build, so the voice switch shows disabled. - Flowchart mirrors the website's `FlowchartPage.tsx` and the session store's `saveFlowchart` and `deleteSavedFlowchart` without React Flow: the saved `flowcharts` row keeps the website's React Flow node and edge JSON, and `packages/core/src/flowchart.ts` reads and edits it (starter map, edge kinds from the label or stroke the website writes, role columns, the tree, and edits that keep every field they do not touch). `flowcharts.ts` reads and writes the rows; Save over an opened map updates it, Save as inserts a row as the website's Save does. `flowchart-drafts.ts` holds each debate's open map and unsaved edits in session memory, shared by the screen and its sheets. Maps follow `activeDebateId`: one-off maps are saved with no debate, as on the website. The website's PNG export is a PDF of the Map tree here (`flowchartExportHtml`). - One-off mode is session-only and never written, as on the website; only maps saved in it are kept, with no debate, as the website keeps them. The switcher turns it on with or without a Current Debate, and `OneOffNotice` shows it on the Debate tab, Prepare, Ask, Library and the Generate, Attack, Brainstorm, 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. +- 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). 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. From 4e6eb5988925213ae2f1f2ffc927e121c39abb46 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 13:48:08 +0000 Subject: [PATCH 10/12] Check the document outline's jump in the screenshot run On a wide iPad window the run taps Case Structure in the rail, waits for it to become the current part and shoots the scrolled case. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/scripts/screenshots.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index 3a20d7347..78453e565 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -432,6 +432,19 @@ 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 the case and marks that part current. + const outline = page.getByTestId('document-outline'); + if (await outline.isVisible()) { + const target = outline.getByRole('menuitem').filter({ hasText: /^(Case Structure|Eset Struktúra|Estructura del Caso)$/ }); + await target.click(); + await page.waitForFunction( + () => Array.from(document.querySelectorAll('[data-testid="document-outline"] [role="menuitem"]')).find((item) => item.getAttribute('aria-selected') === 'true')?.textContent?.match(/Case Structure|Eset Struktúra|Estructura del Caso/), + undefined, + { timeout: 5_000 }, + ); + await page.waitForTimeout(300); + 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`); From e61ccf193e1ce0257b8aad4502daf29dc20df2e1 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 13:55:49 +0000 Subject: [PATCH 11/12] Check the outline jump by where the heading lands React Native Web renders no aria-selected on menu items, so the run checks that Case Structure's heading moved to the top of the case. A run that skips the sign-in screens no longer says the demo code is missing. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/scripts/screenshots.ts | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index 78453e565..3d33a7b38 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -432,17 +432,16 @@ 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 the case and marks that part current. + // 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 target = outline.getByRole('menuitem').filter({ hasText: /^(Case Structure|Eset Struktúra|Estructura del Caso)$/ }); - await target.click(); - await page.waitForFunction( - () => Array.from(document.querySelectorAll('[data-testid="document-outline"] [role="menuitem"]')).find((item) => item.getAttribute('aria-selected') === 'true')?.textContent?.match(/Case Structure|Eset Struktúra|Estructura del Caso/), - undefined, - { timeout: 5_000 }, - ); - await page.waitForTimeout(300); + 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. @@ -1166,8 +1165,8 @@ async function main() { let shots = 0; try { const session = DEMO_CODE ? await signIn(browser, problems) : null; - if (session && wants('sign-in')) await checkOfflineColdStart(browser, session, problems); - else console.log('STAGING_DEMO_CODE is not set: signed-in screens skipped'); + if (!session) console.log('STAGING_DEMO_CODE is not set: signed-in screens skipped'); + else if (wants('sign-in')) await checkOfflineColdStart(browser, session, problems); for (const [device, options] of Object.entries(DEVICES)) { for (const scheme of SCHEMES) { From bc39729e942ba751e52c7719025bcb94bfea5481 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 16:53:55 +0000 Subject: [PATCH 12/12] Close the docked claim when the account changes The iPad inspector kept a card from the debate and map it was opened on, but not the account, while the Library's split pane checks all three. Drafts are keyed by debate only, so check the account too. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/flowchart/index.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/app/flowchart/index.tsx b/apps/mobile/src/app/flowchart/index.tsx index cf7dbb91e..3e1a0a67f 100644 --- a/apps/mobile/src/app/flowchart/index.tsx +++ b/apps/mobile/src/app/flowchart/index.tsx @@ -71,8 +71,8 @@ export default function FlowchartScreen() { const [menu, setMenu] = useState<'export' | null>(null); const [notice, setNoticeState] = useState(''); const layout = useLayoutClass(); - // The card docked in the iPad inspector, with the map it was opened on. - const [claim, setClaim] = useState<{ scope: string; map: string; node: string } | null>(null); + // The card docked in the iPad inspector, with the account and map it was opened on. + const [claim, setClaim] = useState<{ userId: string; scope: string; map: string; node: string } | null>(null); const live = useRef(true); const owner = useRef(userId); @@ -146,13 +146,13 @@ export default function FlowchartScreen() { const openCard = (node: FlowNode) => { if (!scope || !open) return; if (layout !== 'phone') { - setClaim({ scope, map: open.draft.handle, node: node.id }); + if (userId) setClaim({ userId, scope, map: open.draft.handle, node: node.id }); return; } router.push({ pathname: '/flowchart/claim', params: { scope, map: open.draft.handle, node: node.id } }); }; - // The inspector shows only a card of the map on screen; another map or debate closes it. - const docked = layout !== 'phone' && claim && claim.scope === scope && claim.map === open?.draft.handle ? claim : null; + // The inspector shows only a card of the map on screen; another account, map or debate closes it. + const docked = layout !== 'phone' && claim && claim.userId === userId && claim.scope === scope && claim.map === open?.draft.handle ? claim : null; /** Runs a save or delete for the map on screen, as the account and map were when the student chose it. */ const act = async (work: (forUser: string, at: { scope: string; key: string; draft: FlowchartDraft }) => Promise) => {