diff --git a/config/scripts/mobile-web-app-session-external-links.test.mjs b/config/scripts/mobile-web-app-session-external-links.test.mjs new file mode 100644 index 00000000000..2a1a742c67c --- /dev/null +++ b/config/scripts/mobile-web-app-session-external-links.test.mjs @@ -0,0 +1,81 @@ +/** + * What the session screen may reach for a URL, and whose clipboard it writes. + * + * Inside the shell's WebView react-native-web's `Linking.openURL` calls + * `window.open(url, '_blank', 'noopener')`, which both shells refuse — iOS returns nil from + * `createWebViewWith`, Android false from `onCreateWindow` — and resolves whether or not anything + * opened. A call site left on that path reports success into a tap that did nothing, which is the + * one failure the `externalLink` grant exists to remove. + * + * This screen's openers are a terminal link tap whose open mode is the phone's browser, and the two + * WebView-backed readers it reaches through the file and Markdown panels, each of which sends a + * tapped link to the system browser rather than navigating the artifact away. + * + * The rule, not the three call sites it happens to have today: a module entering this closure later + * is held to it without anyone remembering to add it here. + */ +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { mobileWebAppRouteClosure } from './build-mobile-web-app-bundle.mjs' +import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' +import { + EXTERNAL_LINK_SEAM as SEAM, + externalLinkOffenders +} from './mobile-web-app-external-link-seam.mjs' + +const mobileDir = fileURLToPath(new URL('../../mobile/', import.meta.url)) +const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.skip + +const SESSION = 'app/h/[hostId]/session/[worktreeId].tsx' + +/** The clipboard seam, as the web build resolves it. */ +const CLIPBOARD_SEAM = 'src/platform/clipboard.web.ts' + +describeClosure( + 'the session screen closure', + () => { + it('opens every external URL through the platform seam', async () => { + const closure = await mobileWebAppRouteClosure(SESSION) + expect(externalLinkOffenders(mobileDir, closure)).toEqual([]) + }) + + it('contains the seam, so the rule above is not vacuous', async () => { + // Without this an empty offender list would also be what a closure that reaches no link code + // at all produces, and the census would pass against a page that opens nothing. + const closure = await mobileWebAppRouteClosure(SESSION) + expect(closure.local).toContain(SEAM) + expect(closure.local.length).toBeGreaterThan(900) + }) + }, + 240_000 +) + +/** + * The screen does not touch the clipboard through the browser's own. + * + * `expo-clipboard` resolves to `ExpoClipboard.web.js`, which is `navigator.clipboard`: it needs a + * secure context, and the iOS shell serves the page from a custom scheme while Android serves + * `https`, so that path works on one platform and silently not on the other. This screen is the + * heaviest clipboard user in the app — a quick command's body, a diff note, a Markdown document, a + * terminal selection, a structured send prompt, and the terminal's own paste — so all of it goes + * through the seam and none of it through the browser. + * + * Asserted as the module's absence from the closure rather than as a count of importers: a new + * import anywhere in the tree puts the file back, whoever writes it and whatever they name it. + */ +describeClosure( + 'the clipboard the session screen reaches', + () => { + it("does not carry expo-clipboard's web module at all", async () => { + const closure = await mobileWebAppRouteClosure(SESSION) + expect(closure.modules.filter((file) => file.endsWith('ExpoClipboard.web.js'))).toEqual([]) + }) + + it('carries the seam that replaced it, so the absence above is not vacuous', async () => { + // An empty list is also what a closure reaching no clipboard code at all would produce. + const closure = await mobileWebAppRouteClosure(SESSION) + expect(closure.local).toContain(CLIPBOARD_SEAM) + }) + }, + 240_000 +) diff --git a/config/scripts/mobile-web-app-session-text-inputs.test.mjs b/config/scripts/mobile-web-app-session-text-inputs.test.mjs new file mode 100644 index 00000000000..407e359da10 --- /dev/null +++ b/config/scripts/mobile-web-app-session-text-inputs.test.mjs @@ -0,0 +1,79 @@ +/** + * Every text input the session screen reaches, and the size it declares. + * + * iOS zooms the page on focus of any input under 16px and does not zoom back out, so the document + * spends the rest of that typing session at a scale other than 1 — which `keyboard-occlusion.web.ts` + * reads as "not a keyboard" on purpose, because geometry cannot separate a zoom from a keyboard. + * + * This closure is the one that cannot afford it. The terminal's own input is a hidden field the + * keyboard seam's geometry is the whole basis of, and this screen reaches nine inputs that declare + * a size — a custom-key capture field, a prompt modal, the chat's ask/composer/question fields, the + * quick-command editor and its search, and the terminal command bar. One of them left off the seam + * leaves every later focus on this screen measuring a zoomed document. + * + * The rule is the closure rather than the nine sites it happens to have today: a module entering it + * later is held to it without anyone remembering to add it here. + */ +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { mobileWebAppRouteClosure } from './build-mobile-web-app-bundle.mjs' +import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' +import { + TEXT_INPUT_FONT_SIZE_SEAM, + textInputFontSizeOffenders, + unresolvedTextInputStyles +} from './mobile-web-app-text-input-font-size-seam.mjs' + +const mobileDir = fileURLToPath(new URL('../../mobile/', import.meta.url)) +const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.skip + +const SESSION = 'app/h/[hostId]/session/[worktreeId].tsx' + +/** + * The two style modules this screen splits, as the page bundle resolves them. + * + * Named rather than left to the offender list because a split is the one fix that can be undone + * without reopening the offence: delete the `.web.ts` and the native sibling's size is what the + * page runs, which is a 15px chat composer and a zoomed document, and the offender list would say + * so — but only the next time someone reads it. Listed here, the closure says which file the page + * loads. + */ +const SPLIT_WEB_STYLES = [ + 'src/components/custom-key-input-styles.web.ts', + 'src/session/mobile-native-chat-input-styles.web.ts' +] + +describeClosure( + 'the text inputs the session screen reaches', + () => { + it('takes every input size through the seam', async () => { + const closure = await mobileWebAppRouteClosure(SESSION) + expect(textInputFontSizeOffenders(mobileDir, closure)).toEqual([]) + }) + + it('reads every input it found, so the list above is complete', async () => { + // The completeness half: an empty offender list is evidence only if every input was read. + const closure = await mobileWebAppRouteClosure(SESSION) + expect(unresolvedTextInputStyles(mobileDir, closure)).toEqual([]) + }) + + it('carries the seam, so the rule is not vacuous', async () => { + // Without this an empty offender list would also be what a closure reaching no text input at + // all produces, and the census would pass against a page that has nothing to raise. + const closure = await mobileWebAppRouteClosure(SESSION) + expect(closure.local).toContain(TEXT_INPUT_FONT_SIZE_SEAM) + expect(closure.local.length).toBeGreaterThan(900) + }) + + it('loads the web half of both split style modules, not the native one', async () => { + const closure = await mobileWebAppRouteClosure(SESSION) + expect(closure.local).toEqual(expect.arrayContaining(SPLIT_WEB_STYLES)) + expect( + closure.local.filter((file) => + SPLIT_WEB_STYLES.some((web) => file === web.replace('.web.ts', '.ts')) + ) + ).toEqual([]) + }) + }, + 240_000 +) diff --git a/mobile/src/components/CustomKeyModal.styles.ts b/mobile/src/components/CustomKeyModal.styles.ts index 991e557441f..a7502d93c29 100644 --- a/mobile/src/components/CustomKeyModal.styles.ts +++ b/mobile/src/components/CustomKeyModal.styles.ts @@ -1,5 +1,6 @@ import { StyleSheet } from 'react-native' import { colors, spacing, radii, typography } from '../theme/mobile-theme' +import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size' export const customKeyModalStyles = StyleSheet.create({ header: { @@ -153,19 +154,6 @@ export const customKeyModalStyles = StyleSheet.create({ chipGlyphSelected: { color: 'rgba(10,10,10,0.5)' }, - keyInput: { - width: '100%', - height: 56, - borderRadius: 10, - backgroundColor: colors.bgPanel, - borderWidth: 1, - borderColor: colors.borderSubtle, - color: colors.textPrimary, - fontFamily: typography.monoFamily, - fontSize: 22, - fontWeight: '600', - textAlign: 'center' - }, moreLink: { paddingVertical: spacing.sm, alignItems: 'center' @@ -240,7 +228,7 @@ export const customKeyModalStyles = StyleSheet.create({ borderRadius: radii.input, paddingHorizontal: spacing.md, paddingVertical: spacing.sm, - fontSize: 14, + fontSize: TEXT_INPUT_FONT_SIZE, fontFamily: typography.monoFamily, borderWidth: 1, borderColor: colors.borderSubtle diff --git a/mobile/src/components/CustomKeyModal.tsx b/mobile/src/components/CustomKeyModal.tsx index 598b17c6905..259a447acf6 100644 --- a/mobile/src/components/CustomKeyModal.tsx +++ b/mobile/src/components/CustomKeyModal.tsx @@ -12,6 +12,7 @@ import { type TerminalShortcutSpecialKey } from '../terminal/terminal-accessory-keys' import { customKeyModalStyles as styles } from './CustomKeyModal.styles' +import { customKeyInputStyles } from './custom-key-input-styles' const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys' @@ -293,7 +294,7 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc Key ({ + StyleSheet: { create: (styles: unknown) => styles } +})) + +// The seam as the page bundle resolves it. Without this the `.web.ts` style below would read the +// native seam and the test would pass on a size that no browser ever renders. +vi.mock( + '../platform/text-input-font-size', + async () => await import('../platform/text-input-font-size.web') +) + +import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size' +import { colors, typography } from '../theme/mobile-theme' +import { customKeyInputBase } from './custom-key-input-base-styles' +import { customKeyInputStyles } from './custom-key-input-styles' +import { customKeyInputStyles as customKeyInputStylesOnWeb } from './custom-key-input-styles.web' + +/** Below this an iOS browser zooms the page when an input takes focus, and does not zoom back. */ +const IOS_FOCUS_ZOOM_FLOOR = 16 + +/** Every property the capture field carried before it was split, read off the commit that split it. */ +const BEFORE_THE_SPLIT = { + width: '100%', + height: 56, + borderRadius: 10, + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle, + color: colors.textPrimary, + fontFamily: typography.monoFamily, + fontSize: 22, + fontWeight: '600', + textAlign: 'center' +} + +describe('the custom-key capture field natively', () => { + it('renders exactly what it rendered before the split, property for property', () => { + expect(customKeyInputStyles.keyInput).toEqual(BEFORE_THE_SPLIT) + // Key for key as well as value for value: `toEqual` would pass over an extra undefined. + expect(Object.keys(customKeyInputStyles.keyInput).sort()).toEqual( + Object.keys(BEFORE_THE_SPLIT).sort() + ) + }) +}) + +describe('the custom-key capture field on the web', () => { + it('takes its size from the seam, which clears the focus-zoom floor', () => { + expect(customKeyInputStylesOnWeb.keyInput.fontSize).toBe(TEXT_INPUT_FONT_SIZE) + expect(customKeyInputStylesOnWeb.keyInput.fontSize).toBeGreaterThanOrEqual(IOS_FOCUS_ZOOM_FLOOR) + }) + + /** + * The one input on this screen the seam lowers rather than raises, recorded rather than implied. + * + * 22 already clears the floor, so this move buys nothing for the keyboard seam; what it buys is + * that the census reads every size on the screen as a binding to one module. Written as a + * comparison rather than as the number 16, so a theme that raised the body size past 22 would + * make this fail and be read rather than silently reverse the direction. + */ + it('is the one field the move shrinks, and says so', () => { + expect(customKeyInputStylesOnWeb.keyInput.fontSize).toBeLessThan(BEFORE_THE_SPLIT.fontSize) + }) + + // The split is one value, not a second style: everything the siblings do not differ on comes from + // the same object, so a padding or a colour cannot drift between the platforms. + it('differs from the native style in nothing but the size', () => { + expect(customKeyInputBase).not.toHaveProperty('fontSize') + expect(customKeyInputStyles.keyInput).toMatchObject(customKeyInputBase) + expect(customKeyInputStylesOnWeb.keyInput).toMatchObject(customKeyInputBase) + expect(Object.keys(customKeyInputStylesOnWeb.keyInput).sort()).toEqual( + Object.keys(customKeyInputStyles.keyInput).sort() + ) + }) +}) diff --git a/mobile/src/components/custom-key-input-styles.web.ts b/mobile/src/components/custom-key-input-styles.web.ts new file mode 100644 index 00000000000..d1e9b7b7f35 --- /dev/null +++ b/mobile/src/components/custom-key-input-styles.web.ts @@ -0,0 +1,17 @@ +import { StyleSheet } from 'react-native' +import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size' +import { customKeyInputBase } from './custom-key-input-base-styles' + +/** + * Web sibling: the capture field goes on the text-input seam. + * + * Every other input on this screen is raised by that move; this one is lowered, from 22 to the + * seam's 16. Both sizes clear the floor below which iOS zooms the page on focus, so nothing about + * the keyboard seam turns on which of them renders — what turns on it is that the census reads the + * seam as a binding and not as a number, so there is no expression that keeps 22 here and still + * says where the size came from. A 56px box holding one capitalised character carries 16 legibly, + * and the alternative is a per-site exemption the next 14px input would inherit. + */ +export const customKeyInputStyles = StyleSheet.create({ + keyInput: { ...customKeyInputBase, fontSize: TEXT_INPUT_FONT_SIZE } +}) diff --git a/mobile/src/navigation/router-seam-census.test-support.ts b/mobile/src/navigation/router-seam-census.test-support.ts index 41f974620eb..28d634ad0a1 100644 --- a/mobile/src/navigation/router-seam-census.test-support.ts +++ b/mobile/src/navigation/router-seam-census.test-support.ts @@ -59,3 +59,47 @@ export function callsRouteHandoff(source: ts.SourceFile): boolean { ts.forEachChild(source, visit) return found } + +/** + * Every value name a module imports from expo-router, so a domain can say which ones it allows. + * + * The two landed censuses answer "no value import at all", which is the right rule for a domain + * whose only reach into expo-router is a router. The session domain's is not: eight of its hooks + * take `useFocusEffect` and two take `useLocalSearchParams`, neither of which can navigate, and a + * blanket rule there would have to be turned off rather than narrowed. + * + * Names rather than a boolean for `useRouter`, because the hazard is the category and not the one + * spelling of it: `import { router }` is expo-router's module singleton and navigates from anywhere, + * and a rule written against `useRouter` alone would have read it as clean. + * + * The imported name, not the local one: `import { useRouter as useAppRouter }` is the same import. + */ +export function expoRouterValueImports(source: ts.SourceFile): string[] { + const names = new Set() + for (const statement of source.statements) { + if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly === true) { + continue + } + const specifier = statement.moduleSpecifier + if (!ts.isStringLiteral(specifier) || specifier.text !== 'expo-router') { + continue + } + const bindings = statement.importClause?.namedBindings + if (bindings !== undefined && ts.isNamedImports(bindings)) { + for (const element of bindings.elements) { + if (element.isTypeOnly) { + continue + } + names.add((element.propertyName ?? element.name).text) + } + } + // A default or namespace import hands the whole module over under one name, router included. + if (statement.importClause?.name !== undefined) { + names.add('default') + } + if (bindings !== undefined && ts.isNamespaceImport(bindings)) { + names.add('*') + } + } + return [...names].sort() +} diff --git a/mobile/src/platform/text-input-font-size.test.ts b/mobile/src/platform/text-input-font-size.test.ts index 09f1f60967b..2a8486a0218 100644 --- a/mobile/src/platform/text-input-font-size.test.ts +++ b/mobile/src/platform/text-input-font-size.test.ts @@ -15,12 +15,14 @@ import { browserAddressFieldStyles } from '../browser/browser-address-field-styl import { mobileBrowserPaneStyles } from '../browser/mobile-browser-pane-styles' import { listStyles } from '../source-control/mobile-source-control-list-styles' import { mobileDiffReviewControlStyles } from '../components/mobile-diff-review-control-styles' +import { customKeyModalStyles } from '../components/CustomKeyModal.styles' +import { mobileSessionCommandInputStyles } from '../session/mobile-session-command-input-styles' import { typography } from '../theme/mobile-theme' import { TEXT_INPUT_FONT_SIZE } from './text-input-font-size' import { TEXT_INPUT_FONT_SIZE as WEB_TEXT_INPUT_FONT_SIZE } from './text-input-font-size.web' /** - * The size the two page-served text inputs carry, on each platform. + * The size every page-served text input carries, on each platform. * * Both halves are asserted from here because a node test resolves the native sibling, so the web * value cannot be read off the style object: the bundler is what swaps the module, and that swap @@ -31,7 +33,15 @@ const MOBILE_ROOT = join(import.meta.dirname, '..', '..') const STYLE_MODULES = [ 'src/source-control/mobile-source-control-list-styles.ts', 'src/components/mobile-diff-review-control-styles.ts', - 'src/browser/mobile-browser-pane-styles.ts' + 'src/browser/mobile-browser-pane-styles.ts', + // The session screen's six, which declare the app's body size and so need no sibling: the + // native seam is that size, so the move is the same number and the swap is the whole change. + 'src/components/CustomKeyModal.styles.ts', + 'src/components/TextInputModal.tsx', + 'src/session/MobileNativeChatAsk.tsx', + 'src/session/QuickCommandEditorForm.tsx', + 'src/session/QuickCommandsList.tsx', + 'src/session/mobile-session-command-input-styles.ts' ] /** @@ -41,7 +51,13 @@ const STYLE_MODULES = [ * seam's value on both platforms the way the four above do. Its web half is where the raise lives, * and that is the file that has to carry the binding. */ -const SPLIT_STYLE_MODULES = ['src/browser/browser-address-field-styles.web.ts'] +const SPLIT_STYLE_MODULES = [ + 'src/browser/browser-address-field-styles.web.ts', + // The session screen's two: a capture field at 22 and the chat's two fields at 15, none of + // which is the body size, so each keeps its own native sibling. + 'src/components/custom-key-input-styles.web.ts', + 'src/session/mobile-native-chat-input-styles.web.ts' +] /** The seam's export, so the source check below looks for a binding rather than for a mention. */ const SEAM_EXPORT_NAME = 'TEXT_INPUT_FONT_SIZE' @@ -58,6 +74,8 @@ describe('the font size the page-served text inputs carry', () => { expect(listStyles.commitInput.fontSize).toBe(typography.bodySize) expect(mobileDiffReviewControlStyles.composerInput.fontSize).toBe(typography.bodySize) expect(mobileBrowserPaneStyles.keyboardInput.fontSize).toBe(typography.bodySize) + expect(customKeyModalStyles.fieldInput.fontSize).toBe(typography.bodySize) + expect(mobileSessionCommandInputStyles.textInput.fontSize).toBe(typography.bodySize) // The pane's address bar is the one that is split: it keeps the compact size natively, so the // seam reaches it through the `.web.ts` sibling rather than through this constant. expect(browserAddressFieldStyles.input.fontSize).toBe(typography.metaSize) diff --git a/mobile/src/session/MobileNativeChatAsk.tsx b/mobile/src/session/MobileNativeChatAsk.tsx index a5ba0ef7f72..33c9311715e 100644 --- a/mobile/src/session/MobileNativeChatAsk.tsx +++ b/mobile/src/session/MobileNativeChatAsk.tsx @@ -3,6 +3,7 @@ import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react- import { Check } from 'lucide-react-native' import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask' import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size' type Props = { prompt: AskPrompt @@ -331,7 +332,7 @@ const styles = StyleSheet.create({ borderColor: colors.borderSubtle, borderRadius: radii.card, color: colors.textPrimary, - fontSize: typography.bodySize, + fontSize: TEXT_INPUT_FONT_SIZE, padding: spacing.sm, minHeight: 44, marginBottom: spacing.xs diff --git a/mobile/src/session/MobileNativeChatComposer.tsx b/mobile/src/session/MobileNativeChatComposer.tsx index 29efb22f547..996f46c6f45 100644 --- a/mobile/src/session/MobileNativeChatComposer.tsx +++ b/mobile/src/session/MobileNativeChatComposer.tsx @@ -30,6 +30,7 @@ import { type MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers' import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' +import { mobileNativeChatInputStyles } from './mobile-native-chat-input-styles' const NO_FILE_PATHS: string[] = [] const NO_ATTACHMENTS: PendingNativeChatImage[] = [] @@ -248,7 +249,7 @@ export function MobileNativeChatComposer({ ({ + StyleSheet: { create: (styles: unknown) => styles } +})) + +// The seam as the page bundle resolves it. Without this the `.web.ts` styles below would read the +// native seam and the test would pass on a size that no browser ever renders. +vi.mock( + '../platform/text-input-font-size', + async () => await import('../platform/text-input-font-size.web') +) + +import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { mobileNativeChatInputBase } from './mobile-native-chat-input-base-styles' +import { mobileNativeChatInputStyles } from './mobile-native-chat-input-styles' +import { mobileNativeChatInputStyles as onWeb } from './mobile-native-chat-input-styles.web' + +/** Below this an iOS browser zooms the page when an input takes focus, and does not zoom back. */ +const IOS_FOCUS_ZOOM_FLOOR = 16 + +/** Every property the two fields carried before the split, read off the commit that split them. */ +const BEFORE_THE_SPLIT = { + input: { + width: '100%', + maxHeight: 140, + minHeight: 40, + color: colors.textPrimary, + fontSize: typography.bodySize + 1, + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingTop: spacing.sm, + paddingBottom: spacing.sm + }, + freeInput: { + flex: 1, + minHeight: 40, + maxHeight: 120, + color: colors.textPrimary, + fontSize: typography.bodySize + 1, + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingTop: spacing.sm, + paddingBottom: spacing.sm + } +} as const + +const KEYS = ['input', 'freeInput'] as const + +describe('the chat composer and question fields natively', () => { + it.each(KEYS)('renders exactly what it rendered before the split: %s', (key) => { + expect(mobileNativeChatInputStyles[key]).toEqual(BEFORE_THE_SPLIT[key]) + // Key for key as well as value for value: `toEqual` would pass over an extra undefined. + expect(Object.keys(mobileNativeChatInputStyles[key]).sort()).toEqual( + Object.keys(BEFORE_THE_SPLIT[key]).sort() + ) + }) + + it('sits one point under the floor, which is why the split exists', () => { + // The premise, not a restatement: if the body size ever rose to 15 this whole pair collapses + // into an in-place move and someone should be told rather than left maintaining three files. + expect(BEFORE_THE_SPLIT.input.fontSize).toBeLessThan(IOS_FOCUS_ZOOM_FLOOR) + }) +}) + +describe('the chat composer and question fields on the web', () => { + it.each(KEYS)('takes its size from the seam, clear of the focus-zoom floor: %s', (key) => { + expect(onWeb[key].fontSize).toBe(TEXT_INPUT_FONT_SIZE) + expect(onWeb[key].fontSize).toBeGreaterThanOrEqual(IOS_FOCUS_ZOOM_FLOOR) + expect(onWeb[key].fontSize).toBeGreaterThan(BEFORE_THE_SPLIT[key].fontSize) + }) + + // The split is one value, not a second style: everything the siblings do not differ on comes from + // the same object, so a padding or a colour cannot drift between the platforms. + it.each(KEYS)('differs from the native style in nothing but the size: %s', (key) => { + expect(mobileNativeChatInputBase[key]).not.toHaveProperty('fontSize') + expect(mobileNativeChatInputStyles[key]).toMatchObject(mobileNativeChatInputBase[key]) + expect(onWeb[key]).toMatchObject(mobileNativeChatInputBase[key]) + expect(Object.keys(onWeb[key]).sort()).toEqual( + Object.keys(mobileNativeChatInputStyles[key]).sort() + ) + }) + + it('keeps the two fields apart where they were always apart', () => { + // A shared base is how two styles drift into one. The composer spans its row; the question's + // field shares the row with a send button, and neither shape is the other's. + expect(onWeb.input).toMatchObject({ width: '100%', maxHeight: 140 }) + expect(onWeb.freeInput).toMatchObject({ flex: 1, maxHeight: 120 }) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-input-styles.web.ts b/mobile/src/session/mobile-native-chat-input-styles.web.ts new file mode 100644 index 00000000000..252a79052df --- /dev/null +++ b/mobile/src/session/mobile-native-chat-input-styles.web.ts @@ -0,0 +1,17 @@ +import { StyleSheet } from 'react-native' +import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size' +import { mobileNativeChatInputBase } from './mobile-native-chat-input-base-styles' + +/** + * Web sibling: both chat fields go on the text-input seam, one point up from the 15 they carry + * natively and clear of the floor below which iOS zooms the page on focus. + * + * The zoom is not cosmetic on this screen. `keyboard-occlusion.web.ts` reads a visual viewport + * scale other than 1 as "not a keyboard" and answers 0, so one focus of the composer would leave + * the terminal's own keyboard lift at 0 for the rest of the session — and the terminal's input is + * a hidden field whose only feedback that it is focused is the lift. + */ +export const mobileNativeChatInputStyles = StyleSheet.create({ + input: { ...mobileNativeChatInputBase.input, fontSize: TEXT_INPUT_FONT_SIZE }, + freeInput: { ...mobileNativeChatInputBase.freeInput, fontSize: TEXT_INPUT_FONT_SIZE } +}) diff --git a/mobile/src/session/mobile-session-command-input-styles.ts b/mobile/src/session/mobile-session-command-input-styles.ts index b19c13a502e..31ddffca08a 100644 --- a/mobile/src/session/mobile-session-command-input-styles.ts +++ b/mobile/src/session/mobile-session-command-input-styles.ts @@ -1,6 +1,7 @@ import { StyleSheet } from 'react-native' import { colors, spacing, radii, typography } from '../theme/mobile-theme' +import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size' export const mobileSessionCommandInputStyles = StyleSheet.create({ createWarningBanner: { @@ -157,7 +158,7 @@ export const mobileSessionCommandInputStyles = StyleSheet.create({ borderRadius: radii.input, paddingHorizontal: spacing.md, paddingVertical: 0, - fontSize: 14, + fontSize: TEXT_INPUT_FONT_SIZE, fontFamily: typography.monoFamily, marginRight: spacing.sm }, diff --git a/mobile/src/session/session-router-seam-census.test.ts b/mobile/src/session/session-router-seam-census.test.ts new file mode 100644 index 00000000000..e2699252e9b --- /dev/null +++ b/mobile/src/session/session-router-seam-census.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { + callsRouteHandoff, + expoRouterValueImports, + parse, + productFiles +} from '../navigation/router-seam-census.test-support' + +const SESSION_ROOT = import.meta.dirname + +/** + * Which modules here hold a router, so the census cannot pass by seeing nothing. + * + * Three, and each for a different target. The foundation hook bounces a deleted workspace back to + * its host; the file-tap handlers push a preview route from a terminal link or a chat path; the + * notification hook consumes a pane tap by rewriting this route's own params. Only the first two + * can leave the page, which is the whole reason the third is in the list anyway — a `setParams` on + * expo-router's router and a `setParams` on the handoff's are the same call, and listing it here is + * what stops someone later giving it back its own `useRouter` because "it never navigates". + */ +const ROUTER_HOLDERS = [ + 'use-mobile-file-tap-handlers.ts', + 'use-mobile-session-foundation.ts', + 'use-notification-pane-navigation.ts' +] + +/** + * The expo-router names this domain may still import, and why each one is not a router. + * + * `useFocusEffect` reads whether this screen is the focused one in the document's own stack and + * `useLocalSearchParams` reads the params of the route already mounted. Neither takes a target, so + * neither can put a screen in front of the page; both are the page's own router answering about the + * page's own route, which is exactly what it is for. + * + * A closed list rather than a ban on `useRouter`: the hazard is anything that navigates, and + * expo-router exports a module-singleton `router` that does it from a plain function. A rule written + * against the one spelling would have read that as clean. + */ +const NON_NAVIGATING_ROUTER_NAMES = ['useFocusEffect', 'useLocalSearchParams'] + +describe('the session domain reaches the router through the handoff seam', () => { + const files = productFiles(SESSION_ROOT) + + it('walks the modules it is written against', () => { + expect(files).toEqual(expect.arrayContaining(ROUTER_HOLDERS)) + expect(files.length).toBeGreaterThan(200) + }) + + it('imports nothing from expo-router that can navigate', () => { + const offenders = files + .map((name) => ({ + name, + imported: expoRouterValueImports(parse(SESSION_ROOT, name)).filter( + (imported) => !NON_NAVIGATING_ROUTER_NAMES.includes(imported) + ) + })) + .filter((entry) => entry.imported.length > 0) + .map((entry) => `${entry.name} (${entry.imported.join(', ')})`) + expect(offenders).toEqual([]) + }) + + it('takes the router from useRouteHandoff at every screen that holds one', () => { + expect(files.filter((name) => callsRouteHandoff(parse(SESSION_ROOT, name))).sort()).toEqual( + [...ROUTER_HOLDERS].sort() + ) + }) + + it('still reaches expo-router for the two names that answer about its own route', () => { + // The completeness half: the rule above also passes over a domain that imports nothing at all, + // which is what it would read as if someone moved these hooks and left the list behind. + const imported = new Set( + files.flatMap((name) => expoRouterValueImports(parse(SESSION_ROOT, name))) + ) + expect([...imported].sort()).toEqual([...NON_NAVIGATING_ROUTER_NAMES].sort()) + }) +})