diff --git a/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx b/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx index 0af4c0c1617..ea1126da52e 100644 --- a/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx @@ -2,11 +2,12 @@ import { useFocusEffect, useRouter } from 'expo-router'; import * as Haptics from 'expo-haptics'; import { Check, Info, Lock, Search, SearchX, Unlock } from '@/components/ui/icons'; import { useCallback, useDeferredValue, useMemo, useRef, useState } from 'react'; -import { Pressable, TextInput, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { useTranslation } from 'react-i18next'; import { EmptyState } from '@/components/empty-state'; import { PickerSheet } from '@/components/picker-sheet'; +import { Input } from '@/components/ui/input'; import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { REPO_PLATFORM_LABEL_KEYS, type RepoOption } from '@/lib/picker-bridge'; @@ -110,19 +111,19 @@ export default function RepoPickerScreen() { {/* The placeholder is a single-line Text overlay, not the input's own placeholder: Android lays the native hint out at the field's width with no line cap, so copy wider than a narrow field wraps onto a - second line that the field's fixed height clips against its - border. A tail-ellipsized Text truncates the copy at any width - instead. Both texts share leading-[normal] and a centred text rect - so the overlay sits exactly where the typed text will. */} + second line. A tail-ellipsized Text truncates the copy at any width + instead. The shared box draws the value on one line box and centres + it, and both texts share `px-0` so the overlay sits exactly where + the typed text will. */} - diff --git a/apps/mobile/src/components/agents/model-picker-content.mounted.test.tsx b/apps/mobile/src/components/agents/model-picker-content.mounted.test.tsx index ade31e65c16..1fb797300ad 100644 --- a/apps/mobile/src/components/agents/model-picker-content.mounted.test.tsx +++ b/apps/mobile/src/components/agents/model-picker-content.mounted.test.tsx @@ -106,6 +106,9 @@ vi.mock('react-native', () => ({ Pressable: 'Pressable', TextInput: 'TextInput', View: 'View', + // `@/components/ui/input` reads `I18nManager.isRTL` through + // `withRtlInputAlignment` on every render. + I18nManager: { isRTL: false }, })); vi.mock('expo-router', () => ({ useRouter: () => ({ back: routerBack, push: vi.fn() }), diff --git a/apps/mobile/src/components/agents/model-picker-content.tsx b/apps/mobile/src/components/agents/model-picker-content.tsx index c7669b7cbad..dba89e09444 100644 --- a/apps/mobile/src/components/agents/model-picker-content.tsx +++ b/apps/mobile/src/components/agents/model-picker-content.tsx @@ -3,7 +3,7 @@ import * as Haptics from 'expo-haptics'; import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; import { AlertCircle, Info, Search, SearchX, X } from '@/components/ui/icons'; import { useCallback, useDeferredValue, useMemo, useRef, useState } from 'react'; -import { Pressable, TextInput, View, type ViewStyle } from 'react-native'; +import { Pressable, type TextInput, View, type ViewStyle } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTranslation } from 'react-i18next'; @@ -11,6 +11,7 @@ import { ModelPickerOptionRow } from '@/components/agents/model-selector'; import { EmptyState } from '@/components/empty-state'; import { PickerSheet } from '@/components/picker-sheet'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; import { Text } from '@/components/ui/text'; import { useModelPreferences } from '@/lib/hooks/use-model-preferences'; import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; @@ -217,14 +218,14 @@ export function ModelPickerContent() { - {/* In-field clear, on every platform: `clearButtonMode` is iOS diff --git a/apps/mobile/src/components/agents/picker-search.mounted.test.tsx b/apps/mobile/src/components/agents/picker-search.mounted.test.tsx index 157dc1f6461..da4e25ca800 100644 --- a/apps/mobile/src/components/agents/picker-search.mounted.test.tsx +++ b/apps/mobile/src/components/agents/picker-search.mounted.test.tsx @@ -43,6 +43,9 @@ vi.mock('react-native', () => ({ ScrollView: 'ScrollView', TextInput: 'TextInput', View: 'View', + // `@/components/ui/input` reads `I18nManager.isRTL` through + // `withRtlInputAlignment` on every render. + I18nManager: { isRTL: false }, })); vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ bottom: 0 }) })); vi.mock('expo-haptics', () => ({ selectionAsync: vi.fn() })); diff --git a/apps/mobile/src/components/agents/question-card-actions.mounted.test.tsx b/apps/mobile/src/components/agents/question-card-actions.mounted.test.tsx index 7f95dc18061..59566b7509d 100644 --- a/apps/mobile/src/components/agents/question-card-actions.mounted.test.tsx +++ b/apps/mobile/src/components/agents/question-card-actions.mounted.test.tsx @@ -8,6 +8,9 @@ import { QuestionCard } from './question-card'; vi.mock('react-native', () => ({ Alert: { alert: vi.fn() }, + // `@/components/ui/input` reads `I18nManager.isRTL` through + // `@/lib/rtl-text`; the mock must expose it or the shared box throws. + I18nManager: { isRTL: false }, Pressable: 'Pressable', ScrollView: 'ScrollView', TextInput: MockTextInput, diff --git a/apps/mobile/src/components/agents/question-card.mounted.test.tsx b/apps/mobile/src/components/agents/question-card.mounted.test.tsx index 0e4338a4ff9..9c06b4a8a44 100644 --- a/apps/mobile/src/components/agents/question-card.mounted.test.tsx +++ b/apps/mobile/src/components/agents/question-card.mounted.test.tsx @@ -14,6 +14,9 @@ vi.mock('@/components/ui/activity-indicator', () => ({ ActivityIndicator: 'Activ vi.mock('react-native', () => ({ ActivityIndicator: 'ActivityIndicator', Alert: { alert: vi.fn() }, + // `@/components/ui/input` reads `I18nManager.isRTL` through + // `@/lib/rtl-text`; the mock must expose it or the shared box throws. + I18nManager: { isRTL: false }, Pressable: 'Pressable', ScrollView: 'ScrollView', TextInput: MockTextInput, diff --git a/apps/mobile/src/components/agents/question-card.tsx b/apps/mobile/src/components/agents/question-card.tsx index f2d1120f689..7833625e6f4 100644 --- a/apps/mobile/src/components/agents/question-card.tsx +++ b/apps/mobile/src/components/agents/question-card.tsx @@ -1,12 +1,20 @@ /* eslint-disable max-lines -- The blocking question card keeps its selection, custom-answer, and CTA presentation together. */ import { useEffect, useRef, useState } from 'react'; -import { Alert, Pressable, type Text as RNText, ScrollView, TextInput, View } from 'react-native'; +import { + Alert, + Pressable, + type Text as RNText, + ScrollView, + type TextInput, + View, +} from 'react-native'; import { ActivityIndicator } from '@/components/ui/activity-indicator'; import * as Haptics from 'expo-haptics'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; import { Check } from '@/components/ui/icons'; +import { Input } from '@/components/ui/input'; import { Text } from '@/components/ui/text'; import { applyBlockingCardAppearance, @@ -329,7 +337,7 @@ export function QuestionCard({ ) : null} - { customInputRefs.current[qIndex] = node; }} @@ -350,7 +358,11 @@ export function QuestionCard({ accessibilityLabel={t('agentChat.questionCard.typeYourOwnAnswer')} accessibilityState={{ disabled: interactionDisabled }} className={cn( - 'rounded-md border px-3 py-2.5 text-sm shadow-sm shadow-[#0000000D]', + // The shared single-line box supplies the touch + // floor (`min-h-[44px]`, never a fixed height); + // dropping `py-2.5` lets the box own the vertical + // geometry, and every other token stays. + 'rounded-md border px-3 text-sm shadow-sm shadow-[#0000000D]', isCustomActive ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-background text-foreground dark:border-neutral-700 dark:bg-secondary', diff --git a/apps/mobile/src/components/agents/session-list-search-header.mounted.test.tsx b/apps/mobile/src/components/agents/session-list-search-header.mounted.test.tsx index eb8aeab9ea7..232b53d0a03 100644 --- a/apps/mobile/src/components/agents/session-list-search-header.mounted.test.tsx +++ b/apps/mobile/src/components/agents/session-list-search-header.mounted.test.tsx @@ -156,7 +156,10 @@ describe('SessionListSearchHeader typed query alignment', () => { it('aligns the typed query to the field start edge in RTL', async () => { i18nManager.isRTL = true; const renderer = await mount(); - expect(searchInput(renderer).props.style).toEqual({ textAlign: 'right' }); + // The shared box applies the RTL content alignment in front of the + // caller's own style (this field passes none), so the field's query follows + // the interface direction. + expect(searchInput(renderer).props.style).toEqual([{ textAlign: 'right' }, undefined]); }); }); @@ -235,4 +238,28 @@ describe('SessionListSearchHeader clear control', () => { box.height + rowVerticalPaddingDp + rowVerticalBorderDp ); }); + + it('takes the row height from the shared field floor, not the row floor', async () => { + const renderer = await mount(); + const rowClassName = fieldRow(renderer).props.className as string; + const rowDeclarations = (await compiledDimensions(rowClassName)) as { minHeight?: number }[]; + const row = Object.assign({}, ...rowDeclarations) as { minHeight?: number }; + const inputDeclarations = (await compiledDimensions( + searchInput(renderer).props.className as string + )) as { minHeight?: number }[]; + const input = Object.assign({}, ...inputDeclarations) as { minHeight?: number }; + + const rowVerticalPaddingDp = 2 * (await compiledLengthDp(rowClassName, 'paddingBlock')); + const rowVerticalBorderDp = 2 * (await compiledLengthDp(rowClassName, 'borderWidth')); + + // The shared field's 44pt floor is taller than the 38pt clear control, so + // the field's floor lays the row out: 44 + 10.5 + 2 = 56.5pt. The row's own + // `min-h-[51px]` is a lower bound the field dominates, not the height the + // row takes. + const fieldFloorDp = input.minHeight ?? 0; + expect(fieldFloorDp).toBe(44); + const laidOutHeightDp = fieldFloorDp + rowVerticalPaddingDp + rowVerticalBorderDp; + expect(laidOutHeightDp).toBe(56.5); + expect(row.minHeight).toBeLessThan(laidOutHeightDp); + }); }); diff --git a/apps/mobile/src/components/agents/session-list-search-header.touch-target.test.tsx b/apps/mobile/src/components/agents/session-list-search-header.touch-target.test.tsx index 2257a6c4d49..8875b511db3 100644 --- a/apps/mobile/src/components/agents/session-list-search-header.touch-target.test.tsx +++ b/apps/mobile/src/components/agents/session-list-search-header.touch-target.test.tsx @@ -50,6 +50,11 @@ vi.mock('@/lib/hooks/use-theme-colors', () => ({ useThemeColors: () => ({ mutedForeground: '#6F6A61' }), })); vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +// `@/components/ui/input` merges its box with `cn`, which imports `@/i18n`; +// this pure suite does not mount the app's i18n instance. +vi.mock('@/lib/utils', () => ({ + cn: (...values: unknown[]) => values.filter(Boolean).join(' '), +})); function mount(hasText: boolean): TestRenderer.ReactTestRenderer { let renderer: TestRenderer.ReactTestRenderer | null = null; diff --git a/apps/mobile/src/components/agents/session-list-search-header.tsx b/apps/mobile/src/components/agents/session-list-search-header.tsx index f0444cd0091..3ebbddf1893 100644 --- a/apps/mobile/src/components/agents/session-list-search-header.tsx +++ b/apps/mobile/src/components/agents/session-list-search-header.tsx @@ -1,17 +1,14 @@ import { Search, X } from '@/components/ui/icons'; import { type RefObject, useMemo } from 'react'; -import { I18nManager, Pressable, TextInput, View } from 'react-native'; +import { Pressable, type TextInput, View } from 'react-native'; import { ActivityIndicator } from '@/components/ui/activity-indicator'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTranslation } from 'react-i18next'; +import { Input } from '@/components/ui/input'; import { COMPACT_CONTROL_HIT_SLOP_DP } from '@/lib/a11y/tap-target'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -// In RTL the field's start edge is its physical right; TextInput maps -// textAlign to a physical gravity (unlike Text), so it must be set explicitly. -const SEARCH_RTL = { textAlign: 'right' } as const; - // The row's `gap-2` compiles to 7pt, not 8pt: NativeWind v5 fixes 1rem at // 14pt. The clear control's left slop is capped at that gap, so its touch // region meets the input's instead of covering it — the same cap @@ -50,7 +47,6 @@ export function SessionListSearchHeader({ }: Readonly) { const colors = useThemeColors(); const { t } = useTranslation(); - const isRtl = I18nManager.isRTL; // The landscape side insets keep the field's rounded border and left tap // area clear of the sensor housing; portrait insets are 0, keeping the // fixed 22px margin unchanged. @@ -61,14 +57,18 @@ export function SessionListSearchHeader({ ); return ( - {/* One `min-height` floor, not two: `min-h-[51px]` is the height the row - must already hold for the clear control's 38pt box plus the field's - own 10.5pt vertical padding (`py-1.5` is 0.375rem at the app's 14pt - rem) and its 2pt vertical `border` (1pt a side, inside the border box - React Native lays out), so the first keystroke cannot grow the row and - shift the list below. A second `min-h-*` class would set the same - property, leaving which floor wins to the order Tailwind emits its - rules rather than to this intent. */} + {/* The row's height comes from the shared field box, not from this row: + the field's 44pt floor plus this row's 10.5pt vertical padding + (`py-1.5` is 0.375rem at the app's 14pt rem) and its 2pt vertical + `border` (1pt a side, inside the border box React Native lays out) is + 56.5pt, the same whether the clear control is showing or not, so the + first keystroke cannot grow the row and shift the list below. The + row's own `min-h-[51px]` (the clear control's 38pt box plus that same + padding and border, 50.5pt) stays as a lower bound the field's floor + dominates, so the row never shrinks below the control's own box. A + second `min-h-*` class would set the same property, leaving which + floor wins to the order Tailwind emits its rules rather than to this + intent. */} )} - ) { {t('codeReviewer.bitbucketConnect.scopes')} - ({ }, }, Dimensions: { get: () => ({ height: 900 }) }, + // `@/components/ui/input` reads `I18nManager.isRTL` through + // `@/lib/rtl-text`; the mock must expose it or the shared box throws. + I18nManager: { isRTL: false }, Pressable: 'Pressable', ScrollView: 'ScrollView', TextInput: 'TextInput', diff --git a/apps/mobile/src/components/code-reviewer/manual-review-screen.tsx b/apps/mobile/src/components/code-reviewer/manual-review-screen.tsx index 4355c7155e3..bc05a40b2ab 100644 --- a/apps/mobile/src/components/code-reviewer/manual-review-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/manual-review-screen.tsx @@ -13,6 +13,7 @@ import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; import { Button } from '@/components/ui/button'; import { formFieldA11y } from '@/components/ui/form-field-a11y'; +import { Input } from '@/components/ui/input'; import { RadioGroup, radioItemA11y } from '@/components/ui/radio-group'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; @@ -251,9 +252,12 @@ export function ManualReviewScreen({ scope }: Readonly<{ scope: string }>) { {t('codeReviewer.manualReview.pullRequestUrl')} - { if (!input) { throw new Error('language search input not found'); } + // The field is the shared single-line `Input`, which renders the TextInput + // itself, so the pill is the input's wrapper row: walk up to it rather than + // stopping at the input's own node. The shared box is what keeps the + // placeholder and the value in one box. const field = findFieldContainer(input); expect((field.props.className as string).split(/\s+/)).toEqual( diff --git a/apps/mobile/src/components/language-picker-sheet.tsx b/apps/mobile/src/components/language-picker-sheet.tsx index 01a7856951a..f950c4383e2 100644 --- a/apps/mobile/src/components/language-picker-sheet.tsx +++ b/apps/mobile/src/components/language-picker-sheet.tsx @@ -2,7 +2,7 @@ import { reloadAppAsync } from 'expo'; import { useFocusEffect, useNavigation } from 'expo-router'; import { useCallback, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { FlatList, I18nManager, TextInput, View } from 'react-native'; +import { FlatList, I18nManager, type TextInput, View } from 'react-native'; import { ActivityIndicator } from '@/components/ui/activity-indicator'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { toast } from 'sonner-native'; @@ -12,6 +12,7 @@ import { EmptyState } from '@/components/empty-state'; import { LanguagePickerRow } from '@/components/language-picker-row'; import { PickerSheet } from '@/components/picker-sheet'; import { Search, SearchX } from '@/components/ui/icons'; +import { Input } from '@/components/ui/input'; import { Text } from '@/components/ui/text'; import { applyLanguagePreference } from '@/i18n/apply-language'; import { languagePickerItems } from '@/i18n/language-rows'; @@ -27,8 +28,6 @@ import { import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { usePreventRemove } from '@/lib/navigation/prevent-remove'; -const SEARCH_RTL = { textAlign: 'right' } as const; - export function LanguagePickerSheet({ onClose, onApplied, @@ -227,22 +226,22 @@ export function LanguagePickerSheet({ headerContent={ // Filled pill with a leading magnifier, the one search-field shape the // repository and share pickers use. The outlined box this replaced made - // the same control look like two different controls across pickers. + // the same control look like two different controls across pickers. The + // field itself is the shared single-line `Input`: it owns the height + // floor, the one line box for the placeholder and the value, and the RTL + // content alignment, so the pill owns only the chrome and this field + // keeps only its flex share and text size. - ({ })); vi.mock('react-native', () => ({ + I18nManager: { isRTL: false }, Platform: { OS: 'ios' }, Pressable: 'Pressable', useColorScheme: () => 'light', diff --git a/apps/mobile/src/components/login/__tests__/idle-auth.mounted.test.tsx b/apps/mobile/src/components/login/__tests__/idle-auth.mounted.test.tsx index 71c77c39d6c..0d636869d94 100644 --- a/apps/mobile/src/components/login/__tests__/idle-auth.mounted.test.tsx +++ b/apps/mobile/src/components/login/__tests__/idle-auth.mounted.test.tsx @@ -12,7 +12,8 @@ import { openBrowserAsync } from 'expo-web-browser'; import { INLINE_LINK_BOX_CLASS, INLINE_LINK_CONNECTOR_CLASS, - INLINE_LINK_HIT_SLOP, + INLINE_LINK_FACING_HIT_SLOP_DP, + INLINE_LINK_OUTER_HIT_SLOP_DP, INLINE_LINK_ROW_CLASS, INLINE_LINK_ROW_MARGIN_DP, INLINE_LINK_VERTICAL_HIT_SLOP_DP, @@ -22,7 +23,7 @@ import { import { PRIVACY_URL, TERMS_URL } from '@/lib/config'; import { i18n } from '@/i18n'; -import { IdleAuth } from '../idle-auth'; +import { IdleAuth, PROVIDER_GLYPH_SLOT_CLASS } from '../idle-auth'; type StartFn = (mode: 'signin' | 'sso', ssoEmail?: string) => Promise; @@ -87,6 +88,7 @@ vi.mock('expo-apple-authentication', () => ({ vi.mock('@/components/ui/activity-indicator', () => ({ ActivityIndicator: 'ActivityIndicator' })); vi.mock('react-native', () => ({ ActivityIndicator: 'ActivityIndicator', + I18nManager: { isRTL: false }, Platform: { OS: 'ios' }, Pressable: 'Pressable', useColorScheme: () => 'light', @@ -191,6 +193,31 @@ function findLink(root: I, label: string): I { return link; } +/** The sentence end a legal link's accessibility label sits at. */ +function linkEdge(label: string | undefined): 'start' | 'end' { + return label === 'Terms' ? 'start' : 'end'; +} + +/** + * The per-side slop the legal link at one end of the sentence must render. The + * facing side (4dp) points at the connector, the outer side (12dp) reaches over + * plain prose, and the two still add to the 44pt target from the 28dp audit + * floor. The sentence is LTR in this suite's native mock. + */ +function expectedLinkSlop(edge: 'start' | 'end'): { + top: number; + bottom: number; + left: number; + right: number; +} { + return { + top: INLINE_LINK_VERTICAL_HIT_SLOP_DP, + bottom: INLINE_LINK_VERTICAL_HIT_SLOP_DP, + left: edge === 'start' ? INLINE_LINK_OUTER_HIT_SLOP_DP : INLINE_LINK_FACING_HIT_SLOP_DP, + right: edge === 'start' ? INLINE_LINK_FACING_HIT_SLOP_DP : INLINE_LINK_OUTER_HIT_SLOP_DP, + }; +} + /** * The legal line's copy in render order: the prefix Text's children plus every * link label, connector, and the suffix, concatenated. The sentence's own word @@ -239,6 +266,24 @@ function labelText(button: I): I { return button.find(n => typeof n.type === 'string' && (n.type as string) === 'Text'); } +/** + * Every node inside a provider Button that carries the reserved glyph-slot + * className. The slot is what puts the label on the same axis in all three + * rows, so the suite asserts exactly one per row and that all three match. + */ +function glyphSlots(button: I): I[] { + return button.findAll(n => String(n.props.className).includes(PROVIDER_GLYPH_SLOT_CLASS)); +} + +function glyphSlot(button: I): I { + const slots = glyphSlots(button); + const slot = slots[0]; + if (!slot || slots.length !== 1) { + throw new Error(`expected exactly one glyph slot, found ${slots.length}`); + } + return slot; +} + /** * The compiled flex layout of a label's own className, through the app's own * pipeline. Only the classes that decide the label's line allocation are @@ -268,6 +313,19 @@ async function compiledDeclarations(classes: string) { ); } +/** The numeric value of one compiled declaration, through the app's pipeline. */ +async function compiledNumber(classes: string, property: string): Promise { + for (const declaration of await compiledDeclarations(classes)) { + if (!Array.isArray(declaration)) { + const plain = declaration as Record; + if (property in plain) { + return plain[property] as number; + } + } + } + return undefined; +} + /** A Button that keeps the default (brand-filled) variant is a primary action. */ function filledPrimaryLabels(root: I): (string | undefined)[] { return root @@ -345,9 +403,11 @@ describe('IdleAuth provider chrome parity', () => { const renderer = await mountIdleAuth(vi.fn()); // The three provider rows read as one group: each carries a leading mark in - // the same slot (the passkey row used to be label-only text). + // the same reserved slot (the passkey row used to be label-only text). The + // mark lives inside the row's slot, so the slot is what puts the label on + // one axis and holds the mark, the spinner, or nothing. const leadingTypes = providerRows(renderer.root).map( - row => row.children.find(child => typeof child !== 'string')?.type + row => glyphSlot(row).children.find(child => typeof child !== 'string')?.type ); expect(leadingTypes).toEqual(['AppleLogo', 'GoogleLogo', 'KeyRound']); @@ -589,6 +649,72 @@ describe('IdleAuth provider label layout', () => { renderer.unmount(); }); }); + + it('reserves one identical glyph slot per provider row', async () => { + providers.appleAvailable = true; + providers.googleConfigured = true; + const renderer = await mountIdleAuth(vi.fn()); + + const buttons = [ + findButton(renderer.root, 'Sign in with Apple'), + findButton(renderer.root, 'Sign in with Google'), + findButton(renderer.root, 'Sign in with a passkey'), + ]; + // Exactly one slot per row, with the identical className, so the three + // flex-1 labels start from the same x with the marks in the same place. + const slotClasses = buttons.map(button => String(glyphSlot(button).props.className)); + expect(slotClasses[0]).toBe(PROVIDER_GLYPH_SLOT_CLASS); + expect(slotClasses[1]).toBe(slotClasses[0]); + expect(slotClasses[2]).toBe(slotClasses[0]); + + act(() => { + renderer.unmount(); + }); + }); + + it('keeps the passkey label className byte-identical while the ceremony runs', async () => { + const idle = await mountIdleAuth(vi.fn()); + const idleClasses = String( + labelText(findButton(idle.root, 'Sign in with a passkey')).props.className + ); + act(() => { + idle.unmount(); + }); + + nativeAuth.busy = 'passkey'; + const busy = await mountIdleAuth(vi.fn()); + const busyButton = findButton(busy.root, 'Sign in with a passkey'); + // The spinner takes the reserved slot, so the label does not move. + expect(glyphSlot(busyButton)).toBeTruthy(); + expect(String(labelText(busyButton).props.className)).toBe(idleClasses); + + act(() => { + busy.unmount(); + }); + }); + + it('keeps the Apple label className byte-identical while the request runs', async () => { + providers.appleAvailable = true; + const idle = await mountIdleAuth(vi.fn()); + const idleClasses = String( + labelText(findButton(idle.root, 'Sign in with Apple')).props.className + ); + act(() => { + idle.unmount(); + }); + + nativeAuth.busy = 'apple'; + const busy = await mountIdleAuth(vi.fn()); + const busyButton = findButton(busy.root, 'Sign in with Apple'); + // Replacing the 18pt mark with the spinner inside the same slot cannot + // move the label. + expect(glyphSlot(busyButton)).toBeTruthy(); + expect(String(labelText(busyButton).props.className)).toBe(idleClasses); + + act(() => { + busy.unmount(); + }); + }); }); describe('IdleAuth email continue copy', () => { it('shows a Continue button with email accessibility', async () => { @@ -621,16 +747,17 @@ describe('IdleAuth email continue copy', () => { const box = boxDp(link.props.className as string); expect(box.width).toBeGreaterThanOrEqual(MIN_TAP_TARGET_DP); expect(box.height).toBeGreaterThanOrEqual(MIN_TAP_TARGET_DP); - // The 44pt design target holds in both axes: across the sentence, where - // no neighbour sits beside the row, and vertically through the clearance - // the legal row reserves (the layout-repair suite checks that clearance - // against the screen's gutter and the Continue button above). + // Each link reaches the 44pt design target from its own rect: the facing + // side (4dp) points at the connector, the outer side (12dp) reaches over + // plain prose, so the two sides are not equal and the reach is + // box + facing + outer rather than box + 2 * slop. const slop = link.props.hitSlop as { top: number; bottom: number; left: number; right: number; }; + expect(slop).toEqual(expectedLinkSlop(linkEdge(link.props.accessibilityLabel as string))); expect(box.width + slop.left + slop.right).toBeGreaterThanOrEqual(TOUCH_TARGET_DP); expect(box.height + slop.top + slop.bottom).toBeGreaterThanOrEqual(TOUCH_TARGET_DP); } @@ -651,7 +778,7 @@ describe('IdleAuth email continue copy', () => { const renderer = await mountIdleAuth(start); // The connector text is the only node between the two links, so it carries - // the shared gap class that keeps their facing slops off each other in a + // the shared gap class that keeps their facing reaches off each other in a // catalog with a short conjunction (ru " и ", pl " i ", ar " و ", // zh " 和 "). `tap-target.test.ts` compiles the class's width. const connectors = renderer.root.findAll( @@ -660,6 +787,11 @@ describe('IdleAuth email continue copy', () => { ); expect(connectors).toHaveLength(1); expect(connectors[0]?.props.className).toContain(INLINE_LINK_CONNECTOR_CLASS); + // The connector's compiled floor holds both facing reaches apart: 2 * 4dp + // plus 2dp of headroom, so the two touch regions never meet between the + // links. `tap-target.test.ts` checks the same floor against the constant. + const connectorFloorDp = await compiledNumber(INLINE_LINK_CONNECTOR_CLASS, 'minWidth'); + expect(connectorFloorDp).toBeGreaterThanOrEqual(2 * INLINE_LINK_FACING_HIT_SLOP_DP); act(() => { renderer.unmount(); @@ -811,7 +943,9 @@ describe('IdleAuth layout repairs', () => { for (const link of linkPressables(renderer.root)) { expect(String(link.props.className)).not.toMatch(/(?:^|\s)px-/); expect(String(link.props.className)).toContain(INLINE_LINK_BOX_CLASS); - expect(link.props.hitSlop).toEqual(INLINE_LINK_HIT_SLOP); + expect(link.props.hitSlop).toEqual( + expectedLinkSlop(linkEdge(link.props.accessibilityLabel as string)) + ); } act(() => { @@ -820,25 +954,12 @@ describe('IdleAuth layout repairs', () => { }); it('cancels the inline link box height so the sentence keeps a text-xs line', async () => { - const box = await compiledDeclarations(INLINE_LINK_BOX_CLASS); - const value = (property: string) => { - for (const declaration of box) { - if (!Array.isArray(declaration)) { - const plain = declaration as Record; - if (property in plain) { - return plain[property]; - } - } - } - return undefined; - }; - // The audit floor stays on the node's own rect... - expect(value('minHeight')).toBe(28); - expect(value('minWidth')).toBe(28); + expect(await compiledNumber(INLINE_LINK_BOX_CLASS, 'minHeight')).toBe(28); + expect(await compiledNumber(INLINE_LINK_BOX_CLASS, 'minWidth')).toBe(28); // ...while the vertical cancel keeps (28 - 14) / 2 out of the text-xs row, // so the legal line is no taller than the surrounding prose. - expect(value('marginBlock')).toBe(-7); + expect(await compiledNumber(INLINE_LINK_BOX_CLASS, 'marginBlock')).toBe(-7); }); it('keeps each legal link touch region inside the row gutter, clear of the Continue button', async () => { diff --git a/apps/mobile/src/components/login/email-otp-form.tsx b/apps/mobile/src/components/login/email-otp-form.tsx index 4089fc91b9d..bbd1d363db9 100644 --- a/apps/mobile/src/components/login/email-otp-form.tsx +++ b/apps/mobile/src/components/login/email-otp-form.tsx @@ -1,10 +1,11 @@ import { useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { TextInput, View } from 'react-native'; +import { View } from 'react-native'; import { ActivityIndicator } from '@/components/ui/activity-indicator'; import { Button } from '@/components/ui/button'; import { formFieldA11y } from '@/components/ui/form-field-a11y'; +import { Input } from '@/components/ui/input'; import { Text } from '@/components/ui/text'; import { type useNativeAuth } from '@/lib/auth/use-native-auth'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; @@ -64,8 +65,12 @@ export function EmailOtpForm({ {t('login.codeArrivalHint')} - - {busy === 'apple' ? ( - - ) : ( - - )} + + {busy === 'apple' ? ( + + ) : ( + + )} + {t('login.signInWithApple')} @@ -266,7 +275,9 @@ export function IdleAuth({ onPress={() => void signInWithGoogle()} accessibilityLabel={t('login.signInWithGoogle')} > - {busy === 'google' ? : } + + {busy === 'google' ? : } + {t('login.signInWithGoogle')} @@ -291,13 +302,15 @@ export function IdleAuth({ }} accessibilityLabel={t('login.signInWithPasskey')} > - {busy === 'passkey' ? ( - - ) : ( - // Same leading-glyph slot as the Apple and Google rows, so the - // three provider options read as one group. - - )} + + {busy === 'passkey' ? ( + + ) : ( + // Same leading-glyph slot as the Apple and Google rows, so the + // three provider options read as one group. + + )} + {t('login.signInWithPasskey')} @@ -359,21 +372,23 @@ export function IdleAuth({ {/* The sentence is a row of nodes, not one Text with nested handlers: an inline link's own box is what the control-size audit measures, so - each link carries the shared inline-link box and its own reach. The - box adds no height to the line (its 28dp floor is cancelled by the - shared layout-neutral form), so the only gaps between the words are - the sentence's own spaces, and the connector's min-width keeps both - facing slops apart in a catalog with a short conjunction. The links' - 44pt vertical reach needs `(28 - 14) / 2 + 8 = 15dp` of free space - above and below the `text-xs` line, which the screen's `gap-3` - gutter (10.5dp) cannot give it, so the row carries the extra 5dp - margin: both regions then stay clear of the Continue button above - and the ghost button below. */} + each link carries the shared inline-link box and its own per-side + reach — 4dp toward the connector, 12dp away — which still adds to + 44pt from the 28dp floor. The box adds no height to the line (its + 28dp floor is cancelled by the shared layout-neutral form), so the + only gaps between the words are the sentence's own spaces, and the + connector's min-width keeps both 4dp facing reaches apart in a + catalog with a short conjunction. The links' 44pt vertical reach + needs `(28 - 14) / 2 + 8 = 15dp` of free space above and below the + `text-xs` line, which the screen's `gap-3` gutter (10.5dp) cannot + give it, so the row carries the extra 5dp margin: both regions then + stay clear of the Continue button above and the ghost button + below. */} {t('login.termsPrefix')} void WebBrowser.openBrowserAsync(TERMS_URL)} @@ -385,7 +400,7 @@ export function IdleAuth({ void WebBrowser.openBrowserAsync(PRIVACY_URL)} diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.test.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.test.tsx index 027e4cc4c68..36596da8302 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.test.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.test.tsx @@ -80,6 +80,9 @@ vi.mock('react-native', () => ({ TextInput: 'TextInput', ActivityIndicator: 'ActivityIndicator', Platform: platformState, + // `@/components/ui/input` reads `I18nManager.isRTL` through + // `withRtlInputAlignment` on every render. + I18nManager: { isRTL: false }, })); vi.mock('react-native-safe-area-context', () => ({ @@ -194,8 +197,17 @@ async function mountNavigator() { return result; } +// The navigator's field is `@/components/ui/input`: the accessibility label +// sits on the composite and on the host TextInput it renders. Match the host so +// the lookup stays single (`findByProps` throws on two matches). +function isSearchInput(node: { type: unknown; props: { accessibilityLabel?: string } }) { + return ( + String(node.type) === 'TextInput' && node.props.accessibilityLabel === 'Filter files by path' + ); +} + function findSearchInput(renderer: Awaited>['renderer']) { - return renderer.root.findByProps({ accessibilityLabel: 'Filter files by path' }); + return renderer.root.find(node => isSearchInput(node)); } function typeSearch( @@ -308,7 +320,7 @@ describe('PrDiffFileNavigator stable row callbacks (finding 2)', () => { const centered = renderer.root.find(node => String(node.type) === 'CenteredState'); expect(centered.findByProps({ children: 'No files match "missing"' })).toBeDefined(); const header = renderer.root.findByProps({ collapsable: false }); - expect(header.findByProps({ accessibilityLabel: 'Filter files by path' })).toBe(input); + expect(header.find(node => isSearchInput(node))).toBe(input); expect(header.parent).toBe(centered.parent); typeSearch(renderer, 'src'); expect(findSearchInput(renderer)).toBe(input); diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.tsx index 8b041df5328..9c5f07a77ee 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.tsx @@ -24,13 +24,14 @@ import { useRouter } from 'expo-router'; import { Search } from '@/components/ui/icons'; import { memo, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Platform, Pressable, TextInput, View, type ViewStyle } from 'react-native'; +import { Platform, Pressable, type TextInput, View, type ViewStyle } from 'react-native'; import { ActivityIndicator } from '@/components/ui/activity-indicator'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { CenteredState } from '@/components/centered-state'; import { EmptyState } from '@/components/empty-state'; import { NavigatorFileRow } from '@/components/pr-review/diff/pr-diff-navigator-file-row'; +import { Input } from '@/components/ui/input'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { i18n } from '@/i18n'; @@ -336,7 +337,7 @@ export function PrDiffFileNavigator({ {header} - 0} @@ -347,7 +348,9 @@ export function PrDiffFileNavigator({ searchRef.current = value; setSearchVersion(version => version + 1); }} - className="flex-1 text-sm leading-[normal] text-foreground" + // The shared single-line box supplies the height floor and the one + // line box; this field keeps only its own size and text size. + className="flex-1 text-sm text-foreground" returnKeyType="search" autoCorrect={false} autoCapitalize="none" diff --git a/apps/mobile/src/components/pr-review/pr-review-entry-screen-test-utils.ts b/apps/mobile/src/components/pr-review/pr-review-entry-screen-test-utils.ts index e32f8ff4f2d..ddd84c9d385 100644 --- a/apps/mobile/src/components/pr-review/pr-review-entry-screen-test-utils.ts +++ b/apps/mobile/src/components/pr-review/pr-review-entry-screen-test-utils.ts @@ -91,6 +91,11 @@ vi.mock('react-native', () => ({ vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' })); vi.mock('@/components/screen-header', () => ({ ScreenHeader: 'ScreenHeader' })); vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +// The harness calls the screen as a plain function, so a composite component is +// never rendered and its host node never exists. The shared single-line field +// is stubbed as the same-named host, like every other component here; the box +// the field now uses is the one `@/components/ui/input` owns and s3 covers. +vi.mock('@/components/ui/input', () => ({ Input: 'Input' })); vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); vi.mock('@/components/ui/activity-indicator', () => ({ ActivityIndicator: 'ActivityIndicator' })); vi.mock('@/components/ui/icons', () => ({ diff --git a/apps/mobile/src/components/pr-review/pr-review-entry-screen.test.ts b/apps/mobile/src/components/pr-review/pr-review-entry-screen.test.ts index 0b1ac587eb0..3d90c781aac 100644 --- a/apps/mobile/src/components/pr-review/pr-review-entry-screen.test.ts +++ b/apps/mobile/src/components/pr-review/pr-review-entry-screen.test.ts @@ -38,7 +38,7 @@ beforeEach(() => { describe('provider-neutral URL field', () => { it('labels and placeholders name both review nouns, no provider host', async () => { const tree = await renderLoaded(); - const input = find(tree, 'TextInput', () => true); + const input = find(tree, 'Input', () => true); expect(input.props?.placeholder).toBe('Pull request or merge request URL'); expect(input.props?.accessibilityLabel).toBe('Enter a pull request or merge request URL'); expect(String(input.props?.placeholder)).not.toContain('github'); @@ -46,7 +46,7 @@ describe('provider-neutral URL field', () => { it('draws the placeholder as one ellipsized line instead of the wrapping native hint', async () => { const tree = await renderLoaded(); - const input = find(tree, 'TextInput', () => true); + const input = find(tree, 'Input', () => true); // pr-review-home finding: at font scale 2 the native EditText lays the long // placeholder hint out on two lines while Yoga sizes the field to one line, // so the second line is clipped. `numberOfLines` cannot stop that — RN @@ -64,7 +64,7 @@ describe('provider-neutral URL field', () => { it('hides the placeholder overlay once the field has text', async () => { const before = await renderLoaded(); expect(findAll(before, 'PrLinkPlaceholder')).toHaveLength(1); - const input = find(before, 'TextInput', () => true); + const input = find(before, 'Input', () => true); (propsOf(input).onChangeText as (value: string) => void)('https://github.com/a/b/pull/1'); const after = render(); expect(findAll(after, 'PrLinkPlaceholder')).toHaveLength(0); @@ -76,19 +76,22 @@ describe('provider-neutral URL field', () => { expect(ENTRY_SCREEN_SOURCE).not.toMatch(/\bPlatform\b/); }); - it('sizes the field with min-h and no vertical padding per the mobile input rules', async () => { + it('leaves the field box to the shared input and adds no vertical padding', async () => { const tree = await renderLoaded(); - const input = find(tree, 'TextInput', () => true); + const input = find(tree, 'Input', () => true); const classes = String(input.props?.className).split(' '); - // apps/mobile/AGENTS.md: single-line inputs set their height with min-h-*, - // not py-*; vertical padding draws the placeholder off-centre. - expect(classes).toContain('min-h-14'); + // The shared single-line box (`@/components/ui/input`) owns the height floor + // and the one line box; the call site must not re-add vertical padding or a + // fixed height, which drew the placeholder off the value's baseline + // (apps/mobile/AGENTS.md). + expect(classes).toContain('text-base'); expect(classes.filter(name => name.startsWith('py-'))).toEqual([]); + expect(classes.filter(name => /^min-h|^h-\d/.test(name))).toEqual([]); }); it('opens a GitHub PR URL on the GitHub route', async () => { const tree = await renderLoaded(); - const input = find(tree, 'TextInput', () => true); + const input = find(tree, 'Input', () => true); (propsOf(input).onChangeText as (value: string) => void)( 'https://github.com/octocat/hello-world/pull/42' ); @@ -103,7 +106,7 @@ describe('provider-neutral URL field', () => { it('opens a self-managed GitLab MR on the provider route with its instance', async () => { const tree = await renderLoaded(); - const input = find(tree, 'TextInput', () => true); + const input = find(tree, 'Input', () => true); (propsOf(input).onChangeText as (value: string) => void)( 'https://gitlab.example.com/group/sub/repo/-/merge_requests/9' ); @@ -120,7 +123,7 @@ describe('provider-neutral URL field', () => { it('opens a Bitbucket PR on the provider route', async () => { const tree = await renderLoaded(); - const input = find(tree, 'TextInput', () => true); + const input = find(tree, 'Input', () => true); (propsOf(input).onChangeText as (value: string) => void)( 'https://bitbucket.org/acme/api/pull-requests/7/overview' ); @@ -135,7 +138,7 @@ describe('provider-neutral URL field', () => { it('toasts the provider-neutral invalid copy for a link no provider serves', async () => { const tree = await renderLoaded(); - const input = find(tree, 'TextInput', () => true); + const input = find(tree, 'Input', () => true); (propsOf(input).onChangeText as (value: string) => void)('https://example.com/blog/post'); const open = render(); ( @@ -182,7 +185,7 @@ describe('provider-neutral URL field', () => { expect( findAll(before, 'Pressable').some(p => p.props?.accessibilityLabel === 'Clear link') ).toBe(false); - const input = find(before, 'TextInput', () => true); + const input = find(before, 'Input', () => true); (propsOf(input).onChangeText as (value: string) => void)('anything'); const after = render(); expect(find(after, 'Pressable', p => p.accessibilityLabel === 'Clear link')).toBeTruthy(); @@ -196,7 +199,7 @@ describe('provider-neutral URL field', () => { // the single instruction as its eyebrow; the field keeps its placeholder. expect(header.props?.title).toBe('PR Review'); expect(header.props?.eyebrow).toBe('Open a pull request or merge request by URL'); - const input = find(tree, 'TextInput', () => true); + const input = find(tree, 'Input', () => true); expect(input.props?.placeholder).toBe('Pull request or merge request URL'); // The paste block no longer carries a caption repeating the instruction. const list = find(tree, 'PrReviewInboxList', () => true); diff --git a/apps/mobile/src/components/pr-review/pr-review-entry-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-entry-screen.tsx index 6be1d414bb2..09d8d0c6fa8 100644 --- a/apps/mobile/src/components/pr-review/pr-review-entry-screen.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-entry-screen.tsx @@ -4,7 +4,7 @@ import { Clipboard as ClipboardIcon, SearchX, X } from '@/components/ui/icons'; import { DirectionalChevronRight } from '@/components/ui/directional-icons'; import { type ReactNode, useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Alert, Pressable, TextInput, View } from 'react-native'; +import { Alert, Pressable, type TextInput, View } from 'react-native'; import { ActivityIndicator } from '@/components/ui/activity-indicator'; import { EmptyState } from '@/components/empty-state'; @@ -13,6 +13,7 @@ import { PrReviewInboxList } from '@/components/pr-review/pr-review-inbox-list'; import { selectRecentPrRowState } from '@/lib/pr-review/recent-pr-row-state'; import { ScreenHeader } from '@/components/screen-header'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; import { Text } from '@/components/ui/text'; import { announcingToast } from '@/lib/a11y/announcing-toast'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; @@ -247,7 +248,7 @@ export function PrReviewEntryScreen() { testID="pr-link-input-row" collapsable={false} > - 0); }} - // leading-[normal] so no lineHeight reaches the style: an explicit lineHeight - // makes iOS draw the placeholder lower than the typed text (see AGENTS.md). - // min-h-14 (not py-*) sizes the single-line field per the mobile - // input rules and still lets Dynamic Type grow it past the floor. - className="min-h-14 min-w-0 flex-1 bg-transparent pl-3 pr-1 text-base text-foreground leading-[normal]" + // The shared single-line box (`@/components/ui/input`) supplies the + // height floor, the one line box for the placeholder and the value, + // and the RTL content alignment, so this field keeps only its own + // chrome and text size. + className="min-w-0 flex-1 bg-transparent pl-3 pr-1 text-base text-foreground" accessibilityLabel={t('prReview.entry.urlAccessibility')} returnKeyType="go" onSubmitEditing={handleSubmit} diff --git a/apps/mobile/src/components/rename-modal.mounted.test.tsx b/apps/mobile/src/components/rename-modal.mounted.test.tsx index 387b7fcd02a..6c194d79c4c 100644 --- a/apps/mobile/src/components/rename-modal.mounted.test.tsx +++ b/apps/mobile/src/components/rename-modal.mounted.test.tsx @@ -16,6 +16,9 @@ vi.mock('react-native', () => ({ Pressable: 'Pressable', TextInput: 'TextInput', View: 'View', + // `@/components/ui/input` reads `I18nManager.isRTL` through + // `withRtlInputAlignment` on every render. + I18nManager: { isRTL: false }, })); vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); // `withUiDeadline` reads its timeout copy from here; the field contract under diff --git a/apps/mobile/src/components/rename-modal.tsx b/apps/mobile/src/components/rename-modal.tsx index a411e7258a0..71af83a4a71 100644 --- a/apps/mobile/src/components/rename-modal.tsx +++ b/apps/mobile/src/components/rename-modal.tsx @@ -1,8 +1,9 @@ import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Modal, Platform, Pressable, TextInput, View } from 'react-native'; +import { Modal, Platform, Pressable, type TextInput, View } from 'react-native'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { withUiDeadline } from '@/lib/ui-deadline'; @@ -108,7 +109,7 @@ export function RenameModal({ }} > {title} - ({ // Multi-line: an explicit leading-5 plus bounded min/max heights, so the // value soft-wraps into the field and scrolls vertically past the cap. className={cn( - 'rounded-md border border-input bg-background px-3 py-2.5 text-sm text-foreground', - multiline ? 'max-h-40 min-h-24 leading-5' : 'leading-[normal]', + 'rounded-md border border-input bg-background px-3 text-sm text-foreground', + multiline ? 'py-2.5 max-h-40 min-h-24 leading-5' : 'leading-[normal]', pending && 'opacity-50' )} placeholder={placeholder} diff --git a/apps/mobile/src/components/security-agent/notification-settings-screen.tsx b/apps/mobile/src/components/security-agent/notification-settings-screen.tsx index f792c41c52f..0cbbe999eb9 100644 --- a/apps/mobile/src/components/security-agent/notification-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/notification-settings-screen.tsx @@ -5,13 +5,14 @@ import { } from '@kilocode/app-shared/security-agent'; import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { TextInput, View } from 'react-native'; +import { View } from 'react-native'; import { PillGroup } from '@/components/security-agent/settings-pill-group'; import { SettingsSaveButton } from '@/components/security-agent/settings-save-button'; import { ToggleRow } from '@/components/security-agent/settings-toggle-row'; import { PlatformErrorScreen } from '@/components/platform-error-screen'; import { ScreenHeader } from '@/components/screen-header'; +import { Input } from '@/components/ui/input'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { TabScreenScrollView } from '@/components/tab-screen'; @@ -248,7 +249,7 @@ export function NotificationSettingsScreen({ scope }: Readonly<{ scope: string } {t('securityAgent.notifications.slaLeadTimeLabel')} - )} - ({ View: 'View', TextInput: 'TextInput', + // `@/components/ui/input` reads `I18nManager.isRTL` through + // `withRtlInputAlignment` on every render. + I18nManager: { isRTL: false }, })); vi.mock('@shopify/flash-list', () => ({ FlashList: (props: { diff --git a/apps/mobile/src/components/share/share-destination-list.tsx b/apps/mobile/src/components/share/share-destination-list.tsx index 26943ada43f..6d0d1c1e2fe 100644 --- a/apps/mobile/src/components/share/share-destination-list.tsx +++ b/apps/mobile/src/components/share/share-destination-list.tsx @@ -2,7 +2,7 @@ import { FlashList, type ListRenderItemInfo } from '@shopify/flash-list'; import { Search, Terminal } from '@/components/ui/icons'; import { type ReactNode, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { TextInput, View, type ViewStyle } from 'react-native'; +import { View, type ViewStyle } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { SessionListSectionHeader } from '@/components/agents/session-list-section-header'; @@ -10,6 +10,7 @@ import { StoredSessionRow } from '@/components/agents/session-row'; import { CenteredState } from '@/components/centered-state'; import { DestinationOptionRow } from '@/components/destination-option-row'; import { QueryError } from '@/components/query-error'; +import { Input } from '@/components/ui/input'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; @@ -40,14 +41,14 @@ function DestinationSearch({ onChange }: { onChange: (next: string) => void }) { return ( - { }); }); +/** + * Flattens the field's nested style array the way React Native does, without + * `StyleSheet` (the `react-native` mock above does not provide it). + */ +function flattenStyle(style: unknown): Record { + const merged: Record = {}; + for (const entry of Array.isArray(style) ? style : [style]) { + if (Array.isArray(entry)) { + Object.assign(merged, flattenStyle(entry)); + } else if (typeof entry === 'object' && entry !== null) { + Object.assign(merged, entry); + } + } + return merged; +} + describe('FormField reserved validation space', () => { it.each([ ['English', en], @@ -113,11 +129,78 @@ describe('FormField reserved validation space', () => { }); }); +describe('FormField shared single-line box', () => { + it('renders the shared single-line box with centered vertical alignment', () => { + act(() => { + renderer = TestRenderer.create( + createElement(FormField, { label: i18n.t('login.emailAddress') }) + ); + }); + if (!renderer) { + throw new Error('renderer was not created'); + } + const input = renderer.root.findByType('TextInput'); + expect(input.props.className).toContain('min-h-[44px]'); + expect(input.props.className).toContain('px-3'); + expect(input.props.className).toContain('leading-[normal]'); + expect(input.props.className).not.toContain('py-'); + expect(input.props.textAlignVertical).toBe('center'); + }); + + it('keeps the box and the content style when an error appears under the field', () => { + act(() => { + renderer = TestRenderer.create( + createElement(FormField, { label: i18n.t('login.emailAddress') }) + ); + }); + if (!renderer) { + throw new Error('renderer was not created'); + } + const mounted = renderer; + const inputProps = () => mounted.root.findByType('TextInput').props; + // The error swaps the border colour only; every other token is unchanged. + const borderColourTokens = new Set(['border-input', 'border-destructive']); + const stableTokens = (className: string) => + className.split(' ').filter(token => !borderColourTokens.has(token)); + + const before = inputProps(); + expect(before.className).not.toContain('border-destructive'); + + act(() => { + mounted.update( + createElement(FormField, { + label: i18n.t('login.emailAddress'), + error: i18n.t('login.pleaseEnterEmail'), + }) + ); + }); + + const after = inputProps(); + // The message only tints the border: the box's geometry, the vertical + // alignment and the inline style are the ones rendered without it. + expect(after.className).toContain('border-destructive'); + expect(stableTokens(String(after.className))).toEqual(stableTokens(String(before.className))); + for (const token of ['min-h-[44px]', 'px-3', 'leading-[normal]']) { + expect(after.className).toContain(token); + } + expect(after.className).not.toContain('py-'); + expect(after.textAlignVertical).toBe('center'); + expect(after.style).toBeUndefined(); + + // The message is a sibling of the input, not part of its content, so the + // text inside the box cannot move when it appears. + const input = mounted.root.findByType('TextInput'); + const status = mounted.root.findByType(AccessibleStatus); + expect(status.findByType('Text').props.children).toBe(i18n.t('login.pleaseEnterEmail')); + expect(input.findAllByType(AccessibleStatus)).toHaveLength(0); + }); +}); + describe('FormField direction-aware content alignment', () => { - function mountInput(style?: { textAlign: 'center' }) { + function mountInput(props: { style?: { textAlign: 'center' }; textAlign?: 'center' } = {}) { act(() => { renderer = TestRenderer.create( - createElement(FormField, { label: i18n.t('login.emailAddress'), style }) + createElement(FormField, { label: i18n.t('login.emailAddress'), ...props }) ); }); if (!renderer) { @@ -145,6 +228,14 @@ describe('FormField direction-aware content alignment', () => { rtl.isRTL = true; const centered = { textAlign: 'center' } as const; - expect(mountInput(centered).props.style).toEqual([{ textAlign: 'right' }, centered]); + expect(mountInput({ style: centered }).props.style).toEqual([{ textAlign: 'right' }, centered]); + }); + + it('keeps an explicit caller alignment passed as a prop after the RTL default', () => { + // The shared field threads a `textAlign` prop straight to `Input`, so the + // prop channel must survive the RTL default exactly like the style channel. + rtl.isRTL = true; + + expect(flattenStyle(mountInput({ textAlign: 'center' }).props.style).textAlign).toBe('center'); }); }); diff --git a/apps/mobile/src/components/ui/form-field.tsx b/apps/mobile/src/components/ui/form-field.tsx index 240bf208361..37f7a7de320 100644 --- a/apps/mobile/src/components/ui/form-field.tsx +++ b/apps/mobile/src/components/ui/form-field.tsx @@ -1,11 +1,10 @@ import { useRef, useState } from 'react'; -import { TextInput, type TextInputProps, View } from 'react-native'; +import { type TextInput, type TextInputProps, View } from 'react-native'; import { AccessibleStatus } from '@/components/ui/accessible-status'; import { formFieldA11y } from '@/components/ui/form-field-a11y'; +import { Input } from '@/components/ui/input'; import { Text } from '@/components/ui/text'; -import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { withRtlInputAlignment } from '@/lib/rtl-text'; import { cn } from '@/lib/utils'; type FormFieldProps = Omit & @@ -52,7 +51,6 @@ function FormField({ onBlur, ...props }: Readonly) { - const colors = useThemeColors(); const [validationError, setValidationError] = useState(null); const valueRef = useRef(defaultValue ?? ''); const displayedError = validate ? validationError : error; @@ -60,17 +58,16 @@ function FormField({ return ( {label} - { @@ -87,11 +84,12 @@ function FormField({ } }} className={cn( - // min-h-[44px] with no vertical padding: the 44pt height meets the - // Apple HIG touch floor and centers the text, while the padding - // draws the single-line text below the middle. min-h (not h) still - // lets Dynamic Type grow the field past the floor. - 'min-h-[44px] rounded-md border border-input bg-background px-3 text-sm leading-[normal] text-foreground', + // The box (min-h-[44px] px-3 leading-[normal]) lives in + // `@/components/ui/input`, so every single-line field shares it and + // the class cannot be copied back wrong. No vertical padding: iOS + // insets the already-centered text rect by it, drawing the value and + // the placeholder below the middle (apps/mobile/AGENTS.md). + 'rounded-md border border-input bg-background text-sm text-foreground', 'focus:border-ring', displayedError && 'border-destructive', disabled && 'opacity-50', diff --git a/apps/mobile/src/components/ui/input.mounted.test.tsx b/apps/mobile/src/components/ui/input.mounted.test.tsx new file mode 100644 index 00000000000..bec82642bf7 --- /dev/null +++ b/apps/mobile/src/components/ui/input.mounted.test.tsx @@ -0,0 +1,148 @@ +import { createElement } from 'react'; +import { type TextInputProps } from 'react-native'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { Input, INPUT_BOX_CLASS } from './input'; +import { act, TestRenderer } from '@/test/renderer'; + +const rtl = vi.hoisted(() => ({ isRTL: false })); +vi.mock('react-native', () => ({ + Platform: { OS: 'android' }, + TextInput: 'TextInput', + I18nManager: { + get isRTL() { + return rtl.isRTL; + }, + }, +})); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ mutedForeground: '#888888' }), +})); + +let renderer: TestRenderer.ReactTestRenderer | undefined = undefined; + +afterEach(() => { + rtl.isRTL = false; + act(() => { + renderer?.unmount(); + }); +}); + +function mountInput(props: Readonly = {}) { + act(() => { + renderer = TestRenderer.create(createElement(Input, props)); + }); + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer.root.findByType('TextInput'); +} + +/** + * Flattens the component's nested style array the way React Native does, so a + * test can assert the alignment that actually reaches the platform without + * `StyleSheet` (the `react-native` mock above does not provide it). + */ +function flattenStyle(style: unknown): Record { + const merged: Record = {}; + for (const entry of Array.isArray(style) ? style : [style]) { + if (Array.isArray(entry)) { + Object.assign(merged, flattenStyle(entry)); + } else if (typeof entry === 'object' && entry !== null) { + Object.assign(merged, entry); + } + } + return merged; +} + +describe('Input single-line box', () => { + it('keeps the shared box free of vertical padding and a fixed height', () => { + expect(INPUT_BOX_CLASS).toBe('min-h-[44px] px-3 leading-[normal]'); + }); + + it('renders the shared box, centered vertical alignment, and the themed placeholder', () => { + const input = mountInput(); + + expect(input.props.className).toContain('min-h-[44px]'); + expect(input.props.className).toContain('px-3'); + expect(input.props.className).toContain('leading-[normal]'); + expect(input.props.className).not.toContain('py-'); + // min-h, never a fixed height, so Dynamic Type can still grow the field. + expect(input.props.className).not.toMatch(/(?:^|\s)h-/); + expect(input.props.textAlignVertical).toBe('center'); + expect(input.props.placeholderTextColor).toBe('#888888'); + expect(input.props.style).toBeUndefined(); + }); + + it('forces the center alignment over a caller vertical alignment', () => { + const input = mountInput({ textAlignVertical: 'top' }); + + expect(input.props.textAlignVertical).toBe('center'); + }); + + it('keeps a caller placeholder colour over the themed default', () => { + const input = mountInput({ placeholderTextColor: '#123456' }); + + expect(input.props.placeholderTextColor).toBe('#123456'); + }); + + it('keeps a caller size and text size in the merged className', () => { + const input = mountInput({ className: 'h-12 px-4 text-lg' }); + + expect(input.props.className).toContain('h-12'); + expect(input.props.className).toContain('text-lg'); + expect(input.props.className).toContain('px-4'); + // The caller's horizontal padding wins; the box's floor and line height stay. + expect(input.props.className).not.toContain('px-3'); + expect(input.props.className).toContain('min-h-[44px]'); + expect(input.props.className).toContain('leading-[normal]'); + }); + + it('applies the RTL content alignment inline', () => { + rtl.isRTL = true; + + expect(mountInput().props.style).toEqual([{ textAlign: 'right' }, undefined]); + }); + + it('keeps a caller textAlign prop over the RTL default', () => { + // React Native flattens `style` after the `textAlign` prop, so without + // folding the prop into the style the injected right alignment would win. + // The SLA day fields pass `textAlign="center"` and must stay centred. + rtl.isRTL = true; + + expect(flattenStyle(mountInput({ textAlign: 'center' }).props.style).textAlign).toBe('center'); + }); + + it('keeps a caller textAlign prop after the caller style and the RTL default', () => { + rtl.isRTL = true; + + const style = flattenStyle( + mountInput({ textAlign: 'center', style: { color: '#abcdef' } }).props.style + ); + + expect(style).toEqual({ textAlign: 'center', color: '#abcdef' }); + }); + + it('keeps a caller textAlign prop in a left-to-right interface', () => { + rtl.isRTL = false; + + expect(flattenStyle(mountInput({ textAlign: 'center' }).props.style).textAlign).toBe('center'); + }); +}); + +describe('Input multiline', () => { + it('keeps the caller box and vertical alignment', () => { + const input = mountInput({ multiline: true, className: 'leading-6', textAlignVertical: 'top' }); + + expect(input.props.className).toBe('leading-6'); + expect(input.props.className).not.toContain('min-h-[44px]'); + expect(input.props.textAlignVertical).toBe('top'); + }); + + it('does not force an alignment when the caller sets none', () => { + const input = mountInput({ multiline: true }); + + expect(input.props.className).not.toContain('min-h-[44px]'); + expect(input.props.textAlignVertical).toBeUndefined(); + }); +}); diff --git a/apps/mobile/src/components/ui/input.tsx b/apps/mobile/src/components/ui/input.tsx new file mode 100644 index 00000000000..2409d96592d --- /dev/null +++ b/apps/mobile/src/components/ui/input.tsx @@ -0,0 +1,92 @@ +import { type StyleProp, TextInput, type TextInputProps, type TextStyle } from 'react-native'; + +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { withRtlInputAlignment } from '@/lib/rtl-text'; +import { cn } from '@/lib/utils'; + +// The box's floor and horizontal padding come before the caller's classes, so +// a caller's `px-4` overrides the shared `px-3` through tailwind-merge. Its +// line height comes after them: tailwind-merge drops `leading-*` when a later +// `text-*` sets a line height of its own, and every caller keeps its own text +// size (`text-sm` here), so the box has to re-assert the one line box both the +// placeholder and the value are drawn in. +const INPUT_BOX_SHAPE_CLASS = 'min-h-[44px] px-3'; +const INPUT_BOX_LINE_HEIGHT_CLASS = 'leading-[normal]'; + +/** The one single-line box. Every single-line field renders this. */ +export const INPUT_BOX_CLASS = `${INPUT_BOX_SHAPE_CLASS} ${INPUT_BOX_LINE_HEIGHT_CLASS}`; + +/** + * The shared single-line box: `min-h-[44px] px-3 leading-[normal]`. + * + * This is the only place the single-line box lives. A call site keeps its own + * chrome (border, fill, text size) and its own horizontal padding — a caller's + * `px-4` overrides the shared `px-3` through tailwind-merge, because the + * caller's className comes after the box's shape in `cn` — but it must not add + * vertical padding or a fixed height: the box already brings `min-h-[44px]` + * (the touch floor, and a floor rather than a height so Dynamic Type still + * grows the field) and `leading-[normal]` (one line box for the placeholder + * and the value, which the same TextInput draws). + * + * The request's "explicit vertical padding" is delivered as this explicit + * vertical box instead: `apps/mobile/AGENTS.md` (Text inputs) forbids `py-*` + * on a single-line input, because iOS insets the already-centered text rect by + * the padding and draws the text and the placeholder low. Vertical padding + * would move both below the middle, which the "focused and unfocused put the + * text in the same place" requirement and the 44pt floor cannot accept. + * `textAlignVertical: 'center'` is the Android half of the same fix: the + * platform's default gravity is top in a taller box while the placeholder is + * drawn by a different path, which is the baseline split the sign-in email + * field showed. + * + * `withRtlInputAlignment` stays on every single-line input: RN 0.86 does not + * resolve `textAlign: 'auto'` from the native direction, so a Latin address + * stays left-aligned under a right-aligned label in an RTL catalog + * (`rtl-text.ts`). + * + * An explicit caller alignment survives that default whichever channel carries + * it. A caller's own `style` already wins, because `withRtlInputAlignment` + * prepends `{ textAlign: 'right' }` ahead of it. A caller's `textAlign` *prop* + * does not: React Native resolves a flattened `style` after the prop, so the + * injected right alignment would override a `textAlign="center"` prop in RTL + * (the Security Agent SLA day fields, which are centred in every locale). + * `Input` folds the prop into the style behind the caller's own style, so the + * caller's choice stays last and wins on both platforms and in both + * directions. + * + * A `multiline` caller is a different control: it keeps its own box (an + * explicit `leading-*`) and its own `textAlignVertical`, so neither the shared + * box nor the forced vertical alignment applies. + */ +function Input({ + className, + style, + textAlign, + placeholderTextColor, + multiline, + textAlignVertical, + ...props +}: Readonly>) { + const colors = useThemeColors(); + // Fold the explicit `textAlign` prop into the style after the caller's own + // style, so it lands after the RTL default too: RN flattens `style` after + // the `textAlign` prop, so the prop alone would lose to `withRtlInputAlignment` + // in RTL. + const contentStyle: StyleProp | undefined = textAlign ? [style, { textAlign }] : style; + return ( + + ); +} + +export { Input }; diff --git a/apps/mobile/src/components/voice-language-picker-sheet.tsx b/apps/mobile/src/components/voice-language-picker-sheet.tsx index 1dce68a1a47..82645c9be4e 100644 --- a/apps/mobile/src/components/voice-language-picker-sheet.tsx +++ b/apps/mobile/src/components/voice-language-picker-sheet.tsx @@ -2,7 +2,7 @@ import { type TFunction } from 'i18next'; import { type ReactNode, useCallback, useMemo, useState } from 'react'; import { useRouter } from 'expo-router'; import { useTranslation } from 'react-i18next'; -import { FlatList, I18nManager, TextInput, View } from 'react-native'; +import { FlatList, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { EmptyState } from '@/components/empty-state'; @@ -10,6 +10,7 @@ import { PickerSheet } from '@/components/picker-sheet'; import { QueryError } from '@/components/query-error'; import { ChoiceRow } from '@/components/ui/choice-row'; import { Mic, Search, SearchX } from '@/components/ui/icons'; +import { Input } from '@/components/ui/input'; import { Skeleton } from '@/components/ui/skeleton'; import { foldForSearch } from '@/i18n/fold-for-search'; import { languageRows } from '@/i18n/language-rows'; @@ -28,8 +29,6 @@ import { import { useGatewayTranscriptionPreference } from '@/lib/voice-input/gateway/gateway-transcription-preference'; import { useVoiceRecognitionLanguages } from '@/lib/voice-input/use-voice-recognition-languages'; -const SEARCH_RTL = { textAlign: 'right' } as const; - // Static skeleton rows: count and shape match the real ChoiceRow rows (name // line + caption; the trailing check is transparent unless selected, so the // skeleton carries no trailing control) so the swap never moves layout and @@ -226,7 +225,6 @@ export function VoiceLanguagePickerSheet() { const chosen = useVoiceInputLanguage(); const chosenLoaded = useVoiceInputLanguageLoaded(); const [query, setQuery] = useState(''); - const isRtl = I18nManager.isRTL; const onSelect = useCallback( (tag: string | null) => { @@ -290,21 +288,20 @@ export function VoiceLanguagePickerSheet() { scrollable={false} headerContent={ // Same filled pill as the account language picker and the repository - // and share pickers: one search-field shape for the same control. + // and share pickers: one search-field shape for the same control. The + // field itself is the shared single-line `Input`, so the pill owns only + // the chrome while the field owns the height floor, the one line box for + // the placeholder and the value, and the RTL content alignment. - ({ isRTL: false })); +vi.mock('react-native', () => ({ I18nManager: rtl })); + const { compile } = createRequire(import.meta.url)( 'react-native-css/compiler' ) as typeof NativeCSSCompiler; @@ -55,7 +64,9 @@ describe('shared tap-target geometry', () => { expect(MIN_TAP_TARGET_DP).toBe(28); expect(TOUCH_TARGET_DP).toBe(44); expect(COMPACT_CONTROL_HIT_SLOP_DP).toBe(8); - expect(INLINE_LINK_HIT_SLOP_DP).toBe(10); + expect(INLINE_LINK_FACING_HIT_SLOP_DP).toBe(4); + expect(INLINE_LINK_OUTER_HIT_SLOP_DP).toBe(12); + expect(INLINE_LINK_VERTICAL_HIT_SLOP_DP).toBe(8); }); it('renders the compact control box from whole pixels that clear the audit floor', () => { @@ -75,20 +86,64 @@ describe('shared tap-target geometry', () => { it('carries an inline link from the audit floor to the design target with its slop', () => { expect(INLINE_LINK_BOX_CLASS).toContain('min-h-[28px]'); expect(INLINE_LINK_BOX_CLASS).toContain('min-w-[28px]'); - expect(tapTargetReachDp(MIN_TAP_TARGET_DP, INLINE_LINK_HIT_SLOP_DP)).toBe(48); - expect(tapTargetReachDp(MIN_TAP_TARGET_DP, INLINE_LINK_HIT_SLOP_DP)).toBeGreaterThanOrEqual( + // The vertical reach is symmetric: 28 + 2 * 8 = 44. + expect(tapTargetReachDp(MIN_TAP_TARGET_DP, INLINE_LINK_VERTICAL_HIT_SLOP_DP)).toBe( + TOUCH_TARGET_DP + ); + // The horizontal reach is asymmetric (4dp toward the neighbour, 12dp away), + // so it is box + facing + outer rather than tapTargetReachDp's 2 * slop. + expect(MIN_TAP_TARGET_DP + INLINE_LINK_FACING_HIT_SLOP_DP + INLINE_LINK_OUTER_HIT_SLOP_DP).toBe( TOUCH_TARGET_DP ); }); - it('reserves both facing inline-link slops on the connector between the links', async () => { - expect(INLINE_LINK_CONNECTOR_CLASS).toContain('min-w-[20px]'); + it('reserves both facing inline-link reaches on the connector between the links', async () => { + expect(INLINE_LINK_CONNECTOR_CLASS).toContain('min-w-[10px]'); // Two inline links separated only by the connector: each reaches - // INLINE_LINK_HIT_SLOP_DP toward the other, so a connector narrower than - // their sum overlaps the two touch regions and the later link claims a tap - // meant for the first. The compiled width is what the layout actually uses. + // INLINE_LINK_FACING_HIT_SLOP_DP toward the other, so a connector narrower + // than their sum lets the two touch regions meet and the later link claims + // a tap meant for the first. The compiled width is what the layout uses. expect(await compiledDp(INLINE_LINK_CONNECTOR_CLASS, 'minWidth')).toBeGreaterThanOrEqual( - 2 * INLINE_LINK_HIT_SLOP_DP + 2 * INLINE_LINK_FACING_HIT_SLOP_DP ); }); + + it('resolves each link end to the side that faces the connector', () => { + const vertical = INLINE_LINK_VERTICAL_HIT_SLOP_DP; + const facing = INLINE_LINK_FACING_HIT_SLOP_DP; + const outer = INLINE_LINK_OUTER_HIT_SLOP_DP; + + // LTR: the link at the sentence's start sits on the left and faces right. + rtl.isRTL = false; + expect(inlineLinkHitSlop('start')).toEqual({ + top: vertical, + bottom: vertical, + left: outer, + right: facing, + }); + expect(inlineLinkHitSlop('end')).toEqual({ + top: vertical, + bottom: vertical, + left: facing, + right: outer, + }); + + // RTL: the flex-row mirrors, so the start link sits on the right and faces + // left; the two ends swap their physical sides. + rtl.isRTL = true; + expect(inlineLinkHitSlop('start')).toEqual({ + top: vertical, + bottom: vertical, + left: facing, + right: outer, + }); + expect(inlineLinkHitSlop('end')).toEqual({ + top: vertical, + bottom: vertical, + left: outer, + right: facing, + }); + + rtl.isRTL = false; + }); }); diff --git a/apps/mobile/src/lib/a11y/tap-target.ts b/apps/mobile/src/lib/a11y/tap-target.ts index a3ab2fbe552..6656bd70d9e 100644 --- a/apps/mobile/src/lib/a11y/tap-target.ts +++ b/apps/mobile/src/lib/a11y/tap-target.ts @@ -1,3 +1,5 @@ +import { I18nManager } from 'react-native'; + // The one place the mobile tap-target geometry lives. Components import these // numbers and classes instead of re-spelling them, so the audit floor, the // design target and the box that carries them stay in step. @@ -52,11 +54,23 @@ export const INLINE_LINK_BOX_CLASS = 'min-h-[28px] min-w-[28px] -my-[7px] items-center justify-center'; /** - * Per-side horizontal `hitSlop` for {@link INLINE_LINK_BOX_CLASS}, lifting the - * control-size audit's 28dp floor to `DESIGN.md:364`'s touch region across the - * sentence: 28 + 20 = 48pt. + * Per-side horizontal `hitSlop` of an inline link pointing at its neighbour. + * The two legal links are separated only by the sentence's connector, and the + * connector's own floor ({@link INLINE_LINK_CONNECTOR_CLASS}) is the only thing + * keeping the two facing reaches apart, so this side is deliberately short: the + * link still reaches `DESIGN.md:364`'s 44pt from its {@link MIN_TAP_TARGET_DP} + * floor as `28 + facing + outer`, while only `facing` of that reach points at + * the neighbour, so the visible gap between the words stays the sentence's own + * spacing instead of the reserved box's. */ -export const INLINE_LINK_HIT_SLOP_DP = 10; +export const INLINE_LINK_FACING_HIT_SLOP_DP = 4; + +/** + * Per-side horizontal `hitSlop` of an inline link pointing away from its + * neighbour, over plain prose with no other control to reach: + * `28 + 4 + 12 = 44pt`. + */ +export const INLINE_LINK_OUTER_HIT_SLOP_DP = 12; /** * Per-side vertical `hitSlop` for {@link INLINE_LINK_BOX_CLASS}. `DESIGN.md:364` @@ -73,15 +87,24 @@ export const INLINE_LINK_HIT_SLOP_DP = 10; export const INLINE_LINK_VERTICAL_HIT_SLOP_DP = 8; /** - * The `hitSlop` both legal links render: at least `DESIGN.md:364`'s 44pt touch - * region in both axes (48pt horizontally, 44pt vertically). + * The per-side `hitSlop` of the legal link that sits at the given end of the + * sentence. `hitSlop` is physical while the sentence's ends are logical, so the + * start/end resolve to left/right through the interface direction: a flex-row + * mirrors in RTL, so the link at the sentence's start faces the connector on its + * other physical side. Both links keep the same vertical slop + * ({@link INLINE_LINK_VERTICAL_HIT_SLOP_DP}); only the horizontal sides differ, + * facing = {@link INLINE_LINK_FACING_HIT_SLOP_DP} and outer = + * {@link INLINE_LINK_OUTER_HIT_SLOP_DP}. */ -export const INLINE_LINK_HIT_SLOP = { - top: INLINE_LINK_VERTICAL_HIT_SLOP_DP, - bottom: INLINE_LINK_VERTICAL_HIT_SLOP_DP, - left: INLINE_LINK_HIT_SLOP_DP, - right: INLINE_LINK_HIT_SLOP_DP, -} as const; +export function inlineLinkHitSlop(edge: 'start' | 'end') { + const facesRight = edge === 'start' ? !I18nManager.isRTL : I18nManager.isRTL; + return { + top: INLINE_LINK_VERTICAL_HIT_SLOP_DP, + bottom: INLINE_LINK_VERTICAL_HIT_SLOP_DP, + left: facesRight ? INLINE_LINK_OUTER_HIT_SLOP_DP : INLINE_LINK_FACING_HIT_SLOP_DP, + right: facesRight ? INLINE_LINK_FACING_HIT_SLOP_DP : INLINE_LINK_OUTER_HIT_SLOP_DP, + }; +} /** * The extra vertical margin the legal sentence row carries, and its class. @@ -102,16 +125,17 @@ export const INLINE_LINK_ROW_MARGIN_DP = 5; export const INLINE_LINK_ROW_CLASS = 'my-[5px]'; /** - * The class an inline-link connector carries so the two links' facing slops do - * not overlap. The connector node is the only thing between them, so it has to - * be at least `2 * {@link INLINE_LINK_HIT_SLOP_DP}` = 20dp wide; a narrower - * node (ru `" и "`, pl `" i "`, ar `" و "`, zh `" 和 "`) would let the second - * link's left slop reach into the first's right slop, and a tap between them - * would be claimed by the later sibling. English `" and "` is already wider, so - * its spacing is unchanged; `text-center` keeps the shorter connectors centred - * on the space they reserve. + * The class an inline-link connector carries so the two links' facing reaches do + * not overlap. The connector node is the only thing between them, so its floor + * is `2 * {@link INLINE_LINK_FACING_HIT_SLOP_DP}` = 8dp plus 2dp of headroom, so + * the two 4dp facing reaches never meet: 10dp. The old 20dp floor was the + * symmetric `2 * 10dp` slop, which centred a short conjunction (ru `" и "`, + * pl `" i "`, ar `" و "`, zh `" 和 "`) in 20dp and added ~4.5dp of gap on each + * side of the `i` on top of the sentence's own space. English `" and "` is + * already wider, so its spacing is unchanged; `text-center` keeps the shorter + * connectors centred on the space they reserve. */ -export const INLINE_LINK_CONNECTOR_CLASS = 'min-w-[20px] text-center'; +export const INLINE_LINK_CONNECTOR_CLASS = 'min-w-[10px] text-center'; /** * The reach a box plus its per-side slop offers. This is the number the tests diff --git a/apps/mobile/src/lib/rtl-text.ts b/apps/mobile/src/lib/rtl-text.ts index 0f90fa75bca..f9851f746a4 100644 --- a/apps/mobile/src/lib/rtl-text.ts +++ b/apps/mobile/src/lib/rtl-text.ts @@ -24,7 +24,9 @@ export const RTL_WRITING_DIRECTION: TextStyle = { writingDirection: 'rtl' }; * 2026-09-22). Naming the alignment keeps every field on the interface's side, * which is what the language search fields already do. * - * Applied to a `TextInput` through `withRtlInputAlignment`; `Text` keeps + * Applied to a `TextInput` through `withRtlInputAlignment`; the shared + * single-line field, `@/components/ui/input`, applies it to every single-line + * input, so a call site does not repeat it. `Text` keeps * `RTL_WRITING_DIRECTION` above, which leaves an explicit `text-center` alone. * It reaches the input as an inline style, not a class: NativeWind maps * `textAlign` to a native prop for `TextInput` and crashes on it in this diff --git a/apps/mobile/src/lib/single-line-input-guard.test.ts b/apps/mobile/src/lib/single-line-input-guard.test.ts new file mode 100644 index 00000000000..b40db6edec7 --- /dev/null +++ b/apps/mobile/src/lib/single-line-input-guard.test.ts @@ -0,0 +1,603 @@ +/* eslint-disable max-lines -- the guard is one cohesive test file: the alias-resolving scan, the reasoned allowlist and owner-boundary record, and the synthetic cases that prove the scan all read together. */ +// Source guard for the "one single-line box" class: a single-line text field +// must render the shared `Input` (`src/components/ui/input.tsx`), never a raw +// ``. The raw control draws its placeholder and its value from two +// different boxes — on Android the value starts at the top of a taller box +// while the placeholder is centred — so the class can only be closed at the one +// shared component. A raw single-line field anywhere else fails this guard. +// The import name is resolved, not spelled: a file that imports +// `TextInput as RNTextInput` and renders `` holds a raw field too, +// so aliasing the import cannot hide one. +// +// Multiline composers are a different control: they keep their own box (an +// explicit `leading-*`, their own `textAlignVertical`) and cannot use the +// single-line shared box, so they render the raw control with `multiline`. They +// are listed in `ALLOWLIST` with the reason they stay raw, and every raw +// `` in a listed file must carry the real `multiline` prop — not the +// bare word inside another attribute or string, and not `multiline={false}`, so +// a new single-line field inside an allowlisted composer still fails. +// +// One more exemption exists, and it is not an allowlist: `OWNER_BOUNDARY_INPUTS` +// pins the pre-existing single-line fields inside the Kilo Claw owner boundary +// (`src/components/kiloclaw/**`, `src/app/(app)/kiloclaw/**`). The 2026-09-02 +// owner-boundary hard gate forbids this section from changing that subtree, so +// those fields are the Kilo Claw owner's to migrate. Each entry names one +// `path:line`, so only the field that exists today passes: a new single-line +// field in one of those files is reported again, and so is one that moves. +// +// eslint-disable-next-line import/no-nodejs-modules -- vitest-only guard, runs in node, never bundled into the app +import { readdirSync, readFileSync } from 'node:fs'; +// eslint-disable-next-line import/no-nodejs-modules -- vitest-only guard, runs in node, never bundled into the app +import { dirname, join, relative } from 'node:path'; +// eslint-disable-next-line import/no-nodejs-modules -- vitest-only guard, runs in node, never bundled into the app +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +/** apps/mobile, so a report reads `src/components/Foo.tsx:12`. */ +const APP_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..'); +const SRC_ROOT = join(APP_ROOT, 'src'); + +/** The one shared single-line box. It holds the app's raw single-line TextInput. */ +const SHARED_INPUT_PATH = 'src/components/ui/input.tsx'; + +/** + * The multiline composers that legitimately stay raw, each with why. A raw + * `` in one of these files must carry the real `multiline` prop — + * not the word inside a string, and not `multiline={false}`; a new single-line + * field in one of them is still a violation. + */ +const ALLOWLIST: Readonly> = { + 'src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/instructions.tsx': + 'multiline instruction editor (`h-32`, `leading-5`)', + 'src/components/agents/chat-composer-input-row.tsx': 'multiline prompt composer', + 'src/components/agents/new-session-prompt.tsx': + 'multiline new-session prompt composer (multiline, leading-6, measured height)', + 'src/components/code-reviewer/manual-review-screen.tsx': + 'multiline `h-24` summary editor (its single-line URL field uses the shared box)', + 'src/components/kilo-chat/message-input-view.tsx': 'multiline chat composer', + 'src/components/kiloclaw/version-pin-row.tsx': 'multiline pin editor (:87-99)', + 'src/components/pr-review/discussion/reply-input.tsx': 'multiline reply composer', + 'src/components/pr-review/merge/pr-merge-sheet-parts.tsx': + 'multiline message body (and its multiline title field)', + 'src/components/pr-review/pr-review-comment-composer-parts.tsx': 'multiline comment body', + 'src/components/pr-review/pr-review-pending-comment-row.tsx': 'multiline pending comment', + 'src/components/security-agent/dismiss-finding-screen.tsx': + 'multiline dismissal comment (:264-267)', + 'src/components/ui/selectable-text.tsx': 'read-only multiline selectable text (:37-42)', + 'src/components/voice-test-field.tsx': 'multiline voice test transcript', +}; + +/** + * Pre-existing raw single-line fields inside the Kilo Claw owner boundary. + * + * This is deliberately not part of `ALLOWLIST`: it is not a multiline composer, + * it is a file this section's owner boundary forbids it to change. The Kilo Claw + * owner migrates those fields in their own slice; until then the guard records + * the leak instead of hiding it. The key is one `path:line`, so the exemption + * covers only the field that exists today — a new single-line field in one of + * these files, or the existing one moving, is a violation again — and the test + * below fails when an entry no longer names a raw field, so the record cannot + * outlive the thing it records. + */ +const OWNER_BOUNDARY_INPUTS: Readonly> = { + 'src/app/(app)/kiloclaw/[instance-id]/settings/model-list.tsx:162': + 'Kilo Claw owner boundary: the models search field stays raw until the Kilo Claw owner migrates it', + 'src/components/kiloclaw/onboarding/identity-step.tsx:312': + 'Kilo Claw owner boundary: the onboarding bot-name field stays raw until the Kilo Claw owner migrates it', + 'src/components/kiloclaw/onboarding/identity-step.tsx:421': + 'Kilo Claw owner boundary: the onboarding location field stays raw until the Kilo Claw owner migrates it', +}; + +/** A named `react-native` import, in a single- or multi-line statement. */ +const REACT_NATIVE_IMPORT = /import\s+(?:type\s+)?\{([^}]*)\}\s+from\s*['"]react-native['"]/g; + +/** An alias binding inside such an import: `TextInput as RNTextInput`. */ +const TEXT_INPUT_ALIAS = /\bTextInput\s+as\s+([A-Za-z_$][\w$]*)\b/g; + +/** + * The local names `TextInput` is imported under in `code`. + * + * `TextInput` is always scanned, so a source that renders it without a + * resolvable import is still caught; `TextInput as RNTextInput` adds + * `RNTextInput`. Every alias binding in an import block is read, so a second + * `TextInput as X` cannot hide behind the first. Longest first, so + * `RNTextInput` is preferred over a name it contains. + */ +function textInputNames(code: string): string[] { + const names = new Set(['TextInput']); + for (const match of code.matchAll(REACT_NATIVE_IMPORT)) { + for (const alias of (match[1] ?? '').matchAll(TEXT_INPUT_ALIAS)) { + const name = alias[1]; + if (name !== undefined) { + names.add(name); + } + } + } + return [...names].toSorted((a, b) => b.length - a.length); +} + +/** + * A literal string escaped for `new RegExp`. A local import name is a legal JS + * identifier, and `$` is one of its characters, so the name must reach the + * pattern as a literal: unescaped, `$TI` is an end anchor that matches nothing. + */ +function escapeRegExp(value: string): string { + return value.replaceAll(/[.*+?^${}()|[\]\\]/g, match => `\\${match}`); +} + +/** + * The `multiline` prop on an opening tag. + * + * The word must be the prop: a `multiline` inside another attribute or a string + * value (`placeholder="multiline"`) is not, and the prop must not be explicitly + * falsy. An allowlisted composer that renders `multiline={false}` is a + * single-line field, so it must still be reported rather than pass on the bare + * word. The pattern is matched against the tag with its string literals blanked + * out, so only a real attribute token can match. + */ +const MULTILINE_PROP = /(?:^|\s)multiline(?![\w$])(?!\s*=\s*\{?\s*false\b)/; + +/** Whether the opening tag carries a real, non-falsy `multiline` prop. */ +function carriesMultiline(block: string): boolean { + return MULTILINE_PROP.test(withoutStringLiterals(block)); +} + +/** + * `tag` with the contents of every string and template literal blanked out, + * keeping the quotes so positions do not shift. A `multiline` word inside a + * string value then cannot be mistaken for the prop. + */ +function withoutStringLiterals(tag: string): string { + let stripped = ''; + let quote: string | null = null; + for (let index = 0; index < tag.length; index += 1) { + const ch = tag.charAt(index); + if (quote !== null) { + if (ch === '\\') { + index += 1; + } else if (ch === quote) { + quote = null; + stripped += ch; + } + } else { + if (ch === '"' || ch === "'" || ch === '`') { + quote = ch; + } + stripped += ch; + } + } + return stripped; +} + +type RawInput = { line: number; block: string }; + +/** + * Finds every raw JSX opening of a `TextInput` local name in `files` that may + * not stay raw. + * + * A path is fine when it is the shared single-line box, or when it is an + * allowlisted multiline composer and the occurrence carries `multiline`. A + * `path:line` recorded in `OWNER_BOUNDARY_INPUTS` is deferred, not allowed. + * Everything else is reported as `path:line`, in path order. + */ +export function unsharedSingleLineInputs(files: Record): string[] { + return Object.keys(files) + .toSorted() + .flatMap(path => { + const source = files[path]; + if (!isScanned(path) || source === undefined) { + return []; + } + return rawTextInputs(source) + .filter(raw => !isAllowedRawInput(path, raw)) + .map(raw => `${path}:${raw.line}`); + }); +} + +/** The product sources the guard owns: no test file, no test support. */ +function isScanned(path: string): boolean { + return ( + path.startsWith('src/') && + (path.endsWith('.ts') || path.endsWith('.tsx')) && + !path.includes('.test.') && + !path.startsWith('src/test/') + ); +} + +function isAllowedRawInput(path: string, raw: RawInput): boolean { + if (path === SHARED_INPUT_PATH) { + return true; + } + if (Object.hasOwn(OWNER_BOUNDARY_INPUTS, `${path}:${raw.line}`)) { + return true; + } + return Object.hasOwn(ALLOWLIST, path) && carriesMultiline(raw.block); +} + +/** + * Every raw JSX opening of a `TextInput` local name in one source, with the + * line it opens on. The lookbehind drops a type reference such as + * `useRef` or `RefObject`: the `<` or a word + * character precedes it, which JSX never does. + */ +function rawTextInputs(source: string): RawInput[] { + const code = stripComments(source); + const jsxTextInput = new RegExp( + `(? escapeRegExp(name)) + .join('|')})\\b`, + 'g' + ); + const found: RawInput[] = []; + for (const match of code.matchAll(jsxTextInput)) { + const start = match.index; + // A `<` (with only whitespace between) before the tag is a type argument — + // `RefObject<\n TextInput | null>` — never JSX, which puts `>`, `(`, `,`, + // `=` or whitespace there. + if (!/<\s*$/.test(code.slice(0, start))) { + found.push({ + line: code.slice(0, start).split('\n').length, + block: openingTag(code, start), + }); + } + } + return found; +} + +/** The JSX opening tag at `start`, through the `>` that closes it. */ +function openingTag(code: string, start: number): string { + let depth = 0; + let quote: string | null = null; + for (let index = start; index < code.length; index += 1) { + const ch = code.charAt(index); + if (quote !== null) { + if (ch === '\\') { + index += 1; + } else if (ch === quote) { + quote = null; + } + } else if (ch === '"' || ch === "'" || ch === '`') { + quote = ch; + } else if (ch === '{') { + depth += 1; + } else if (ch === '}') { + depth -= 1; + } else if (ch === '>' && depth === 0) { + return code.slice(start, index + 1); + } + } + return code.slice(start); +} + +/** + * Removes block and line comments while keeping line numbers, and leaves + * string and template literals alone so a `//` inside a URL stays code. + */ +function stripComments(source: string): string { + let code = ''; + let index = 0; + let quote: string | null = null; + while (index < source.length) { + const ch = source.charAt(index); + const next = source.charAt(index + 1); + if (quote !== null) { + code += ch; + if (ch === '\\') { + code += next; + index += 2; + } else { + if (ch === quote) { + quote = null; + } + index += 1; + } + } else if (ch === '"' || ch === "'" || ch === '`') { + quote = ch; + code += ch; + index += 1; + } else if (ch === '/' && next === '/') { + while (index < source.length && source.charAt(index) !== '\n') { + index += 1; + } + } else if (ch === '/' && next === '*') { + index += 2; + while ( + index < source.length && + !(source.charAt(index) === '*' && source.charAt(index + 1) === '/') + ) { + if (source.charAt(index) === '\n') { + code += '\n'; + } + index += 1; + } + index += 2; + } else { + code += ch; + index += 1; + } + } + return code; +} + +/** The real product tree, keyed as the guard reports it (`src/...`). */ +function readSourceTree(dir: string = SRC_ROOT): Record { + const files: Record = {}; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + Object.assign(files, readSourceTree(full)); + } else { + const path = relative(APP_ROOT, full); + if (isScanned(path)) { + files[path] = readFileSync(full, 'utf8'); + } + } + } + return files; +} + +/** A single-line raw field in a component that is not the shared box. */ +const RAW_SINGLE_LINE = [ + "import { TextInput } from 'react-native';", + '', + 'export function Whatever() {', + ' return ;', + '}', + '', +].join('\n'); + +/** The same field routed through the shared component. */ +const ROUTED_SINGLE_LINE = [ + "import { Input } from '@/components/ui/input';", + '', + 'export function Whatever() {', + ' return ;', + '}', + '', +].join('\n'); + +/** The same raw field, reached through an aliased `react-native` import. */ +const ALIASED_SINGLE_LINE = [ + "import { TextInput as RNTextInput } from 'react-native';", + '', + 'export function Whatever() {', + ' return ;', + '}', + '', +].join('\n'); + +describe('single-line input guard', () => { + it('routes every raw single-line TextInput through the shared input box', () => { + const files = readSourceTree(); + expect(Object.keys(files).length).toBeGreaterThan(100); + expect( + unsharedSingleLineInputs(files), + 'render single-line fields with @/components/ui/input, add a reasoned multiline allowlist entry, or record the field in OWNER_BOUNDARY_INPUTS' + ).toEqual([]); + }); + + it('still sees the shared box, so the scan cannot pass by finding nothing', () => { + const files = readSourceTree(); + const shared = files[SHARED_INPUT_PATH]; + expect(shared).toBeDefined(); + expect(rawTextInputs(shared ?? '').length).toBeGreaterThan(0); + }); + + it('reports a raw single-line TextInput', () => { + expect(unsharedSingleLineInputs({ 'src/components/Whatever.tsx': RAW_SINGLE_LINE })).toEqual([ + 'src/components/Whatever.tsx:4', + ]); + }); + + it('accepts the same field routed through the shared input', () => { + expect(unsharedSingleLineInputs({ 'src/components/Whatever.tsx': ROUTED_SINGLE_LINE })).toEqual( + [] + ); + }); + + it('keeps an allowlisted composer multiline-only', () => { + const allowlisted = 'src/components/agents/chat-composer-input-row.tsx'; + const multiline = [ + "import { TextInput } from 'react-native';", + '', + 'export function Composer() {', + ' return ;', + '}', + '', + ].join('\n'); + expect(unsharedSingleLineInputs({ [allowlisted]: multiline })).toEqual([]); + expect(unsharedSingleLineInputs({ [allowlisted]: RAW_SINGLE_LINE })).toEqual([ + `${allowlisted}:4`, + ]); + }); + + it('reports a field in an allowlisted file that turns multiline off', () => { + const allowlisted = 'src/components/agents/chat-composer-input-row.tsx'; + const explicitlySingleLine = [ + "import { TextInput } from 'react-native';", + '', + 'export function Composer() {', + ' return ;', + '}', + '', + ].join('\n'); + expect(unsharedSingleLineInputs({ [allowlisted]: explicitlySingleLine })).toEqual([ + `${allowlisted}:4`, + ]); + }); + + it('does not read the word multiline inside a string attribute as the prop', () => { + const allowlisted = 'src/components/agents/chat-composer-input-row.tsx'; + const stringValueOnly = [ + "import { TextInput } from 'react-native';", + '', + 'export function Composer() {', + ' return ;', + '}', + '', + ].join('\n'); + expect(unsharedSingleLineInputs({ [allowlisted]: stringValueOnly })).toEqual([ + `${allowlisted}:4`, + ]); + }); + + it('reports a raw single-line field reached through an aliased import', () => { + expect( + unsharedSingleLineInputs({ 'src/components/Whatever.tsx': ALIASED_SINGLE_LINE }) + ).toEqual(['src/components/Whatever.tsx:4']); + }); + + it('accepts an aliased multiline field in an allowlisted composer', () => { + const allowlisted = 'src/components/agents/chat-composer-input-row.tsx'; + const aliasedMultiline = [ + "import { TextInput as RNTextInput } from 'react-native';", + '', + 'export function Composer() {', + ' return ;', + '}', + '', + ].join('\n'); + expect(unsharedSingleLineInputs({ [allowlisted]: aliasedMultiline })).toEqual([]); + expect(unsharedSingleLineInputs({ [allowlisted]: ALIASED_SINGLE_LINE })).toEqual([ + `${allowlisted}:4`, + ]); + }); + + it('resolves an alias declared in a multi-line import', () => { + const multiLineImport = [ + 'import {', + ' Platform,', + ' TextInput as PromptInput,', + "} from 'react-native';", + '', + 'export function Whatever() {', + ' return ;', + '}', + '', + ].join('\n'); + expect(unsharedSingleLineInputs({ 'src/components/Whatever.tsx': multiLineImport })).toEqual([ + 'src/components/Whatever.tsx:7', + ]); + }); + + it('reports an aliased field whose alias starts with a dollar sign', () => { + const dollarAlias = [ + "import { TextInput as $TI } from 'react-native';", + '', + 'export function Whatever() {', + ' return <$TI placeholder="x" />;', + '}', + '', + ].join('\n'); + expect(unsharedSingleLineInputs({ 'src/components/Whatever.tsx': dollarAlias })).toEqual([ + 'src/components/Whatever.tsx:4', + ]); + }); + + it('resolves every TextInput alias declared in one import block', () => { + const twoAliases = [ + "import { TextInput as A, TextInput as B } from 'react-native';", + '', + 'export function Whatever() {', + ' return ;', + '}', + '', + ].join('\n'); + expect(unsharedSingleLineInputs({ 'src/components/Whatever.tsx': twoAliases })).toEqual([ + 'src/components/Whatever.tsx:4', + ]); + }); + + it('leaves an aliased name in a type position alone', () => { + const typed = [ + "import { useRef } from 'react';", + "import { TextInput as RNTextInput } from 'react-native';", + '', + 'export function Whatever() {', + ' const ref = useRef(null);', + ' return ref;', + '}', + '', + ].join('\n'); + expect(unsharedSingleLineInputs({ 'src/components/Whatever.tsx': typed })).toEqual([]); + }); + + it('leaves a type reference or a comparison alone', () => { + const typed = [ + "import { useRef } from 'react';", + "import { type TextInput } from 'react-native';", + '', + 'export function Whatever() {', + ' const ref = useRef(null);', + ' return ref;', + '}', + '', + ].join('\n'); + expect(unsharedSingleLineInputs({ 'src/components/Whatever.tsx': typed })).toEqual([]); + }); + + it('ignores a commented-out field', () => { + const commented = [ + 'export function Whatever() {', + ' // return ;', + ' return null;', + '}', + '', + ].join('\n'); + expect(unsharedSingleLineInputs({ 'src/components/Whatever.tsx': commented })).toEqual([]); + }); + + it('ignores test files and test support', () => { + expect( + unsharedSingleLineInputs({ + 'src/components/Whatever.test.tsx': RAW_SINGLE_LINE, + 'src/test/whatever.tsx': RAW_SINGLE_LINE, + }) + ).toEqual([]); + }); + + it('names an existing file and a written reason for every allowlist entry', () => { + const files = readSourceTree(); + for (const [path, reason] of Object.entries(ALLOWLIST)) { + expect(reason.trim(), `${path} needs a reason`).not.toBe(''); + expect(files[path], `${path} does not exist`).toBeDefined(); + } + }); + + it('keeps every raw TextInput in an allowlisted file multiline', () => { + const files = readSourceTree(); + for (const path of Object.keys(ALLOWLIST)) { + const blocks = rawTextInputs(files[path] ?? ''); + expect(blocks.length, `${path} is allowlisted but holds no raw TextInput`).toBeGreaterThan(0); + for (const raw of blocks) { + expect( + carriesMultiline(raw.block), + `${path}:${raw.line} must carry the multiline prop` + ).toBe(true); + } + } + }); + + it('records only the owner-boundary field that exists today', () => { + const files = readSourceTree(); + for (const [location, reason] of Object.entries(OWNER_BOUNDARY_INPUTS)) { + expect(reason.trim(), `${location} needs a reason`).not.toBe(''); + const path = location.slice(0, location.lastIndexOf(':')); + expect(files[path], `${location} does not exist`).toBeDefined(); + const recorded = rawTextInputs(files[path] ?? '').some( + raw => `${path}:${raw.line}` === location + ); + expect(recorded, `${location} no longer names a raw TextInput`).toBe(true); + } + }); + + it('still reports a new single-line field in an owner-boundary file', () => { + const path = 'src/components/kiloclaw/onboarding/identity-step.tsx'; + expect( + OWNER_BOUNDARY_INPUTS[`${path}:4`], + 'the synthetic violation must sit on a line no entry records' + ).toBeUndefined(); + expect(unsharedSingleLineInputs({ [path]: RAW_SINGLE_LINE })).toEqual([`${path}:4`]); + }); +});