Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
Expand Down Expand Up @@ -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();
});
});
15 changes: 10 additions & 5 deletions apps/mobile/src/components/language-picker-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -208,14 +208,19 @@ export function LanguagePickerSheet({
disabled={busy}
scrollable={false}
headerContent={
<View className="px-4 pb-2 pt-3">
// 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.
<View className="mx-4 mb-3 mt-3 flex-row items-center gap-2 rounded-full bg-secondary px-3 py-2">
<Search size={18} color={colors.mutedForeground} />
<TextInput
key={searchEpoch}
accessibilityLabel={t('language.search')}
// leading-[normal] so no lineHeight reaches the style: iOS otherwise
// draws the placeholder below the typed text and clips it. min-h-*
// sets the height without padding, so iOS centres the text rect.
className="rounded-md border border-input bg-background px-3 min-h-[44px] text-sm leading-[normal] text-foreground"
// draws the placeholder below the typed text and clips it. The fixed
// h-* with p-0 sets the height without padding, so iOS centres the
// text rect the same way the repository picker's field does.
className="h-8 flex-1 p-0 text-base leading-[normal] text-foreground"
placeholder={t('language.search')}
placeholderTextColor={colors.mutedForeground}
// textAlign is applied inline, not via a class: NativeWind maps it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,12 @@ vi.mock('@/components/ui/activity-indicator', () => ({ 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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;

Expand Down Expand Up @@ -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(),
Expand All @@ -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;
Expand All @@ -132,6 +135,10 @@ afterEach(() => {
});
});

afterEach(async () => {
await i18n.changeLanguage('en');
});

async function mountIdleAuth(start: StartFn): Promise<R> {
const ref: { current: R | undefined } = { current: undefined };
await act(async () => {
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -327,6 +341,41 @@ describe('IdleAuth provider chrome parity', () => {
});
});

it('gives every provider row a leading glyph', async () => {
const renderer = await mountIdleAuth(vi.fn<StartFn>());

// 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<StartFn>());

// 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<StartFn>());

Expand Down Expand Up @@ -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<StartFn>());
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' };
Expand Down Expand Up @@ -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<StartFn>());

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]');
Expand All @@ -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]);

Expand Down
7 changes: 7 additions & 0 deletions apps/mobile/src/components/login/apple-logo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Svg width={size} height={size} viewBox="0 0 24 24">
Expand Down
41 changes: 31 additions & 10 deletions apps/mobile/src/components/login/idle-auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
<Button
Comment thread
iscekic marked this conversation as resolved.
variant="outline"
size="lg"
// Same chrome and no-flex-wrap row as the Google and passkey rows
// below: the label must stay on the icon's line at the shared 44pt
// floor, so all three provider rows keep one height.
className="min-h-[44px] w-full flex-row gap-2 rounded-[8px] py-2.5"
disabled={authBusy}
onPress={() => void signInWithApple()}
onPress={() => {
void signInWithApple();
}}
accessibilityLabel={t('login.signInWithApple')}
>
{busy === 'apple' ? (
Expand Down Expand Up @@ -281,7 +291,13 @@ export function IdleAuth({
}}
accessibilityLabel={t('login.signInWithPasskey')}
>
{busy === 'passkey' ? <ActivityIndicator size="small" /> : null}
{busy === 'passkey' ? (
<ActivityIndicator size="small" />
) : (
// Same leading-glyph slot as the Apple and Google rows, so the
// three provider options read as one group.
<KeyRound size={18} color={colors.foreground} />
)}
<Text className="flex-1 text-center text-[17px] font-medium">
{t('login.signInWithPasskey')}
</Text>
Expand Down Expand Up @@ -379,14 +395,19 @@ export function IdleAuth({
<Text className="text-xs text-muted-foreground">{t('login.termsSuffix')}</Text>
</View>
<Button
variant="ghost"
// A text action that opens the browser sign-in options. It wears the
// same underlined primary link treatment as the Terms and Privacy
// Policy links above, so it reads as tappable rather than as plain bold
// text with no affordance.
variant="link"
className="active:opacity-60"
disabled={authBusy}
onPress={() => {
void startBrowserAuth();
}}
accessibilityLabel={t('login.moreSignInOptions')}
>
<Text>{t('login.moreSignInOptions')}</Text>
<Text className="underline">{t('login.moreSignInOptions')}</Text>
</Button>
</View>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────────

Expand Down
Loading
Loading