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/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.
+
+