mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
feat(mobile): put the session screen's nine text inputs on the web font seam (OTA phase C, C7.2)
The landed text-input census, run over `app/h/[hostId]/session/[worktreeId].tsx`, reports nine sizes that do not come from `TEXT_INPUT_FONT_SIZE`. Six declare the app's body size and move in place, which is the same number natively. Three do not — a 22px key-capture field and the chat's two 15px fields — so each gets a `.web.ts` sibling of the address bar's shape, with a shared base so the two halves can differ in nothing but the size. The capture field is the one the move shrinks rather than raises: 22 already clears the focus-zoom floor, and the census reads the seam as a binding rather than as a number, so there is no expression that keeps 22 and still says where the size came from. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionLabel}>Key</Text>
|
||||
<TextInput
|
||||
style={styles.keyInput}
|
||||
style={customKeyInputStyles.keyInput}
|
||||
value={shortcutKey.length === 1 ? shortcutKey.toUpperCase() : ''}
|
||||
onChangeText={handleShortcutKeyInput}
|
||||
placeholder={SPECIAL_KEY_BY_ID[shortcutKey]?.label ?? 'C'}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type KeyboardTypeOptions
|
||||
} from 'react-native'
|
||||
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
|
||||
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
|
||||
type Props = {
|
||||
@@ -133,7 +134,7 @@ const styles = StyleSheet.create({
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm,
|
||||
fontSize: typography.bodySize,
|
||||
fontSize: TEXT_INPUT_FONT_SIZE,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { colors, typography } from '../theme/mobile-theme'
|
||||
|
||||
/**
|
||||
* The custom-key capture field, minus the one thing that is a platform answer.
|
||||
*
|
||||
* Shared because a `.web.ts` cannot import a value from the file it shadows, and two copies of a
|
||||
* style object is how the two platforms drift apart on everything except the difference that was
|
||||
* meant to be between them.
|
||||
*/
|
||||
export const customKeyInputBase = {
|
||||
width: '100%',
|
||||
height: 56,
|
||||
borderRadius: 10,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center'
|
||||
} as const
|
||||
@@ -0,0 +1,15 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { customKeyInputBase } from './custom-key-input-base-styles'
|
||||
|
||||
/**
|
||||
* Native: the single character this field captures is shown large, which is what it has rendered
|
||||
* at since the modal existed.
|
||||
*
|
||||
* The `.web.ts` sibling puts it on the text-input seam instead. That is a reduction rather than
|
||||
* the raise every other input in this closure gets — 22 is already clear of the focus-zoom floor —
|
||||
* and it is the price of the seam being a binding rule rather than a number: a size the census
|
||||
* cannot follow to the seam module is one nobody can tell from a 14 that was left behind.
|
||||
*/
|
||||
export const customKeyInputStyles = StyleSheet.create({
|
||||
keyInput: { ...customKeyInputBase, fontSize: 22 }
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// StyleSheet.create is identity in React Native and on RN Web alike, and every other export of the
|
||||
// module reaches the native runtime this test does not have.
|
||||
vi.mock('react-native', () => ({
|
||||
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()
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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<string>()
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
<View style={styles.composerInset} testID="native-chat-composer-inset">
|
||||
<View style={styles.bar} testID="native-chat-composer">
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
style={mobileNativeChatInputStyles.input}
|
||||
value={value}
|
||||
onChangeText={handleChange}
|
||||
// Controlled only transiently right after an autocomplete insert.
|
||||
@@ -397,18 +398,6 @@ const styles = StyleSheet.create({
|
||||
actionSpacer: {
|
||||
flex: 1
|
||||
},
|
||||
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
|
||||
},
|
||||
iconButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo, useRef, useState } from 'react'
|
||||
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
|
||||
import { ArrowUp, Check, CircleHelp, X } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import { mobileNativeChatInputStyles } from './mobile-native-chat-input-styles'
|
||||
import {
|
||||
formatQuestionAnswerByIndexes,
|
||||
formatQuestionAnswerWithOtherByIndexes,
|
||||
@@ -175,7 +176,7 @@ export function MobileNativeChatQuestion({
|
||||
{allowOther ? (
|
||||
<View style={styles.freeTextRow}>
|
||||
<TextInput
|
||||
style={styles.freeInput}
|
||||
style={mobileNativeChatInputStyles.freeInput}
|
||||
value={freeText}
|
||||
onChangeText={setFreeText}
|
||||
placeholder={hasOptions ? 'Or type a reply…' : 'Type your reply…'}
|
||||
@@ -303,18 +304,6 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'flex-end',
|
||||
gap: 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
|
||||
},
|
||||
freeSend: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { View, Text, Pressable, TextInput, StyleSheet, Switch } from 'react-native'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react-native'
|
||||
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
|
||||
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
|
||||
import { MobileAgentIcon } from '../components/MobileAgentIcon'
|
||||
import {
|
||||
getQuickCommandAgentLabel,
|
||||
@@ -250,7 +251,7 @@ const styles = StyleSheet.create({
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
fontSize: 14,
|
||||
fontSize: TEXT_INPUT_FONT_SIZE,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { View, Text, Pressable, TextInput, StyleSheet, ActivityIndicator } from 'react-native'
|
||||
import { Check, Plus, Search } from 'lucide-react-native'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
|
||||
import { MobileAgentIcon } from '../components/MobileAgentIcon'
|
||||
import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog'
|
||||
import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-command-types'
|
||||
@@ -207,7 +208,7 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm
|
||||
},
|
||||
searchInput: { flex: 1, color: colors.textPrimary, fontSize: 14, padding: 0 },
|
||||
searchInput: { flex: 1, color: colors.textPrimary, fontSize: TEXT_INPUT_FONT_SIZE, padding: 0 },
|
||||
error: { color: colors.statusRed, fontSize: 13, paddingHorizontal: spacing.xs },
|
||||
loading: { paddingVertical: spacing.lg },
|
||||
empty: {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { colors, radii, spacing } from '../theme/mobile-theme'
|
||||
|
||||
/**
|
||||
* The chat's two free-text fields, minus the one thing that is a platform answer.
|
||||
*
|
||||
* The composer spans its row and grows to 140; the question's field shares the row with a send
|
||||
* button and stops at 120. Everything else about them is the same and is here, because a `.web.ts`
|
||||
* cannot import a value from the file it shadows and two copies of a style object drift apart on
|
||||
* everything except the difference that was meant to be between them.
|
||||
*/
|
||||
const chatInputSurface = {
|
||||
minHeight: 40,
|
||||
color: colors.textPrimary,
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.sm,
|
||||
paddingBottom: spacing.sm
|
||||
} as const
|
||||
|
||||
export const mobileNativeChatInputBase = {
|
||||
input: { ...chatInputSurface, width: '100%', maxHeight: 140 },
|
||||
freeInput: { ...chatInputSurface, flex: 1, maxHeight: 120 }
|
||||
} as const
|
||||
@@ -0,0 +1,17 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { typography } from '../theme/mobile-theme'
|
||||
import { mobileNativeChatInputBase } from './mobile-native-chat-input-base-styles'
|
||||
|
||||
/**
|
||||
* Native: both chat fields sit one point above the body size, which is what they have rendered at.
|
||||
*
|
||||
* The `.web.ts` sibling raises them to the text-input seam, because 15 is under the size below
|
||||
* which iOS zooms the page on focus — and this screen is the one that cannot afford that zoom, the
|
||||
* terminal's keyboard lift being pure geometry on a document it assumes is at scale 1.
|
||||
*/
|
||||
const CHAT_INPUT_FONT_SIZE = typography.bodySize + 1
|
||||
|
||||
export const mobileNativeChatInputStyles = StyleSheet.create({
|
||||
input: { ...mobileNativeChatInputBase.input, fontSize: CHAT_INPUT_FONT_SIZE },
|
||||
freeInput: { ...mobileNativeChatInputBase.freeInput, fontSize: CHAT_INPUT_FONT_SIZE }
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// StyleSheet.create is identity in React Native and on RN Web alike, and every other export of the
|
||||
// module reaches the native runtime this test does not have.
|
||||
vi.mock('react-native', () => ({
|
||||
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 })
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user