diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 0d9febb40..72e5f9ba0 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -13,6 +13,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp - Google sign-in needs a development build, because Expo Go cannot receive OAuth redirects. Until then, sign-in is email code or demo code. - Native code has no DOM. eslint rejects `window`, `document`, `localStorage`, `sessionStorage` and `location` outside `*.web.ts(x)` files. A `.web.tsx` file exists only for the web preview. - `ios/` and `android/` are generated and gitignored. Configure native behaviour in `app.json`. +- A form sheet's root view is `SheetScrollView` (`src/components/sheet.tsx`), with nothing around it: no `KeyboardAvoidingView`, no wrapping `View`. react-native-screens positions only a scroll view that is the sheet's direct child; a nested one lands below the visible sheet and the sheet looks empty. ## Layout @@ -22,7 +23,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp - `sources.ts` mirrors the session store's `addSource` and `removeSource` and `src/lib/source-import.ts`. The Library always shows the Current Debate, also in one-off mode, as on the website. Adding a file (the website parses files in the browser) and Scan pages are not in the app yet. - Ask mirrors `src/lib/ai/streaming.ts` and `askChat` in `src/lib/ai/client.ts`: `ask.ts` sends the requests (plain questions stream from `ai-stream` through `expo/fetch`; with Citations, Web search, Pros/Cons or Argument map on, the whole answer comes from `ai`, because `ai-stream` refuses those options), `ai-request.ts` holds the shared POST with the website's headers, timeout and error kinds, `feature-config.ts` reads each tool's model from `user_settings.feature_configs`, `context-request.ts` builds the Current Debate context with the website's default selection and pins, and `chats.ts` saves finished exchanges to `chats` and `messages`. The SSE parser is shared in `packages/core/src/ai/sse.ts`. `markdown.ts` reads answers, because react-markdown needs the DOM. - Generate and Attack mirror `generateCase` and `attackCase` in `src/lib/ai/client.ts` and the session store's `recordResult`: `results.ts` sends the requests through `ai-request.ts`, checks the output with the website's transform (moved to `packages/core/src/ai/generate-output.ts`), keeps researched sources in the debate's Auto-saved folder (`packages/core/src/ai/discovered-sources.ts`), saves the server's source rankings onto the Library sources a result used (`packages/core/src/ai/source-assessments.ts`, also for Ask's whole answers), says when the server shortened the input, and saves rows to `cases` and `attacks`. `motion.ts` and `motion-gate.ts` are the website's motion gate. `open-results.ts` holds results made in this session, so one-off work and failed saves still open as documents. It and the last Generate and Attack forms live in `session-memory.ts`, which the auth provider clears on every account change, including sign-out. `components/document.tsx` draws results as documents (mock 03); Brainstorm and Factoids reuse it. Export renders `export-html.ts` to a PDF with expo-print and shares it (`export.ts`); the web preview cannot print, so `export.web.ts` keeps the HTML and copied text on `window` for the screenshot script. Word export and Google Docs are not in the app. -- One-off mode is session-only and never written, as on the website. Students prepare both sides of a motion, so a debate has no side: the Debate tab's In favor / Against toggle only chooses which side's work it shows, using the website's rule (`'both'` counts for either side, neutral and side-less work for neither). It starts on In favor and is never stored. +- One-off mode is session-only and never written, as on the website. The switcher turns it on with or without a Current Debate, and `OneOffNotice` shows it on the Debate tab, Prepare, Ask, Library, Generate and Attack (through `WorkingIn`). The Debate tab, Prepare and Library still list the Current Debate's work in one-off mode, as the website's home does. Students prepare both sides of a motion, so a debate has no side: the Debate tab's In favor / Against toggle only chooses which side's work it shows, using the website's rule (`'both'` counts for either side, neutral and side-less work for neither). It starts on In favor and is never stored. - `src/theme/`: colours from the mocks and the website's dark palette, Inter fonts. - `scripts/screenshots.ts`: web-export screenshots. `scripts/render-tab-icons.ts` redraws the tab bar PNGs. diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 13caf6d20..c68036b1a 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -9,7 +9,10 @@ "userInterfaceStyle": "automatic", "ios": { "bundleIdentifier": "com.adam.debatelab", - "supportsTablet": true + "supportsTablet": true, + "infoPlist": { + "CADisableMinimumFrameDurationOnPhone": true + } }, "android": { "adaptiveIcon": { diff --git a/apps/mobile/package.json b/apps/mobile/package.json index c1ef574cc..65d926152 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -11,6 +11,7 @@ "dependencies": { "@debatelab/core": "*", "@expo-google-fonts/inter": "^0.4.2", + "@formatjs/intl-pluralrules": "^6.3.15", "@react-native-async-storage/async-storage": "2.2.0", "@supabase/supabase-js": "^2.49.1", "aes-js": "^3.1.2", diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index 9628be503..c206daf5b 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -21,7 +21,10 @@ * therefore answer the app's user_settings reads with the run's language and * the System theme; nothing is written. The Debate tab shows the account's * real Current Debate, and its empty state comes from answering the - * current_debates read with no row. The Debate tab is shot once per side view. + * current_debates read with no row. The Debate tab is shot once per side view, + * and with one-off mode on, with and without a Current Debate; Prepare and + * Generate are shot in one-off mode too. One-off mode is in memory only; the + * script toggles it and never taps a debate row. * * Ask screenshots never call a model and never save a chat: the page answers * ai-stream from a fake stream inside the page (expo/fetch uses the page's @@ -149,12 +152,35 @@ async function shootDebateScreens(page: Page, prefix: string) { await page.waitForTimeout(700); await shoot(page, `${prefix}-debates-sheet`); + // One-off mode lives in memory only, so toggling it writes nothing. Never tap a debate row + // here: switching saves the pointer and last activity to staging. + await turnOnOneOff(page); + await shoot(page, `${prefix}-debates-oneoff`); + await closeSwitcher(page); + await page.getByTestId('one-off-notice').waitFor(); + await shoot(page, `${prefix}-debate-oneoff`); + await page.getByTestId('one-off-notice').getByRole('button').click(); + await page.getByTestId('one-off-notice').waitFor({ state: 'detached' }); + + await page.getByTestId('current-debate-pill').click(); + await page.getByTestId('debates-sheet').waitFor(); await page.getByRole('button', { name: /^(New debate|Új vita|Nuevo debate)$/ }).click(); await page.getByTestId('new-debate-sheet').waitFor(); await page.waitForTimeout(700); await shoot(page, `${prefix}-new-debate`); } +async function turnOnOneOff(page: Page) { + const toggle = page.getByTestId('debates-sheet').getByRole('switch'); + await toggle.click(); + if (!(await toggle.isChecked())) throw new Error('The one-off switch did not turn on'); +} + +async function closeSwitcher(page: Page) { + await page.getByTestId('debates-sheet').getByRole('button', { name: /^(Done|Kész|Listo)$/ }).click(); + await page.getByTestId('debates-sheet').waitFor({ state: 'detached' }); +} + // A made-up answer in the shape of mock 07, streamed a word at a time. const ASK_QUESTION = 'How do I answer "what about the nurse who drives in at 5 a.m."?'; const ASK_STREAM = 'Concede the edge case, then shrink it. Three moves:\n\n1. **Exemptions exist.** Every real ban permits shift workers and carers.\n2. **Night buses follow demand.** Cities that closed their centres added routes in the first year.\n3. **Flip it.** The same nurse breathes the air on her walk from the car park.'; @@ -433,6 +459,34 @@ async function shootAskScreens(context: BrowserContext, prefix: string, language await streaming.close(); } +/** Prepare and Generate in one-off mode, reached in the app so the in-memory mode survives. Nothing is sent or saved. */ +async function shootOneOffTools(page: Page, prefix: string) { + await page.goto(`${ORIGIN}/`); + // Opening the switcher takes the tab out of focus, which drops a load still running; let it finish first. + await page.getByTestId('current-debate-motion').waitFor({ timeout: 30_000 }); + await page.waitForFunction(() => !document.querySelector('[data-testid="debate-counts"]')?.textContent?.includes('–')); + await settle(page); + await page.getByTestId('current-debate-pill').click(); + await page.getByTestId('debates-sheet').waitFor(); + await settle(page); + await turnOnOneOff(page); + await closeSwitcher(page); + await page.getByRole('tab', { name: /^(Prepare|Felkészülés|Preparar)$/ }).click(); + await page.getByTestId('prepare-tool-generate').waitFor({ timeout: 30_000 }); + // The Debate tab stays mounted behind Prepare with its own notice; only the visible one is Prepare's. + await page.getByTestId('one-off-notice').filter({ visible: true }).waitFor(); + await settle(page); + await shoot(page, `${prefix}-prepare-oneoff`); + await page.getByTestId('prepare-tool-generate').click(); + await page.getByTestId('generate-motion').waitFor({ timeout: 30_000 }); + const notice = page.getByTestId('generate-screen').getByTestId('one-off-notice'); + await notice.waitFor(); + await shoot(page, `${prefix}-generate-oneoff`); + // Return to debate turns the mode off in place, and the strip says where work goes again. + await notice.getByRole('button').click(); + await page.getByTestId('generate-screen').getByTestId('working-in').waitFor(); +} + async function shootEmptyDebate(page: Page, prefix: string) { await page.route(`${REST}/current_debates*`, (route) => route.fulfill({ status: 200, contentType: 'application/json', json: [] }), @@ -441,6 +495,18 @@ async function shootEmptyDebate(page: Page, prefix: string) { await page.getByTestId('current-debate-pill').waitFor({ timeout: 30_000 }); await settle(page); await shoot(page, `${prefix}-debate-empty`); + + // One-off mode works without a Current Debate too; there is nothing to return to. + await page.getByTestId('current-debate-pill').click(); + await page.getByTestId('debates-sheet').waitFor(); + await settle(page); + await turnOnOneOff(page); + await closeSwitcher(page); + await page.getByTestId('one-off-notice').waitFor(); + if ((await page.getByTestId('one-off-notice').getByRole('button').count()) > 0) { + throw new Error('One-off mode offered a return without a Current Debate'); + } + await shoot(page, `${prefix}-debate-empty-oneoff`); } async function signIn(browser: Browser, problems: string[]): Promise { @@ -555,7 +621,7 @@ async function main() { await shoot(tabPage, `${prefix}-failed`); throw error; }); - shots += 4; + shots += 6; await shootLibraryScreens(tabPage, prefix).catch(async (error: unknown) => { await shoot(tabPage, `${prefix}-failed`); throw error; @@ -573,11 +639,17 @@ async function main() { watchErrors(emptyPage, problems, `${prefix} empty`); await pinSettings(emptyPage, language); await shootEmptyDebate(emptyPage, prefix); - shots += 1; + shots += 2; await shootAskScreens(signedIn, prefix, language, problems); shots += 3; await shootResultScreens(signedIn, prefix, language, problems); shots += 9; + const oneOffPage = await signedIn.newPage(); + watchErrors(oneOffPage, problems, `${prefix} one-off tools`); + await pinSettings(oneOffPage, language); + await shootOneOffTools(oneOffPage, prefix); + await oneOffPage.close(); + shots += 2; await signedIn.close(); } } diff --git a/apps/mobile/src/app/(tabs)/(debate)/index.tsx b/apps/mobile/src/app/(tabs)/(debate)/index.tsx index d96f3b6bc..95eec2b0a 100644 --- a/apps/mobile/src/app/(tabs)/(debate)/index.tsx +++ b/apps/mobile/src/app/(tabs)/(debate)/index.tsx @@ -8,6 +8,7 @@ import { MainActionButton, TextButton } from '@/components/buttons'; import { Card, IconTile } from '@/components/card'; import { Icon } from '@/components/icon'; import type { IconName } from '@/components/icon-paths'; +import { OneOffNotice } from '@/components/one-off-notice'; import { PREPARE_TOOLS } from '@/components/prepare-tools'; import { Screen } from '@/components/screen'; import { SegmentedControl } from '@/components/segmented-control'; @@ -31,7 +32,7 @@ const RECENT_LIMIT = 3; export default function DebateScreen() { const { t } = useTranslation(); const router = useRouter(); - const { status, current, oneOff, setOneOff, refresh } = useDebates(); + const { status, current, refresh } = useDebates(); // Debates switched on the website show up when the tab comes back into view. const focusedBefore = useRef(false); @@ -69,6 +70,7 @@ export default function DebateScreen() { return ( + {t('debate.noCurrentTitle')} @@ -85,7 +87,7 @@ export default function DebateScreen() { return ( - {oneOff ? setOneOff(false)} /> : null} + {current.motion} @@ -112,19 +114,6 @@ function SwitcherPill({ onPress }: { onPress: () => void }) { ); } -function OneOffNotice({ onReturn }: { onReturn: () => void }) { - const { t } = useTranslation(); - const { colors } = useTheme(); - return ( - - - {t('debate.oneOffStrip')} - - - - ); -} - /** * Counts, the main action, the Prepare tools and recent work for one debate. * Students prepare both sides, so the toggle only chooses which side's work is @@ -317,16 +306,7 @@ const styles = StyleSheet.create({ marginTop: 4, }, pillLabel: { fontSize: 15 }, - notice: { - marginTop: 12, - borderWidth: 1, - borderStyle: 'dashed', - borderRadius: radius.field, - paddingHorizontal: 14, - paddingTop: 10, - alignItems: 'flex-start', - }, - noticeText: { fontSize: 14, lineHeight: 19 }, + notice: { marginTop: 12 }, emptyText: { gap: 8 }, emptyTitle: { fontSize: 28, lineHeight: 34, letterSpacing: -0.6 }, motion: { fontSize: 27, lineHeight: 32, letterSpacing: -0.6, marginTop: 12, marginBottom: 15 }, diff --git a/apps/mobile/src/app/(tabs)/(library)/library.tsx b/apps/mobile/src/app/(tabs)/(library)/library.tsx index efb12a582..43a74edba 100644 --- a/apps/mobile/src/app/(tabs)/(library)/library.tsx +++ b/apps/mobile/src/app/(tabs)/(library)/library.tsx @@ -9,6 +9,7 @@ import { Card, IconTile } from '@/components/card'; import { Chip } from '@/components/chip'; import { Icon } from '@/components/icon'; import { LibraryHeader, type AddSourceKind } from '@/components/library-header'; +import { OneOffNotice } from '@/components/one-off-notice'; import { Screen } from '@/components/screen'; import { SearchField } from '@/components/search-field'; import { SOURCE_ICONS, sourceMeta } from '@/components/source-display'; @@ -47,6 +48,8 @@ export default function LibraryScreen() { return ( + {/* New sources still go to the Current Debate in one-off mode, as on the website, so this only says the mode is on. */} + {status === 'loading' ? : null} {status !== 'loading' && !debateId ? ( diff --git a/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx b/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx index b3da5f601..5885cfe17 100644 --- a/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx +++ b/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx @@ -6,6 +6,7 @@ import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; import { TextButton } from '@/components/buttons'; import { Card, IconTile } from '@/components/card'; import { Icon } from '@/components/icon'; +import { OneOffNotice } from '@/components/one-off-notice'; import { PREPARE_TOOLS, type PrepareTool } from '@/components/prepare-tools'; import { Screen } from '@/components/screen'; import { ScreenTitle } from '@/components/screen-title'; @@ -14,6 +15,7 @@ import { WorkRow, workHref } from '@/components/work-row'; import { useAuth } from '@/lib/auth'; import { useDebates } from '@/lib/debate-context'; import { fetchPrepareWork, type DebateItem } from '@/lib/debates'; +import { keepIfSame } from '@/lib/keep-if-same'; import { useTheme } from '@/theme/use-theme'; const SAVED_LIMIT = 30; @@ -25,7 +27,7 @@ export default function PrepareScreen() { const { colors } = useTheme(); const { session } = useAuth(); const userId = session?.user.id ?? null; - const { current, oneOff } = useDebates(); + const { current } = useDebates(); const debateId = current?.id ?? null; // Each load belongs to the debate it was read for, so another debate's results never show, or open, under this one. const [loaded, setLoaded] = useState<{ debateId: string; items: DebateItem[] | null; failed: boolean } | null>(null); @@ -45,7 +47,8 @@ export default function PrepareScreen() { try { const next = await fetchPrepareWork(userId, forDebate, SAVED_LIMIT); if (!isCurrent()) return; - setLoaded({ debateId: forDebate, items: next, failed: false }); + // Every focus rereads the list; an unchanged one keeps its identity, so the rows do not re-render. + setLoaded((previous) => keepIfSame(previous, { debateId: forDebate, items: next, failed: false })); } catch (cause) { if (!isCurrent()) return; console.warn('Could not load saved results:', cause instanceof Error ? cause.message : cause); @@ -66,6 +69,7 @@ export default function PrepareScreen() { return ( + {PREPARE_TOOLS.map((tool, index) => ( {t('mobile.prepare.saved')} - {oneOff || !debateId ? ( + {/* The Current Debate's results stay listed in one-off mode, as on the Debate tab and the website's home. */} + {!debateId ? ( - {oneOff ? t('debate.oneOffStrip') : t('debate.noCurrentStrip')} + {t('debate.noCurrentStrip')} ) : failed ? ( diff --git a/apps/mobile/src/app/add-link.tsx b/apps/mobile/src/app/add-link.tsx index 37ad6a44b..0d141eb40 100644 --- a/apps/mobile/src/app/add-link.tsx +++ b/apps/mobile/src/app/add-link.tsx @@ -2,12 +2,12 @@ import type { SourceImportOutput } from '@debatelab/core/ai/schemas/source-impor import { useRouter } from 'expo-router'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { StyleSheet, View } from 'react-native'; import { MainActionButton, PrimaryButton, TextButton } from '@/components/buttons'; import { Card } from '@/components/card'; import { Field } from '@/components/field'; +import { SheetScrollView } from '@/components/sheet'; import { Text } from '@/components/text'; import { useAuth } from '@/lib/auth'; import { useDebates } from '@/lib/debate-context'; @@ -22,7 +22,6 @@ import { useTheme } from '@/theme/use-theme'; export default function AddLinkSheet() { const { t } = useTranslation(); const router = useRouter(); - const insets = useSafeAreaInsets(); const { colors } = useTheme(); const { session } = useAuth(); const { current } = useDebates(); @@ -73,78 +72,72 @@ export default function AddLinkSheet() { }; return ( - - - - - {t('mobile.library.pasteLink')} - - router.back()} disabled={busy === 'save'} /> - + + + + {t('mobile.library.pasteLink')} + + router.back()} disabled={busy === 'save'} /> + - {!current ? ( - - {t('debate.libraryNeedsDebate')} - - ) : ( - <> - { - setUrl(next); - setDraft(null); - }} - keyboardType="url" - autoCapitalize="none" - autoCorrect={false} - autoFocus - returnKeyType="go" - onSubmitEditing={() => void runImport()} - editable={busy === null} - /> - {!draft ? ( - void runImport()} /> - ) : ( - <> - - {t('libraryPage.importPreview')} + {!current ? ( + + {t('debate.libraryNeedsDebate')} + + ) : ( + <> + { + setUrl(next); + setDraft(null); + }} + keyboardType="url" + autoCapitalize="none" + autoCorrect={false} + autoFocus + returnKeyType="go" + onSubmitEditing={() => void runImport()} + editable={busy === null} + /> + {!draft ? ( + void runImport()} /> + ) : ( + <> + + {t('libraryPage.importPreview')} + + + + + {`${draft.domain} · ${draft.wordCount} ${t('libraryPage.words')}`} - - - - {`${draft.domain} · ${draft.wordCount} ${t('libraryPage.words')}`} + {draft.excerpt ? ( + + {draft.excerpt} - {draft.excerpt ? ( - - {draft.excerpt} - - ) : null} - - void save()} /> - - )} - - )} + ) : null} + + void save()} /> + + )} + + )} - {error ? ( - - {error} - - ) : null} - - + {error ? ( + + {error} + + ) : null} + ); } const styles = StyleSheet.create({ - root: { flex: 1 }, - content: { paddingHorizontal: 20, paddingTop: 22, gap: 16, width: '100%', maxWidth: 640, alignSelf: 'center' }, + content: { gap: 16 }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, title: { fontSize: 21, lineHeight: 26, letterSpacing: -0.3, flexShrink: 1 }, body: { fontSize: 15, lineHeight: 21 }, diff --git a/apps/mobile/src/app/ask.tsx b/apps/mobile/src/app/ask.tsx index 0d432ced5..9b9678d0c 100644 --- a/apps/mobile/src/app/ask.tsx +++ b/apps/mobile/src/app/ask.tsx @@ -11,6 +11,7 @@ import { TextButton } from '@/components/buttons'; import { Chip } from '@/components/chip'; import { Icon } from '@/components/icon'; import type { IconName } from '@/components/icon-paths'; +import { OneOffNotice } from '@/components/one-off-notice'; import { Text } from '@/components/text'; import { AIError, isAbortError, type AIErrorKind, type ResponseExtras } from '@/lib/ai-request'; import { askOnce, canStream, isFinalAskError, isMessageWithinBudget, streamAsk, type AskOptions } from '@/lib/ask'; @@ -353,7 +354,9 @@ export default function AskScreen() { contentContainerStyle={styles.messagesContent} keyboardShouldPersistTaps="handled" keyboardDismissMode="interactive" - onContentSizeChange={() => scroll.current?.scrollToEnd({ animated: true })}> + // A streamed answer grows every 30 ms; restarting an animated scroll each time stutters. + onContentSizeChange={() => scroll.current?.scrollToEnd({ animated: streaming === null })}> + {messages.length === 0 && streaming === null && !busy ? ( diff --git a/apps/mobile/src/app/debates.tsx b/apps/mobile/src/app/debates.tsx index fd2f11f17..753e3baab 100644 --- a/apps/mobile/src/app/debates.tsx +++ b/apps/mobile/src/app/debates.tsx @@ -1,12 +1,12 @@ import { useRouter } from 'expo-router'; import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Switch, TextInput, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { ActivityIndicator, Pressable, StyleSheet, Switch, TextInput, View } from 'react-native'; import { PrimaryButton, TextButton } from '@/components/buttons'; import { Card } from '@/components/card'; import { Icon } from '@/components/icon'; +import { SheetScrollView } from '@/components/sheet'; import { Text } from '@/components/text'; import { useAuth } from '@/lib/auth'; import { useDebates } from '@/lib/debate-context'; @@ -24,7 +24,6 @@ function normalize(text: string) { export default function DebatesSheet() { const { t } = useTranslation(); const router = useRouter(); - const insets = useSafeAreaInsets(); const { colors } = useTheme(); const { status, debates, current, oneOff, setOneOff, switchTo, refresh } = useDebates(); const { session } = useAuth(); @@ -76,11 +75,7 @@ export default function DebatesSheet() { }; return ( - + {t('debate.yourDebates')} @@ -180,7 +175,6 @@ export default function DebatesSheet() { router.push('/new-debate')} /> - + ); } const styles = StyleSheet.create({ - content: { paddingHorizontal: 20, paddingTop: 22, gap: 14, width: '100%', maxWidth: 640, alignSelf: 'center' }, + content: { gap: 14 }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, title: { fontSize: 21, lineHeight: 26, letterSpacing: -0.3, flexShrink: 1 }, search: { diff --git a/apps/mobile/src/app/delete-account.tsx b/apps/mobile/src/app/delete-account.tsx index 39a1d691c..d9a1fa9e9 100644 --- a/apps/mobile/src/app/delete-account.tsx +++ b/apps/mobile/src/app/delete-account.tsx @@ -2,11 +2,11 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { useRouter } from 'expo-router'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { StyleSheet, View } from 'react-native'; import { PrimaryButton, TextButton } from '@/components/buttons'; import { Field } from '@/components/field'; +import { SheetScrollView } from '@/components/sheet'; import { Text } from '@/components/text'; import { deleteAccount, deletionConfirmation } from '@/lib/account'; import { useAuth } from '@/lib/auth'; @@ -22,7 +22,6 @@ async function forgetAccount(userId: string) { export default function DeleteAccountSheet() { const { t } = useTranslation(); const router = useRouter(); - const insets = useSafeAreaInsets(); const { colors } = useTheme(); const { session, signOut } = useAuth(); const email = session?.user.email; @@ -51,59 +50,51 @@ export default function DeleteAccountSheet() { }; return ( - - - - - {t('settings.deleteAccountDialogTitle')} + + + + {t('settings.deleteAccountDialogTitle')} + + router.back()} disabled={deleting} /> + + + {t('settings.deleteAccountDialogDesc', { email: email || t('settings.unknownUser') })} + + void remove()} + /> + {failed ? ( + + + {t('settings.deleteAccountFailed')} + + + {t('mobile.deleteAccount.failedHint')} - router.back()} disabled={deleting} /> - - {t('settings.deleteAccountDialogDesc', { email: email || t('settings.unknownUser') })} - - void remove()} - /> - {failed ? ( - - - {t('settings.deleteAccountFailed')} - - - {t('mobile.deleteAccount.failedHint')} - - - ) : null} - void remove()} - /> - - + ) : null} + void remove()} + /> + ); } const styles = StyleSheet.create({ - root: { flex: 1 }, - content: { paddingHorizontal: 20, paddingTop: 22, gap: 18, width: '100%', maxWidth: 640, alignSelf: 'center' }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, title: { fontSize: 21, lineHeight: 26, letterSpacing: -0.3, flexShrink: 1 }, body: { fontSize: 15, lineHeight: 21 }, diff --git a/apps/mobile/src/app/new-debate.tsx b/apps/mobile/src/app/new-debate.tsx index 3760d78f4..3d1f46a26 100644 --- a/apps/mobile/src/app/new-debate.tsx +++ b/apps/mobile/src/app/new-debate.tsx @@ -1,13 +1,13 @@ import { useRouter } from 'expo-router'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Pressable, StyleSheet, View } from 'react-native'; import { MainActionButton, TextButton } from '@/components/buttons'; import { Card } from '@/components/card'; import { Field } from '@/components/field'; import { Icon } from '@/components/icon'; +import { SheetScrollView } from '@/components/sheet'; import { Text } from '@/components/text'; import { useDebates } from '@/lib/debate-context'; import { DEBATE_FORMATS, MAX_MOTION_LENGTH, type DebateFormat } from '@/lib/debates'; @@ -17,7 +17,6 @@ import { useTheme } from '@/theme/use-theme'; export default function NewDebateSheet() { const { t } = useTranslation(); const router = useRouter(); - const insets = useSafeAreaInsets(); const { colors } = useTheme(); const { create } = useDebates(); const [motion, setMotion] = useState(''); @@ -43,84 +42,75 @@ export default function NewDebateSheet() { }; return ( - - - - - {t('debate.startNew')} - - router.back()} disabled={saving} /> - - - {t('debate.startNewDesc')} + + + + {t('debate.startNew')} + router.back()} disabled={saving} /> + + + {t('debate.startNewDesc')} + - + - - - {t('debate.format')} - - - {DEBATE_FORMATS.map((value, index) => { - const selected = value === format; - return ( - setFormat(value)} - style={({ pressed }) => [ - styles.option, - index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, - pressed && styles.pressed, - ]}> - - {t(`debate.formats.${value}`)} - - {selected ? : null} - - ); - })} - - + + + {t('debate.format')} + + + {DEBATE_FORMATS.map((value, index) => { + const selected = value === format; + return ( + setFormat(value)} + style={({ pressed }) => [ + styles.option, + index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, + pressed && styles.pressed, + ]}> + + {t(`debate.formats.${value}`)} + + {selected ? : null} + + ); + })} + + - {error ? ( - - {error} - - ) : null} + {error ? ( + + {error} + + ) : null} - void submit()} - /> - - + void submit()} + /> + ); } const styles = StyleSheet.create({ - root: { flex: 1 }, - content: { paddingHorizontal: 20, paddingTop: 22, gap: 18, width: '100%', maxWidth: 640, alignSelf: 'center' }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, title: { fontSize: 21, lineHeight: 26, letterSpacing: -0.3, flexShrink: 1 }, body: { fontSize: 15, lineHeight: 21 }, diff --git a/apps/mobile/src/app/new-note.tsx b/apps/mobile/src/app/new-note.tsx index 4e89c5907..22874dd59 100644 --- a/apps/mobile/src/app/new-note.tsx +++ b/apps/mobile/src/app/new-note.tsx @@ -1,11 +1,11 @@ import { useRouter } from 'expo-router'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { StyleSheet, View } from 'react-native'; import { MainActionButton, TextButton } from '@/components/buttons'; import { Field } from '@/components/field'; +import { SheetScrollView } from '@/components/sheet'; import { Text } from '@/components/text'; import { useAuth } from '@/lib/auth'; import { useDebates } from '@/lib/debate-context'; @@ -16,7 +16,6 @@ import { useTheme } from '@/theme/use-theme'; export default function NewNoteSheet() { const { t } = useTranslation(); const router = useRouter(); - const insets = useSafeAreaInsets(); const { colors } = useTheme(); const { session } = useAuth(); const { current } = useDebates(); @@ -43,57 +42,50 @@ export default function NewNoteSheet() { }; return ( - - - - - {t('mobile.library.writeNote')} - - router.back()} disabled={saving} /> - + + + + {t('mobile.library.writeNote')} + + router.back()} disabled={saving} /> + - {!current ? ( - - {t('debate.libraryNeedsDebate')} - - ) : ( - <> - - - {error ? ( - - {error} - - ) : null} - void save()} /> - - )} - - + {!current ? ( + + {t('debate.libraryNeedsDebate')} + + ) : ( + <> + + + {error ? ( + + {error} + + ) : null} + void save()} /> + + )} + ); } const styles = StyleSheet.create({ - root: { flex: 1 }, - content: { paddingHorizontal: 20, paddingTop: 22, gap: 18, width: '100%', maxWidth: 640, alignSelf: 'center' }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, title: { fontSize: 21, lineHeight: 26, letterSpacing: -0.3, flexShrink: 1 }, body: { fontSize: 15, lineHeight: 21 }, diff --git a/apps/mobile/src/app/source/[id].tsx b/apps/mobile/src/app/source/[id].tsx index a9dc498db..c15de2591 100644 --- a/apps/mobile/src/app/source/[id].tsx +++ b/apps/mobile/src/app/source/[id].tsx @@ -2,12 +2,12 @@ import { useLocalSearchParams, useRouter } from 'expo-router'; import * as WebBrowser from 'expo-web-browser'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, Pressable, ScrollView, StyleSheet, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; import { TextButton } from '@/components/buttons'; import { Card, IconTile } from '@/components/card'; import { Icon } from '@/components/icon'; +import { SheetScrollView } from '@/components/sheet'; import { SOURCE_ICONS, sourceMeta } from '@/components/source-display'; import { Text } from '@/components/text'; import { useAuth } from '@/lib/auth'; @@ -22,7 +22,6 @@ function webUrl(origin: string | null): string | null { export default function SourceSheet() { const { t } = useTranslation(); const router = useRouter(); - const insets = useSafeAreaInsets(); const { colors } = useTheme(); const { id } = useLocalSearchParams<{ id: string }>(); const { session } = useAuth(); @@ -49,10 +48,7 @@ export default function SourceSheet() { const url = webUrl(source?.origin ?? null); return ( - + {source ? ( @@ -106,12 +102,12 @@ export default function SourceSheet() { )} - + ); } const styles = StyleSheet.create({ - content: { paddingHorizontal: 20, paddingTop: 18, gap: 14, width: '100%', maxWidth: 640, alignSelf: 'center' }, + content: { paddingTop: 18, gap: 14 }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, headerText: { flex: 1 }, kind: { flexDirection: 'row', alignItems: 'center', gap: 9 }, diff --git a/apps/mobile/src/components/one-off-notice.tsx b/apps/mobile/src/components/one-off-notice.tsx new file mode 100644 index 000000000..dedd8f784 --- /dev/null +++ b/apps/mobile/src/components/one-off-notice.tsx @@ -0,0 +1,46 @@ +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native'; + +import { TextButton } from '@/components/buttons'; +import { Text } from '@/components/text'; +import { useDebates } from '@/lib/debate-context'; +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +/** + * Shown while one-off mode is on, like the website's CurrentDebateStrip: the message always, + * and "Return to debate" only when there is a Current Debate to return to. + */ +export function OneOffNotice({ + message = 'debate.oneOffStrip', + style, +}: { + message?: 'debate.oneOffStrip' | 'debate.oneOffActive'; + style?: StyleProp; +}) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const { oneOff, current, setOneOff } = useDebates(); + if (!oneOff) return null; + return ( + + + {t(message)} + + {current ? setOneOff(false)} /> : null} + + ); +} + +const styles = StyleSheet.create({ + notice: { + borderWidth: 1, + borderStyle: 'dashed', + borderRadius: radius.field, + paddingHorizontal: 14, + paddingTop: 10, + alignItems: 'flex-start', + }, + noAction: { paddingBottom: 10 }, + text: { fontSize: 14, lineHeight: 19 }, +}); diff --git a/apps/mobile/src/components/sheet.tsx b/apps/mobile/src/components/sheet.tsx new file mode 100644 index 000000000..1704df017 --- /dev/null +++ b/apps/mobile/src/components/sheet.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from 'react'; +import { ScrollView, StyleSheet, type StyleProp, type ViewStyle } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { useTheme } from '@/theme/use-theme'; + +/** + * The scrolling body of a form sheet. It must be the sheet's root view: react-native-screens + * sizes a form sheet's scroll view natively, and only a direct child keeps its position. Wrapped + * in a KeyboardAvoidingView or any other view, it falls back to `setFrame(screen.frame)`, which + * copies the sheet's slide-in offset and leaves the content below the visible sheet. + * The sheet rises to its top detent when the keyboard opens; the insets scroll fields clear of it. + */ +export function SheetScrollView({ + children, + contentContainerStyle, + testID, +}: { + children: ReactNode; + contentContainerStyle?: StyleProp; + testID?: string; +}) { + const insets = useSafeAreaInsets(); + const { colors } = useTheme(); + return ( + + {children} + + ); +} + +const styles = StyleSheet.create({ + content: { paddingHorizontal: 20, paddingTop: 22, gap: 18, width: '100%', maxWidth: 640, alignSelf: 'center' }, +}); diff --git a/apps/mobile/src/components/tab-stack.tsx b/apps/mobile/src/components/tab-stack.tsx index 5b2b075ea..4bb6e1cf7 100644 --- a/apps/mobile/src/components/tab-stack.tsx +++ b/apps/mobile/src/components/tab-stack.tsx @@ -1,8 +1,13 @@ import { Stack } from 'expo-router'; +import { Platform } from 'react-native'; import { fontFamily } from '@/theme/fonts'; import { useTheme } from '@/theme/use-theme'; +// iOS 26 draws its own scroll edge effect under a transparent header; a blur on top of it +// doubles the effect (react-native-screens warns). Earlier iOS has no edge effect, so it keeps the blur. +const legacyHeaderBlur = Platform.OS === 'ios' && Number.parseInt(String(Platform.Version), 10) < 26; + /** Each tab is a native stack with an iOS large title, so later screens can push onto it. */ export function TabStack({ largeTitle = true }: { largeTitle?: boolean }) { const { colors } = useTheme(); @@ -12,7 +17,7 @@ export function TabStack({ largeTitle = true }: { largeTitle?: boolean }) { headerShown: largeTitle, headerLargeTitleEnabled: true, headerTransparent: true, - headerBlurEffect: 'systemMaterial', + headerBlurEffect: legacyHeaderBlur ? 'systemMaterial' : 'none', headerShadowVisible: false, headerLargeTitleShadowVisible: false, headerLargeStyle: { backgroundColor: 'transparent' }, diff --git a/apps/mobile/src/components/tool-form.tsx b/apps/mobile/src/components/tool-form.tsx index 9dce46cda..e100c4eab 100644 --- a/apps/mobile/src/components/tool-form.tsx +++ b/apps/mobile/src/components/tool-form.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { ActivityIndicator, StyleSheet, View } from 'react-native'; import { TextButton } from './buttons'; +import { OneOffNotice } from './one-off-notice'; import { Text } from './text'; import type { AIErrorKind } from '@/lib/ai-request'; @@ -25,18 +26,15 @@ export function ToolTitle({ title, subtitle }: { title: string; subtitle: string ); } -/** Where new work will be saved, as the website's CurrentDebateStrip says it. */ +/** Where new work will be saved, as the website's CurrentDebateStrip says it. In one-off mode it offers the way back to the Current Debate. */ export function WorkingIn() { const { t } = useTranslation(); const { colors } = useTheme(); const { current, oneOff } = useDebates(); + if (oneOff) return ; return ( - - {oneOff ? ( - - {t('debate.oneOffStrip')} - - ) : current ? ( + + {current ? ( {`${t('debate.workingIn')} `} @@ -108,7 +106,6 @@ const styles = StyleSheet.create({ title: { fontSize: 30, lineHeight: 36, letterSpacing: -0.8 }, subtitle: { fontSize: 16, lineHeight: 22 }, strip: { borderWidth: 1, borderRadius: radius.field, paddingHorizontal: 14, paddingVertical: 10 }, - dashed: { borderStyle: 'dashed' }, stripText: { fontSize: 14.5, lineHeight: 20 }, error: { borderWidth: 1, borderRadius: radius.card, padding: 14, gap: 4 }, errorTitle: { fontSize: 16 }, diff --git a/apps/mobile/src/lib/debate-context.tsx b/apps/mobile/src/lib/debate-context.tsx index bda43d62f..40b013f72 100644 --- a/apps/mobile/src/lib/debate-context.tsx +++ b/apps/mobile/src/lib/debate-context.tsx @@ -10,6 +10,7 @@ import { type Debate, type DebateSettings, } from './debates'; +import { keepIfSame } from './keep-if-same'; type Status = 'loading' | 'ready' | 'error'; @@ -54,7 +55,8 @@ export function DebateProvider({ children }: { children: ReactNode }) { try { const next = await fetchDebates(forUser); if (owner.current !== forUser || refreshes.current !== request || writes.current !== writesBefore) return; - setDebates(next.debates); + // Refreshes run on every Debate tab focus; an unchanged list keeps its identity so the tabs do not re-render. + setDebates((previous) => keepIfSame(previous, next.debates)); setCurrentId(next.currentId); setStatus('ready'); } catch (cause) { diff --git a/apps/mobile/src/lib/i18n.ts b/apps/mobile/src/lib/i18n.ts index b41fa428b..d8de4c7d3 100644 --- a/apps/mobile/src/lib/i18n.ts +++ b/apps/mobile/src/lib/i18n.ts @@ -1,3 +1,11 @@ +// Hermes has no Intl.PluralRules, and without it i18next cannot pick `_one`/`_other` keys +// and shows the raw key. The polyfill only installs where it is missing, so the web keeps +// the browser's. It must load before i18next initialises below. +import '@formatjs/intl-pluralrules/polyfill.js'; +import '@formatjs/intl-pluralrules/locale-data/en.js'; +import '@formatjs/intl-pluralrules/locale-data/es.js'; +import '@formatjs/intl-pluralrules/locale-data/hu.js'; + import { isAppLanguage, type AppLanguage } from '@debatelab/core/languages'; import { getLocales } from 'expo-localization'; import i18n from 'i18next'; diff --git a/apps/mobile/src/lib/keep-if-same.ts b/apps/mobile/src/lib/keep-if-same.ts new file mode 100644 index 000000000..1c14e942e --- /dev/null +++ b/apps/mobile/src/lib/keep-if-same.ts @@ -0,0 +1,4 @@ +/** Focus refreshes usually reread unchanged data; returning the previous value keeps its identity, so nothing re-renders. */ +export function keepIfSame(previous: T, next: T): T { + return JSON.stringify(previous) === JSON.stringify(next) ? previous : next; +} diff --git a/docs/development.md b/docs/development.md index a1e306018..f6ba1c2a1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -116,20 +116,22 @@ The phone loads the app from a Metro dev server on the VPS over the tailnet. Sta ```bash PREVIEW=~/.claude/skills/tailnet-preview/scripts/preview -HOST=$(tailscale status --self --json | jq -r .Self.DNSName | sed 's/\.$//') +HOST=$(tailscale status --self --json | jq -r '.Self.TailscaleIPs[0]') cd apps/mobile $PREVIEW run 9230 "debatelab mobile expo-go" -- env REACT_NATIVE_PACKAGER_HOSTNAME=$HOST CI=1 npx expo start --port 9230 ``` -`REACT_NATIVE_PACKAGER_HOSTNAME` makes Metro put the tailnet name in the manifest, so Expo Go fetches the bundle from `http://:9230`. The firewall accepts all traffic on `tailscale0`, so the phone reaches that port directly. In Expo Go, enter `exp://:9230` as the URL, or open that link in Safari. `CI=1` stops Expo waiting for keyboard input in the background. To check the server without a phone: +`REACT_NATIVE_PACKAGER_HOSTNAME` makes Metro put the VPS's tailnet IP in the manifest, so Expo Go fetches the bundle from `http://:9230`. iOS allows plain HTTP to IP addresses on local networks, and the firewall accepts all traffic on `tailscale0`, so the phone reaches that port directly. In Expo Go, enter `exp://:9230` as the URL, or open that link in Safari. `CI=1` stops Expo waiting for keyboard input in the background. To check the server without a phone: ```bash curl -s -H 'expo-platform: ios' http://127.0.0.1:9230/ | jq -r .launchAsset.url ``` -The URL must start with `http://:9230/`. +The URL must start with `http://:9230/`. -If iOS refuses the plain-HTTP bundle URL because of App Transport Security, there are two fallbacks. Set `REACT_NATIVE_PACKAGER_HOSTNAME` to the tailnet IP (`tailscale status --self --json | jq -r '.Self.TailscaleIPs[0]'`), since iOS allows plain HTTP to IP addresses on local networks. Or use the HTTPS URL that `run` publishes (`https://:91xx`) by also setting `EXPO_PACKAGER_PROXY_URL=https://:91xx`, so the manifest points at the HTTPS proxy. +Do not use the MagicDNS name (`codex-remote..ts.net`) as the hostname. The VPS cannot resolve its own MagicDNS name, and Metro's CLI looks the hostname up while it runs: it crashes with `getaddrinfo ENOTFOUND` a few seconds after the phone connects. If you need HTTPS, use the URL that `run` publishes (`https://:91xx`) by also setting `EXPO_PACKAGER_PROXY_URL=https://:91xx`, so the manifest points at the HTTPS proxy. + +To sign the CLI in to an Expo account, run `npx expo login` from `apps/mobile`, where the Expo CLI is installed. Its `--browser` and `--sso` options hand the login back to a callback on `localhost` on the VPS, so a login opened in a laptop's browser cannot finish. Use the username and password prompt (`-u `, then the password on stdin with `-p -`). Stop it with `$PREVIEW down "debatelab mobile expo-go"` when you are done. diff --git a/package-lock.json b/package-lock.json index 1cb4e067a..51deaa67b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -120,6 +120,7 @@ "dependencies": { "@debatelab/core": "*", "@expo-google-fonts/inter": "^0.4.2", + "@formatjs/intl-pluralrules": "^6.3.15", "@react-native-async-storage/async-storage": "2.2.0", "@supabase/supabase-js": "^2.49.1", "aes-js": "^3.1.2", @@ -3761,6 +3762,37 @@ "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, + "node_modules/@formatjs/bigdecimal": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@formatjs/bigdecimal/-/bigdecimal-0.2.7.tgz", + "integrity": "sha512-B4+XY/e4u5v261r3a37Kq2ax91gPZSKGyVpx8X5dygAggLlUz+6MeBpmAhaBFGB8WQ4JB1qD+QtKHk9aJkEb9Q==", + "license": "MIT" + }, + "node_modules/@formatjs/fast-memoize": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.7.tgz", + "integrity": "sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==", + "license": "MIT" + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.9.0.tgz", + "integrity": "sha512-Z7TD3ssNry1Av1dVDoq/mzpxGyxqWwU0ClWEo+BB3k0JQTeQvUGmeK9WZGYvlqStS0UqkaKXk3dfjsb6NaHAxw==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "3.1.7" + } + }, + "node_modules/@formatjs/intl-pluralrules": { + "version": "6.3.15", + "resolved": "https://registry.npmjs.org/@formatjs/intl-pluralrules/-/intl-pluralrules-6.3.15.tgz", + "integrity": "sha512-/K6Up6SrvLcTP5mWVHp58A9Uk7I4jYbDYqtYdgKDeSY1HG0bhBMS4eVtqK2nFx8mWWdcHbt9ZqHBcCto573PNg==", + "license": "MIT", + "dependencies": { + "@formatjs/bigdecimal": "0.2.7", + "@formatjs/intl-localematcher": "0.9.0" + } + }, "node_modules/@gilbarbara/deep-equal": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/@gilbarbara/deep-equal/-/deep-equal-0.3.1.tgz",