diff --git a/apps/mobile/src/components/language-picker-sheet.mounted.test.tsx b/apps/mobile/src/components/language-picker-sheet.mounted.test.tsx index b50182e3b2..42be6afc38 100644 --- a/apps/mobile/src/components/language-picker-sheet.mounted.test.tsx +++ b/apps/mobile/src/components/language-picker-sheet.mounted.test.tsx @@ -90,7 +90,7 @@ vi.mock('@/components/picker-sheet', () => ({ })); vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); vi.mock('@/components/ui/choice-row', () => ({ ChoiceRow: 'ChoiceRow' })); -vi.mock('@/components/ui/icons', () => ({ SearchX: 'SearchX' })); +vi.mock('@/components/ui/icons', () => ({ Search: 'Search', SearchX: 'SearchX' })); vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); vi.mock('@/lib/hooks/use-theme-colors', () => ({ useThemeColors: () => ({ mutedForeground: '#6b7280' }), @@ -466,3 +466,43 @@ describe('LanguagePickerSheet apply', () => { renderer.unmount(); }); }); + +describe('LanguagePickerSheet search field', () => { + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + }); + + // The language and repository pickers render the same "search a list" + // control. The repository picker's field is a filled rounded pill with a + // leading magnifier; the language field used to be a thin outlined box with + // no icon, so the same control read as two different controls. + it('uses the shared filled search pill with a leading magnifier', async () => { + const renderer = await mountSheet(vi.fn<() => void>()); + const input = findByType(renderer.root, 'TextInput')[0]; + if (!input) { + throw new Error('language search input not found'); + } + const field = input.parent; + if (!field) { + throw new Error('language search field container not found'); + } + + expect((field.props.className as string).split(/\s+/)).toEqual( + expect.arrayContaining([ + 'flex-row', + 'items-center', + 'gap-2', + 'rounded-full', + 'bg-secondary', + 'px-3', + 'py-2', + ]) + ); + expect(field.props.className as string).not.toContain('border-input'); + expect(findByType(renderer.root, 'Search')).toHaveLength(1); + expect(input.props.className as string).not.toContain('border'); + expect(input.props.className as string).toContain('flex-1'); + + renderer.unmount(); + }); +}); diff --git a/apps/mobile/src/components/language-picker-sheet.tsx b/apps/mobile/src/components/language-picker-sheet.tsx index 6681ee7837..862fae8a24 100644 --- a/apps/mobile/src/components/language-picker-sheet.tsx +++ b/apps/mobile/src/components/language-picker-sheet.tsx @@ -11,7 +11,7 @@ import { CenteredState } from '@/components/centered-state'; import { EmptyState } from '@/components/empty-state'; import { LanguagePickerRow } from '@/components/language-picker-row'; import { PickerSheet } from '@/components/picker-sheet'; -import { SearchX } from '@/components/ui/icons'; +import { Search, SearchX } from '@/components/ui/icons'; import { Text } from '@/components/ui/text'; import { applyLanguagePreference } from '@/i18n/apply-language'; import { languagePickerItems } from '@/i18n/language-rows'; @@ -208,14 +208,19 @@ export function LanguagePickerSheet({ disabled={busy} scrollable={false} 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. + + ({ ActivityIndicator: 'Activ vi.mock('@/components/login/email-otp-form', () => ({ EmailOtpForm: 'EmailOtpForm' })); vi.mock('@/components/login/apple-logo', () => ({ AppleLogo: 'AppleLogo' })); vi.mock('@/components/login/google-logo', () => ({ GoogleLogo: 'GoogleLogo' })); +vi.mock('@/components/ui/icons', () => ({ KeyRound: 'KeyRound' })); + // The idle screen reads the foreground ink for the Apple mark; the real hook -// pulls expo-router, which the node test environment cannot load. +// pulls expo-router, which the mounted project cannot load. vi.mock('@/lib/hooks/use-theme-colors', () => ({ - useThemeColors: () => ({ foreground: '#14130f' }), + useThemeColors: () => ({ foreground: '#14130f', mutedForeground: '#6f6a61' }), })); const { IdleAuth } = await import('@/components/login/idle-auth'); 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 5f083737cd..71c77c39d6 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 @@ -21,8 +21,8 @@ import { } from '@/lib/a11y/tap-target'; import { PRIVACY_URL, TERMS_URL } from '@/lib/config'; +import { i18n } from '@/i18n'; import { IdleAuth } from '../idle-auth'; -import '@/i18n'; type StartFn = (mode: 'signin' | 'sso', ssoEmail?: string) => Promise; @@ -103,9 +103,7 @@ vi.mock('@/components/ui/form-field', () => ({ FormField: 'FormField' })); vi.mock('@/components/login/email-otp-form', () => ({ EmailOtpForm: 'EmailOtpForm' })); vi.mock('@/components/login/apple-logo', () => ({ AppleLogo: 'AppleLogo' })); vi.mock('@/components/login/google-logo', () => ({ GoogleLogo: 'GoogleLogo' })); -vi.mock('@/lib/hooks/use-theme-colors', () => ({ - useThemeColors: () => ({ foreground: '#14130f' }), -})); +vi.mock('@/components/ui/icons', () => ({ KeyRound: 'KeyRound' })); vi.mock('expo-web-browser', () => ({ openBrowserAsync: vi.fn(), @@ -116,6 +114,11 @@ vi.mock('@/lib/config', () => ({ PRIVACY_URL: 'https://app.kilo.ai/privacy-app', })); +// The real hook pulls expo-router, which the mounted project cannot load. +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ foreground: '#14130f', mutedForeground: '#6f6a61' }), +})); + const { compile } = createRequire(import.meta.url)( 'react-native-css/compiler' ) as typeof NativeCSSCompiler; @@ -132,6 +135,10 @@ afterEach(() => { }); }); +afterEach(async () => { + await i18n.changeLanguage('en'); +}); + async function mountIdleAuth(start: StartFn): Promise { const ref: { current: R | undefined } = { current: undefined }; await act(async () => { @@ -269,6 +276,13 @@ function filledPrimaryLabels(root: I): (string | undefined)[] { .map(b => b.props.accessibilityLabel as string | undefined); } +/** Every provider control that carries a leading glyph before its label. */ +function providerRows(root: I): I[] { + return ['Sign in with Apple', 'Sign in with Google', 'Sign in with a passkey'].map(label => + findButton(root, label) + ); +} + // Provider controls are opt-in per test; the file default is the plain // email-only form every other suite renders. beforeEach(() => { @@ -327,6 +341,41 @@ describe('IdleAuth provider chrome parity', () => { }); }); + it('gives every provider row a leading glyph', async () => { + 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). + const leadingTypes = providerRows(renderer.root).map( + row => row.children.find(child => typeof child !== 'string')?.type + ); + expect(leadingTypes).toEqual(['AppleLogo', 'GoogleLogo', 'KeyRound']); + + act(() => { + renderer.unmount(); + }); + }); + + it('labels the Apple row from the app catalog, not the device language', async () => { + await i18n.changeLanguage('de'); + const renderer = await mountIdleAuth(vi.fn()); + + // Apple's native control titles itself in the device language, which left + // English "Sign in with Apple" beside the translated Google row. The Apple + // row must use the catalog like every other control on the screen. + expect(texts(renderer.root)).toContain('Mit Apple anmelden'); + expect(texts(renderer.root)).not.toContain('Sign in with Apple'); + expect( + renderer.root.findAll( + n => typeof n.type === 'string' && (n.type as string) === 'AppleAuthenticationButton' + ) + ).toHaveLength(0); + + act(() => { + renderer.unmount(); + }); + }); + it('signs in with Apple through the hook handler', async () => { const renderer = await mountIdleAuth(vi.fn()); @@ -357,6 +406,32 @@ describe('IdleAuth provider chrome parity', () => { }); }); +describe('IdleAuth text action affordance', () => { + beforeEach(() => { + ssoRecovery.value = null; + nativeAuth.busy = undefined; + passkeySupport.supported = false; + providers.appleAvailable = false; + providers.googleConfigured = false; + }); + + it('draws "More sign-in options" as an underlined link, not plain text', async () => { + const renderer = await mountIdleAuth(vi.fn()); + const more = findButton(renderer.root, 'More sign-in options'); + + // A tappable control with no underline, chevron or button shape read as a + // plain bold sentence next to the underlined Terms and Privacy Policy links. + expect(more.props.variant).toBe('link'); + expect(more.props.className as string).toContain('active:opacity-60'); + const label = more.findAll(n => typeof n.type === 'string' && (n.type as string) === 'Text')[0]; + expect(label?.props.className as string).toContain('underline'); + + act(() => { + renderer.unmount(); + }); + }); +}); + describe('IdleAuth SSO recovery', () => { beforeEach(() => { ssoRecovery.value = { email: 'user@example.com', ssoOrganizationId: 'org_1' }; @@ -451,15 +526,20 @@ describe('IdleAuth provider label layout', () => { providers.googleConfigured = true; }); - it('keeps the Google and passkey labels on one line at equal button heights', async () => { + it('keeps every provider label on one line at equal button heights', async () => { + // The Apple row is a provider row too: left on the pre-fix flex-wrap + + // shrink classes it would let its label wrap onto its own flex line and + // grow taller than the two siblings it is drawn to match. + providers.appleAvailable = true; const renderer = await mountIdleAuth(vi.fn()); + const apple = findButton(renderer.root, 'Sign in with Apple'); const google = findButton(renderer.root, 'Sign in with Google'); const passkey = findButton(renderer.root, 'Sign in with a passkey'); - for (const button of [google, passkey]) { + for (const button of [apple, google, passkey]) { // A label must never be pushed onto its own flex line, so the icon stays - // on the label's line and the two stacked buttons keep one height. + // on the label's line and the stacked buttons keep one height. expect(String(button.props.className).split(/\s+/)).not.toContain('flex-wrap'); // Keep the 44pt floor so Dynamic Type can still grow the control. expect(String(button.props.className)).toContain('min-h-[44px]'); @@ -473,9 +553,14 @@ describe('IdleAuth provider label layout', () => { expect(label.props.allowFontScaling).not.toBe(false); } + // The three provider controls carry one row class, so none of them can + // drift back to the wrapping chrome on its own. + expect(String(apple.props.className)).toBe(String(google.props.className)); + // flexBasis 0% + flexGrow 1 gives each label the whole remaining row width: // the remedy the resend-code label already ships (PR #6384). const oneLineLabel = { flexBasis: '0%', flexGrow: 1, flexShrink: 1, textAlign: 'center' }; + expect(await compiledLabelLayout(labelText(apple))).toEqual([oneLineLabel]); expect(await compiledLabelLayout(labelText(google))).toEqual([oneLineLabel]); expect(await compiledLabelLayout(labelText(passkey))).toEqual([oneLineLabel]); diff --git a/apps/mobile/src/components/login/apple-logo.tsx b/apps/mobile/src/components/login/apple-logo.tsx index ffd8df4d2f..b539fd9d8b 100644 --- a/apps/mobile/src/components/login/apple-logo.tsx +++ b/apps/mobile/src/components/login/apple-logo.tsx @@ -4,6 +4,13 @@ import Svg, { Path } from 'react-native-svg'; // label's own ink. Apple's HIG pairs this mark with the exact wording "Sign in // with Apple"; the app renders both inside its own outline Button, beside the // custom Google mark, so all three provider controls share one chrome. +// +// We draw the mark ourselves rather than use `AppleAuthenticationButton`: the +// native control titles itself in the device language, which put English +// "Sign in with Apple" next to the catalog's translated Google and passkey rows +// whenever the app language differed from the device language. The mark is our +// themed ink, matching the Google and passkey rows' leading glyphs (Apple's own +// outline style is a monochrome mark on the button chrome). export function AppleLogo({ size = 18, color }: { size?: number; color: string }) { return ( diff --git a/apps/mobile/src/components/login/idle-auth.tsx b/apps/mobile/src/components/login/idle-auth.tsx index 21a9735c0d..3e28adf6a3 100644 --- a/apps/mobile/src/components/login/idle-auth.tsx +++ b/apps/mobile/src/components/login/idle-auth.tsx @@ -12,6 +12,7 @@ import { EmailOtpForm } from '@/components/login/email-otp-form'; import { GoogleLogo } from '@/components/login/google-logo'; import { Button } from '@/components/ui/button'; import { FormField } from '@/components/ui/form-field'; +import { KeyRound } from '@/components/ui/icons'; import { Text } from '@/components/ui/text'; import { INLINE_LINK_BOX_CLASS, @@ -218,19 +219,28 @@ export function IdleAuth({ )} {showApple && ( - // The app's own outlined Button, exactly like Google and the passkey: - // one border colour and width, one fill, radius, height and label - // weight across the three. The native AppleAuthenticationButton draws + // The provider row is ours, not Apple's native control: the native + // button titles itself in the device language, which left English + // "Sign in with Apple" next to the translated Google and passkey rows + // when the app language differed from the device language, and it draws // its own dark border (about twice the design system hairline) that no - // buttonStyle can match, so the mark is drawn here beside the label. - // Apple's HIG requires the mark and the exact "Sign in with Apple" - // wording; a custom control satisfies it, as does the Google button. + // buttonStyle can match. The label comes from the catalog + // (`login.signInWithApple`), and the mark and outline chrome match the + // two rows below it: one border colour and width, one fill, radius, + // height and label weight across the three. Apple's HIG requires the + // mark and the exact "Sign in with Apple" wording; a custom control + // satisfies it, as does the Google button. ); diff --git a/apps/mobile/src/components/voice-language-picker-sheet.test-helpers.tsx b/apps/mobile/src/components/voice-language-picker-sheet.test-helpers.tsx index 4451fcf7d7..cc833e5183 100644 --- a/apps/mobile/src/components/voice-language-picker-sheet.test-helpers.tsx +++ b/apps/mobile/src/components/voice-language-picker-sheet.test-helpers.tsx @@ -103,7 +103,7 @@ vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' })); vi.mock('@/components/ui/choice-row', () => ({ ChoiceRow: 'ChoiceRow' })); vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); -vi.mock('@/components/ui/icons', () => ({ Mic: 'Mic', SearchX: 'SearchX' })); +vi.mock('@/components/ui/icons', () => ({ Mic: 'Mic', Search: 'Search', SearchX: 'SearchX' })); // ── Helpers ──────────────────────────────────────────────────────────────── diff --git a/apps/mobile/src/components/voice-language-picker-sheet.tsx b/apps/mobile/src/components/voice-language-picker-sheet.tsx index 8cde74e2a1..6c67a461c9 100644 --- a/apps/mobile/src/components/voice-language-picker-sheet.tsx +++ b/apps/mobile/src/components/voice-language-picker-sheet.tsx @@ -9,7 +9,7 @@ import { EmptyState } from '@/components/empty-state'; import { PickerSheet } from '@/components/picker-sheet'; import { QueryError } from '@/components/query-error'; import { ChoiceRow } from '@/components/ui/choice-row'; -import { Mic, SearchX } from '@/components/ui/icons'; +import { Mic, Search, SearchX } from '@/components/ui/icons'; import { Skeleton } from '@/components/ui/skeleton'; import { foldForSearch } from '@/i18n/fold-for-search'; import { languageRows } from '@/i18n/language-rows'; @@ -268,13 +268,17 @@ 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. + +