diff --git a/apps/mobile/plugins/branded-splash.test.ts b/apps/mobile/plugins/branded-splash.test.ts index 2cbd03b8eb..1c80dd176d 100644 --- a/apps/mobile/plugins/branded-splash.test.ts +++ b/apps/mobile/plugins/branded-splash.test.ts @@ -124,6 +124,10 @@ describe('shared branded splash', () => { }); it('generates both native splash surfaces from the same options', async () => { + // Compile against a throwaway project root like the sibling cases: reading + // the developer's generated `android/` would fold its existing colors into + // the introspection result and make this assertion depend on local state. + const { root } = createAndroidProject(); const config: ExportedConfig = withBrandedSplash( { name: 'Kilo', slug: 'kilo-app', _internal: { projectRoot } }, { image: './assets/images/logo-mark.png', backgroundColor: '#FAF74F', imageWidth: 100 } @@ -140,7 +144,6 @@ describe('shared branded splash', () => { // it at this package's root would read a developer's prebuilt `android/` // tree — its `colors.xml` is absent in CI — and merge colors this test does // not own into the mod results, making the assertion machine-dependent. - const { root } = createAndroidProject(); const evaluated = await compileModsAsync(config, { projectRoot: root, platforms: ['ios', 'android'], diff --git a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx index 063f80b200..ab11b214f5 100644 --- a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx @@ -22,6 +22,7 @@ import { shouldShowNeedsInput, useSessionAttentionRevision, } from '@/lib/session-attention'; +import { TabBarLabelContext } from '@/lib/tab-bar-clearance'; import { getEffectiveTabBarHeight, getTabBarHorizontalInset, @@ -60,21 +61,46 @@ export default function TabsLayout() { const segments = useSegments(); const colors = useThemeColors(); const { bottom, left, right } = useSafeAreaInsets(); - const { fontScale } = useWindowDimensions(); + const { width, fontScale } = useWindowDimensions(); const hideTabs = shouldHideTabBar(pathname); - const showTabLabel = shouldShowTabLabel(fontScale); + const showKiloClawTab = useKiloClawTabVisible(); + const showQuickChatTab = useFeatureFlag(FEATURE_FLAG_QUICK_CHAT, false); + const tabFlags = { showKiloClaw: showKiloClawTab, showQuickChat: showQuickChatTab }; + const tabCount = visibleTabCount(showKiloClawTab, showQuickChatTab); + // The label box is the tab item minus the bar's side safe areas and the + // tab item's own padding (subtracted inside `tabLabelFits`). A window whose + // width was never measured leaves the box unknown — the same missing + // measurement `shouldStackHeaderActions` treats as its default — so the + // width rule stays out of it and the font-scale rule alone decides. + const tabBarContentWidth = width - left - right; + const tabItemWidth = Number.isFinite(tabBarContentWidth) + ? tabBarContentWidth / tabCount + : undefined; + const { t } = useTranslation(); + const homeLabel = t('tabs.home'); + const kiloclawLabel = + fontScale > TAB_LABEL_WRAP_FONT_SCALE ? t('tabs.kiloclawWrapped') : t('common.kiloclaw'); + const agentsLabel = t('common.agents'); + const chatLabel = t('common.chat'); + const profileLabel = t('common.profile'); + // The label set in render order, so the visible/dropped decision measures + // exactly the strings each `TabBarLabel` renders. + const tabLabels = [ + homeLabel, + ...(showKiloClawTab ? [kiloclawLabel] : []), + agentsLabel, + ...(showQuickChatTab ? [chatLabel] : []), + profileLabel, + ]; + const showTabLabel = shouldShowTabLabel(fontScale, tabItemWidth, tabLabels); const tabBarHeight = getEffectiveTabBarHeight({ bottomInset: bottom, platform: Platform.OS, fontScale, + showLabel: showTabLabel, }); const tabBarHorizontalInset = getTabBarHorizontalInset({ left, right }); const tabIconSize = getTabBarIconSize(fontScale); - const showKiloClawTab = useKiloClawTabVisible(); - const showQuickChatTab = useFeatureFlag(FEATURE_FLAG_QUICK_CHAT, false); - const tabFlags = { showKiloClaw: showKiloClawTab, showQuickChat: showQuickChatTab }; - const tabCount = visibleTabCount(showKiloClawTab, showQuickChatTab); - const { t } = useTranslation(); const { organizationId, isLoaded: orgLoaded } = useOrganization(); const { activeSessions, isLoading, isError } = useLiveAgentSessions({ organizationId, @@ -126,7 +152,11 @@ export default function TabsLayout() { // overlay; reserving it here shrank the band below the empty state's height // in a short landscape window and parked its second line and action behind // the bar (landscape spot defect e8). - return ( + // + // The label decision is published to the tab screens, whose content + // clearance must match the height this layout renders (the width rule can + // drop the labels without the callers seeing the window width). + const tabsLayout = ( , + tabBarLabel: ({ focused }) => , tabBarIcon: ({ color, focused }) => ( ), @@ -184,16 +214,10 @@ export default function TabsLayout() { tabBarPosition('kiloclaw', tabFlags) ?? 2, tabCount ), - tabBarLabel: ({ focused }) => ( - TAB_LABEL_WRAP_FONT_SCALE - ? t('tabs.kiloclawWrapped') - : t('common.kiloclaw') - } - focused={focused} - /> - ), + // The pre-wrapped copy is chosen once, from the same font scale the + // width decision measures, so `tabLabels` and the rendered label + // cannot disagree about which string is on the bar. + tabBarLabel: ({ focused }) => , tabBarIcon: ({ color, focused }) => ( ), @@ -218,9 +242,7 @@ export default function TabsLayout() { tabBarPosition('agents', tabFlags) ?? 2, tabCount ), - tabBarLabel: ({ focused }) => ( - - ), + tabBarLabel: ({ focused }) => , tabBarIcon: ({ color, focused }) => ( ), @@ -237,9 +259,7 @@ export default function TabsLayout() { tabBarPosition('chat', tabFlags) ?? 3, tabCount ), - tabBarLabel: ({ focused }) => ( - - ), + tabBarLabel: ({ focused }) => , tabBarIcon: ({ color, focused }) => ( ), @@ -255,9 +275,7 @@ export default function TabsLayout() { tabCount, tabCount ), - tabBarLabel: ({ focused }) => ( - - ), + tabBarLabel: ({ focused }) => , tabBarIcon: ({ color, focused }) => ( ), @@ -273,4 +291,5 @@ export default function TabsLayout() { ); + return {tabsLayout}; } diff --git a/apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx b/apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx index 3a9020e880..e70daa9002 100644 --- a/apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx @@ -102,6 +102,9 @@ vi.mock('react-native', () => ({ ActivityIndicator: 'ActivityIndicator', I18nManager: { isRTL: false }, Platform: { OS: 'android' }, + // The header reads the window to decide whether its actions share the title + // row; this phone is wide enough for them to. + useWindowDimensions: () => ({ width: 390, fontScale: 1, height: 844 }), })); vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ top: 0, bottom: 0 }), diff --git a/apps/mobile/src/components/account-settings-screen.arabic.mounted.test.tsx b/apps/mobile/src/components/account-settings-screen.arabic.mounted.test.tsx index 3738e8c9cf..5e5c41364a 100644 --- a/apps/mobile/src/components/account-settings-screen.arabic.mounted.test.tsx +++ b/apps/mobile/src/components/account-settings-screen.arabic.mounted.test.tsx @@ -89,7 +89,9 @@ describe('AccountSettingsScreen in Arabic', () => { expect(renderer.root.findByType('ScreenHeader').props.title).toBe('الحساب'); expect(findConfigureRow(renderer, 'اللغة').props).toMatchObject({ subtitle: 'العربية' }); expect(findConfigureRow(renderer, 'المضيفون الموثوقون')).toBeDefined(); - expect(findConfigureRow(renderer, 'مفاتيح المرور')).toBeDefined(); + expect(findConfigureRow(renderer, 'مفاتيح المرور').props).toMatchObject({ + subtitle: 'سجّل الدخول بدون كلمة مرور', + }); expect(findConfigureRow(renderer, 'جلسات الأجهزة')).toBeDefined(); }); diff --git a/apps/mobile/src/components/agents/new-session-configure-form.test.ts b/apps/mobile/src/components/agents/new-session-configure-form.test.ts index e1781115de..9df8436635 100644 --- a/apps/mobile/src/components/agents/new-session-configure-form.test.ts +++ b/apps/mobile/src/components/agents/new-session-configure-form.test.ts @@ -133,12 +133,13 @@ vi.mock('@/components/ui/icons', () => ({ RefreshCw: 'RefreshCw' })); vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); // The profile row and the environment row both render a loading `Skeleton`, -// whose module imports `react-native-reanimated`: this pure suite does not set -// Reanimated up, and this project runs in plain Node, where the -// Reanimated/worklets native entry cannot resolve (the published worklets -// build uses bundler-style extensionless imports). The stub is the type the -// pending-environment case asserts by name; its own rendering is not under test -// here. +// whose module imports `react-native-reanimated`: Reanimated's worklets entry +// is unavailable in the pure project, so the skeleton leaf that reaches it is +// stubbed as the other pure suites do. This suite does not set Reanimated up, +// and this project runs in plain Node, where the Reanimated/worklets native +// entry cannot resolve (the published worklets build uses bundler-style +// extensionless imports). The stub is the type the pending-environment case +// asserts by name; its own rendering is not under test here. vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); vi.mock('@/components/ui/segmented-control', () => ({ diff --git a/apps/mobile/src/components/agents/session-detail-content.test.ts b/apps/mobile/src/components/agents/session-detail-content.test.ts index 7143c3ebb5..3d01dcb607 100644 --- a/apps/mobile/src/components/agents/session-detail-content.test.ts +++ b/apps/mobile/src/components/agents/session-detail-content.test.ts @@ -119,6 +119,9 @@ vi.mock('react-native', () => ({ KeyboardAvoidingView: 'KeyboardAvoidingView', I18nManager: { isRTL: false }, Platform: { OS: 'ios' }, + // The header reads the window to decide whether its actions share the title + // row; this phone is wide enough for them to. + useWindowDimensions: () => ({ width: 390, fontScale: 1, height: 844 }), })); vi.mock('react-native-reanimated', () => ({ default: { View: 'AnimatedView' }, diff --git a/apps/mobile/src/components/agents/session-list-content.tsx b/apps/mobile/src/components/agents/session-list-content.tsx index e03768173c..c8fb28ef66 100644 --- a/apps/mobile/src/components/agents/session-list-content.tsx +++ b/apps/mobile/src/components/agents/session-list-content.tsx @@ -2,7 +2,7 @@ import { FlashList, type FlashListRef, type ListRenderItemInfo } from '@shopify/flash-list'; import { useFocusEffect, useScrollToTop } from 'expo-router'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Platform, useWindowDimensions, View } from 'react-native'; +import { View } from 'react-native'; import { RefreshControl } from '@/components/ui/refresh-control'; import { ActivityIndicator } from '@/components/ui/activity-indicator'; import Animated, { FadeIn } from 'react-native-reanimated'; @@ -34,7 +34,7 @@ import { SESSION_LIST_SORT } from '@/lib/agent-session-sort'; import { useSessionMutations } from '@/lib/hooks/use-session-mutations'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { getRevisionSnapshot } from '@/lib/session-attention'; -import { getEffectiveTabBarHeight } from '@/lib/tab-bar-layout'; +import { useEffectiveTabBarHeight } from '@/lib/tab-bar-clearance'; export const FAB_SIZE = 56; export const FAB_MARGIN = 16; @@ -114,8 +114,10 @@ export function AgentSessionListContent({ const colors = useThemeColors(); const { t } = useTranslation(); - const { bottom, left, right } = useSafeAreaInsets(); - const { fontScale } = useWindowDimensions(); + const { left, right } = useSafeAreaInsets(); + // The tabs layout's width-aware label decision rides along, so the list + // clearance tracks the bar height the layout actually renders. + const tabBarHeight = useEffectiveTabBarHeight(); const { deleteSession, renameSession } = useSessionMutations(); // The stored refetch resolves void: a pull failure surfaces through the // query error state (showInlineError below), so a settlement is always @@ -145,16 +147,7 @@ export function AgentSessionListContent({ // must clear it or the last rows are stuck underneath it. The history list // owns no FAB, so a bottom-only TabBar clearance is the only inset the // content container needs. - const tabBarOnlyClearanceStyle = useMemo( - () => ({ - paddingBottom: getEffectiveTabBarHeight({ - bottomInset: bottom, - platform: Platform.OS, - fontScale, - }), - }), - [bottom, fontScale] - ); + const tabBarOnlyClearanceStyle = useMemo(() => ({ paddingBottom: tabBarHeight }), [tabBarHeight]); // The landscape side insets keep row text clear of the sensor housing // (portrait insets are 0, keeping the geometry unchanged). They live on a diff --git a/apps/mobile/src/components/agents/session-list-screen.tsx b/apps/mobile/src/components/agents/session-list-screen.tsx index 3024e2b6da..1bb3b73363 100644 --- a/apps/mobile/src/components/agents/session-list-screen.tsx +++ b/apps/mobile/src/components/agents/session-list-screen.tsx @@ -1,14 +1,6 @@ /* eslint-disable max-lines -- The live list keeps its query, pull-refresh, keyboard container, and FAB orchestration together on one screen. */ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - AppState, - FlatList, - KeyboardAvoidingView, - Platform, - Pressable, - useWindowDimensions, - View, -} from 'react-native'; +import { AppState, FlatList, KeyboardAvoidingView, Platform, Pressable, View } from 'react-native'; import { RefreshControl } from '@/components/ui/refresh-control'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTranslation } from 'react-i18next'; @@ -37,7 +29,7 @@ import { ScreenHeader } from '@/components/screen-header'; import { AppAwareKeyboardPaddingView } from '@/components/kilo-chat/app-aware-keyboard-padding'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { getRevisionSnapshot } from '@/lib/session-attention'; -import { getEffectiveTabBarHeight } from '@/lib/tab-bar-layout'; +import { useEffectiveTabBarHeight } from '@/lib/tab-bar-clearance'; import { type ActiveSession, useLiveAgentSessions } from '@/lib/hooks/use-agent-sessions'; import { type Href, useFocusEffect, useNavigation, useRouter, useScrollToTop } from 'expo-router'; @@ -49,13 +41,10 @@ export function AgentSessionListScreen() { const navigation = useNavigation(); const colors = useThemeColors(); const { t } = useTranslation(); - const { bottom, left, right } = useSafeAreaInsets(); - const { fontScale } = useWindowDimensions(); - - const tabBarHeight = useMemo( - () => getEffectiveTabBarHeight({ bottomInset: bottom, platform: Platform.OS, fontScale }), - [bottom, fontScale] - ); + const { left, right } = useSafeAreaInsets(); + // The tabs layout's width-aware label decision rides along, so this screen's + // clearance (list frame, FAB and state-surface insets) tracks the bar height. + const tabBarHeight = useEffectiveTabBarHeight(); // Android runs edge-to-edge and never resizes the window for the IME, so the // native KeyboardAvoidingView is inert there; the app-aware container follows // the keyboard events instead (the repo's one platform fork for this). 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 47118c715f..a97e3604f0 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 @@ -110,4 +110,11 @@ describe('SessionListSearchHeader landscape sensor insets', () => { expect(classes).toContain('min-h-'); expect(classes).not.toMatch(/(?:^|\s)py-/); }); + + it('keeps the placeholder on one line at any width', async () => { + // A narrow window with a large font scale made the placeholder wrap inside + // the field and the field grow with it (e1-list-bottom.png). + const renderer = await mount(); + expect(searchInput(renderer).props.numberOfLines).toBe(1); + }); }); 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 a02b68b471..8e207abd03 100644 --- a/apps/mobile/src/components/agents/session-list-search-header.tsx +++ b/apps/mobile/src/components/agents/session-list-search-header.tsx @@ -68,6 +68,9 @@ export function SessionListSearchHeader({ // Height comes from `min-h`, never `py`: iOS insets the already-centered // text rect by the padding and draws the placeholder low. className="min-h-[26px] flex-1 text-[15px] leading-[normal] text-foreground" + // One line, always: at a narrow width with a large font scale the + // placeholder wrapped inside the field and the field grew with it. + numberOfLines={1} placeholder={t('agents.search.searchSessionsPlaceholder')} placeholderTextColor={colors.mutedForeground} onChangeText={onChangeText} diff --git a/apps/mobile/src/components/code-reviewer/manual-review-screen.mounted.test.tsx b/apps/mobile/src/components/code-reviewer/manual-review-screen.mounted.test.tsx index b009b0294d..017063ef4f 100644 --- a/apps/mobile/src/components/code-reviewer/manual-review-screen.mounted.test.tsx +++ b/apps/mobile/src/components/code-reviewer/manual-review-screen.mounted.test.tsx @@ -39,6 +39,13 @@ vi.mock('react-native', () => ({ TextInput: 'TextInput', View: 'View', })); +// The keyboard-padding leaf the screen renders reads the native side insets +// through `react-native-safe-area-context`, whose module resolves to its +// untransformed `react-native` entry (`src/index.tsx`) and breaks the mounted +// project; every mounted suite mocks it. +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), +})); // The screen wraps its form in the shared keyboard-lift view, whose real module // reads the platform and the safe-area insets (a react-native entry this node // project cannot load). Sibling mounted specs stub the view for the same diff --git a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx index e2fbe3d2ff..d80088a20c 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx @@ -5,13 +5,12 @@ import { getCalendars } from 'expo-localization'; import { type Href, useRouter } from 'expo-router'; import { Plus, Settings2 } from '@/components/ui/icons'; import { useCallback, useMemo } from 'react'; -import { Platform, Pressable, useWindowDimensions, View, type ViewStyle } from 'react-native'; +import { Pressable, View, type ViewStyle } from 'react-native'; import { RefreshControl } from '@/components/ui/refresh-control'; import { RefreshProgress } from '@/components/ui/refresh-progress'; import { ActivityIndicator } from '@/components/ui/activity-indicator'; import Animated, { FadeIn } from 'react-native-reanimated'; import { useTranslation } from 'react-i18next'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { QueryError } from '@/components/query-error'; import { captureEvent, CONVERSATION_CREATED_EVENT } from '@/lib/analytics/posthog'; @@ -21,7 +20,7 @@ import { Text } from '@/components/ui/text'; import { useManualRefresh } from '@/lib/hooks/use-manual-refresh'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { chatConversationPath } from '@/lib/kilo-chat-routes'; -import { getEffectiveTabBarHeight } from '@/lib/tab-bar-layout'; +import { useEffectiveTabBarHeight } from '@/lib/tab-bar-clearance'; import { EmptyConversationList } from './empty-conversation-list'; import { groupConversationsByActivity } from './conversation-list-groups'; @@ -101,8 +100,9 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { const { t } = useTranslation(); const router = useRouter(); const colors = useThemeColors(); - const { bottom } = useSafeAreaInsets(); - const { fontScale } = useWindowDimensions(); + // The tabs layout's width-aware label decision rides along, so the list and + // FAB clearance track the bar height the layout actually renders. + const tabBarHeight = useEffectiveTabBarHeight(); const client = useKiloChatClient(); const eventClient = useEventServiceClient(); const activeAndFocused = useAppActiveAndFocused(); @@ -120,11 +120,6 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { const isFetchingNextPage = listQuery.isFetchingNextPage; const fetchNextPage = listQuery.fetchNextPage; const refetchConversations = listQuery.refetch; - const tabBarHeight = getEffectiveTabBarHeight({ - bottomInset: bottom, - platform: Platform.OS, - fontScale, - }); const listContentContainerStyle = useMemo( () => ({ diff --git a/apps/mobile/src/components/profile-screen.signout.mounted.test.tsx b/apps/mobile/src/components/profile-screen.signout.mounted.test.tsx index cdf9e87c9e..a4d90c4d68 100644 --- a/apps/mobile/src/components/profile-screen.signout.mounted.test.tsx +++ b/apps/mobile/src/components/profile-screen.signout.mounted.test.tsx @@ -35,6 +35,14 @@ vi.mock('react-native-reanimated', () => ({ LinearTransition: {}, })); +// ProfileScreen reads the landscape side insets, so the real native module +// would load here. Its build requires `react-native` itself, whose Flow source +// the vitest transform cannot parse (see `test/render-with-providers.tsx`), so +// the harness mocks it exactly as `profile-screen.queries.mounted.test.tsx` does. +// ProfileScreen reads the landscape side insets, so the real native module +// would load here. Its build requires `react-native` itself, whose Flow source +// the vitest transform cannot parse (see `test/render-with-providers.tsx`), so +// the harness mocks it exactly as `profile-screen.queries.mounted.test.tsx` does. // The screen reads its side insets through `@/lib/screen-insets`, whose real // `react-native-safe-area-context` entry is a Flow source this pipeline cannot // transform; the insets are irrelevant to the sign-out flow. diff --git a/apps/mobile/src/components/screen-header.mounted.test.tsx b/apps/mobile/src/components/screen-header.mounted.test.tsx index 611fd7f7cd..9780ee5da0 100644 --- a/apps/mobile/src/components/screen-header.mounted.test.tsx +++ b/apps/mobile/src/components/screen-header.mounted.test.tsx @@ -10,7 +10,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { OFFLINE_BANNER_HEIGHT } from '@/lib/offline-banner-state'; import { SESSION_HEADER_TITLE_LINES } from '@/components/agents/session-header'; -import { ScreenHeader } from './screen-header'; +import { ScreenHeader, shouldStackHeaderActions } from './screen-header'; import { OfflineBannerSpaceProvider } from './offline-banner-space'; const routerState = vi.hoisted(() => ({ @@ -22,6 +22,8 @@ const routerState = vi.hoisted(() => ({ const i18nManager = vi.hoisted(() => ({ isRTL: false })); const safeArea = vi.hoisted(() => ({ top: 0, bottom: 0, left: 0, right: 0 })); const platform = vi.hoisted(() => ({ OS: 'ios' as 'ios' | 'android' })); +/** A normal phone window: wide enough for the title and its actions to share a row. */ +const layout = vi.hoisted(() => ({ width: 390, fontScale: 1 })); vi.mock('expo-router', () => ({ useRouter: () => routerState, @@ -31,6 +33,7 @@ vi.mock('react-native', () => ({ Platform: platform, Pressable: 'Pressable', View: 'View', + useWindowDimensions: () => ({ width: layout.width, height: 800, fontScale: layout.fontScale }), })); vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => safeArea, @@ -75,6 +78,21 @@ function findTitlePressable(root: TestInstance): TestInstance { ); } +/** + * The row the header puts `headerRight` on when the window is too narrow for + * the title and the actions to share one. Only that variant carries `mt-2` + * without the single-row wrapper's `max-w-[50%]`. + */ +function isStackedActionsRow(node: TestInstance): boolean { + const className = String(node.props.className ?? ''); + return ( + typeof node.type === 'string' && + (node.type as string) === 'View' && + className.includes('mt-2') && + !className.includes('max-w-[50%]') + ); +} + function findIcon(back: TestInstance, type: string): TestInstance { const icons = back.findAll(node => typeof node.type === 'string' && node.type === type); const icon = icons[0]; @@ -197,6 +215,7 @@ describe('ScreenHeader mounted', () => { i18nManager.isRTL = false; platform.OS = 'ios'; Object.assign(safeArea, { top: 0, bottom: 0, left: 0, right: 0 }); + Object.assign(layout, { width: 390, fontScale: 1 }); }); it('gives the back control a 44-point target and no hit slop', () => { @@ -702,3 +721,97 @@ describe('ScreenHeader mounted', () => { expect(eyebrow.children).toEqual(['\u00A0']); }); }); + +describe('header actions row', () => { + it('keeps the actions on the title row at a normal phone width', () => { + const renderer = renderHeader({ title: 'Agents', size: 'large', headerRight: 'RIGHT' }); + + expect(renderer.root.findAll(isStackedActionsRow)).toHaveLength(0); + const actions = renderer.root.find( + node => + typeof node.type === 'string' && + (node.type as string) === 'View' && + String(node.props.className ?? '').includes('max-w-[50%]') + ); + // The inline wrapper's gap is the logical start margin (`ms-3`), which the + // RTL swap cannot mirror away; the title's box keeps its `shrink` cap. + expect(actions.props.className).toContain('ms-3'); + }); + + it('drops the actions to their own row when the window cannot hold the title beside them', () => { + // The e1 geometry: 160dp wide with a 1.5 font scale left the 30px title + // 48dp, which broke it and the mono link beside it into letter columns. + layout.width = 160; + layout.fontScale = 1.5; + const renderer = renderHeader({ title: 'Agents', size: 'large', headerRight: 'RIGHT' }); + + expect(renderer.root.findAll(isStackedActionsRow)).toHaveLength(1); + expect( + renderer.root.findAll( + node => + typeof node.type === 'string' && + (node.type as string) === 'View' && + String(node.props.className ?? '').includes('max-w-[50%]') + ) + ).toHaveLength(0); + // The title still shares its row with the back control; only the actions moved. + const title = renderer.root.findByProps({ accessibilityRole: 'header' }); + expect(title.parent?.parent).toBe(findBackPressable(renderer.root).parent); + }); + + it('reflows a centered modal header the same way', () => { + layout.width = 160; + layout.fontScale = 1.5; + const renderer = renderHeader({ title: 'Agents', modal: true, headerRight: 'RIGHT' }); + + expect(renderer.root.findAll(isStackedActionsRow)).toHaveLength(1); + const title = renderer.root.findByProps({ accessibilityRole: 'header' }); + expect(title.parent?.parent?.parent).toBe(findBackPressable(renderer.root).parent); + }); + + it('leaves a header without actions on its single row', () => { + layout.width = 160; + layout.fontScale = 1.5; + const renderer = renderHeader({ title: 'Agents', size: 'large' }); + + expect(renderer.root.findAll(isStackedActionsRow)).toHaveLength(0); + }); +}); + +describe('header status line', () => { + it('keeps the eyebrow on one line at the reported narrow geometry', () => { + // e1: a 160dp window at a 1.5 font scale broke '1 LIVE' into a letter + // column beside the title and grew the header down over the search field. + layout.width = 160; + layout.fontScale = 1.5; + const renderer = renderHeader({ title: 'Agents', size: 'large', eyebrow: '1 LIVE' }); + + const eyebrow = renderer.root.findByType('Eyebrow'); + expect(eyebrow.props.numberOfLines).toBe(1); + expect(eyebrow.children).toEqual(['1 LIVE']); + }); +}); + +describe('shouldStackHeaderActions', () => { + it('reflows at the reported 160dp window with a 1.5 font scale', () => { + expect(shouldStackHeaderActions(160, 1.5)).toBe(true); + }); + + it('keeps one row at every phone width the scenarios cover', () => { + expect(shouldStackHeaderActions(320, 1)).toBe(false); + expect(shouldStackHeaderActions(360, 1)).toBe(false); + expect(shouldStackHeaderActions(360, 2)).toBe(false); + expect(shouldStackHeaderActions(390, 2)).toBe(false); + }); + + it('reflows a window too narrow for the title beside the actions', () => { + expect(shouldStackHeaderActions(160, 1)).toBe(true); + expect(shouldStackHeaderActions(220, 1.5)).toBe(true); + expect(shouldStackHeaderActions(320, 2.5)).toBe(true); + }); + + it('treats a missing window measurement as a single row', () => { + expect(shouldStackHeaderActions(Number.NaN, 1)).toBe(false); + expect(shouldStackHeaderActions(160, Number.NaN)).toBe(true); + }); +}); diff --git a/apps/mobile/src/components/screen-header.tsx b/apps/mobile/src/components/screen-header.tsx index 8fee5bd2f1..0222fe4a17 100644 --- a/apps/mobile/src/components/screen-header.tsx +++ b/apps/mobile/src/components/screen-header.tsx @@ -1,7 +1,7 @@ import { type Href, useRouter } from 'expo-router'; import { ChevronDown } from '@/components/ui/icons'; import { DirectionalChevronLeft } from '@/components/ui/directional-icons'; -import { I18nManager, Platform, Pressable, View } from 'react-native'; +import { I18nManager, Platform, Pressable, useWindowDimensions, View } from 'react-native'; import { useTranslation } from 'react-i18next'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -22,6 +22,41 @@ import { cn } from '@/lib/utils'; */ const MODAL_HEADER_TOP_PADDING = 32; +/** + * Content width (dp, at font scale 1) a header needs for its title and its + * actions to share one row. + * + * A narrow window cannot hold both. At the e1 geometry — 160dp wide with a 1.5 + * font scale — the shared row left the heading 48dp: the 30px list title, its + * eyebrow, and the mono link beside it need about 180dp together, which is + * about 120dp at scale 1. Every one of them broke into a single-letter column + * and grew the header down over the search field. Below this width the actions + * take their own row under the title, the reflow the design system asks of + * narrow product UI. Wider windows keep the single-row layout byte-identical. + */ +const HEADER_SINGLE_ROW_MIN_CONTENT_WIDTH = 120; + +/** + * The horizontal gutter a header's content sits inside. The list screens use + * the widest one in service (`px-[22px]`, 44dp); the default `px-4` is + * narrower, so the check reflows a hair early for those callers instead of + * squeezing them. + */ +const HEADER_CONTENT_GUTTER = 44; + +/** + * Whether the header's actions drop to their own row instead of sharing the + * title's. `width` is the window width in dp; `fontScale` is the system font + * scale, so a large font reflows before a small one at the same width. + */ +export function shouldStackHeaderActions(width: number, fontScale: number): boolean { + if (!Number.isFinite(width)) { + return false; + } + const scale = Number.isFinite(fontScale) && fontScale > 0 ? fontScale : 1; + return width - HEADER_CONTENT_GUTTER < HEADER_SINGLE_ROW_MIN_CONTENT_WIDTH * scale; +} + /** * Ceiling for the header title's line cap. The reserved box grows by a full * line height per cap, so three lines is the most a header should take; the @@ -121,8 +156,13 @@ export function ScreenHeader({ const router = useRouter(); const colors = useThemeColors(); const { t } = useTranslation(); + const { width: windowWidth, fontScale } = useWindowDimensions(); const canGoBack = showBackButton ?? (router.canGoBack() || backFallback !== undefined); const isOfflineBannerVisible = useOfflineBannerSpace(); + // A window too narrow to hold the title beside its actions drops the actions + // to their own row instead of squeezing the title into a single-letter + // column. Only a header that has actions can reflow this way. + const stackActions = headerRight != null && shouldStackHeaderActions(windowWidth, fontScale); // A modal is a native sheet that owns its own top inset; the header keeps the // fixed grabber clearance on both platforms. A pinned header adds the app @@ -230,6 +270,10 @@ export function ScreenHeader({ {eyebrow || reserveEyebrow ? ( ) : null; const centeredControls = - separateHeading && backControl && !headerRight ? ( + separateHeading && backControl ? ( ) : null; @@ -293,25 +337,39 @@ export function ScreenHeader({ {separateHeading ? ( - - {backControl} - {heading} - {headerRight ? ( - {headerRight} - ) : ( - centeredControls - )} - - ) : ( - - + <> + {backControl} - {heading} + {heading} + {headerRight && !stackActions ? ( + {headerRight} + ) : ( + centeredControls + )} + + {headerRight && stackActions ? ( + // The actions keep the title's own row only while it can hold a + // readable title; below that they take a row of their own. They + // keep the row's full width so a long translation still wraps + // inside it instead of running off the edge. + {headerRight} + ) : null} + + ) : ( + <> + + + {backControl} + {heading} + + {headerRight && !stackActions ? ( + {headerRight} + ) : null} - {headerRight ? ( - {headerRight} + {headerRight && stackActions ? ( + {headerRight} ) : null} - + )} diff --git a/apps/mobile/src/components/security-agent/finding-detail-screen.mounted.test.tsx b/apps/mobile/src/components/security-agent/finding-detail-screen.mounted.test.tsx index 5acd212747..8ebb33b7a4 100644 --- a/apps/mobile/src/components/security-agent/finding-detail-screen.mounted.test.tsx +++ b/apps/mobile/src/components/security-agent/finding-detail-screen.mounted.test.tsx @@ -52,6 +52,9 @@ vi.mock('react-native', () => ({ Pressable: 'Pressable', I18nManager: { isRTL: false }, Platform: { OS: 'ios' }, + // The header reads the window to decide whether its actions share the title + // row; this phone is wide enough for them to. + useWindowDimensions: () => ({ width: 390, fontScale: 1, height: 844 }), })); vi.mock('@/components/ui/icons', () => ({ Ban: 'Ban', diff --git a/apps/mobile/src/components/tab-screen.mounted.test.tsx b/apps/mobile/src/components/tab-screen.mounted.test.tsx index 9a79a978f2..9a7664dd34 100644 --- a/apps/mobile/src/components/tab-screen.mounted.test.tsx +++ b/apps/mobile/src/components/tab-screen.mounted.test.tsx @@ -1,30 +1,66 @@ import { createElement } from 'react'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { TabBarLabelContext } from '@/lib/tab-bar-clearance'; +import { getTabBarIconForwardHeight, getTabBarOverlayHeight } from '@/lib/tab-bar-layout'; import { renderWithProviders } from '@/test/render-with-providers'; -import { TabScreenScrollView } from './tab-screen'; + +import { useTabBarBottomPadding } from './tab-screen'; + +/** Mirrors `TAB_SCREEN_BOTTOM_GAP` in `tab-screen.tsx`. */ +const TAB_SCREEN_BOTTOM_GAP = 16; +const DEFAULT_FONT_SCALE = 1.5; +const layout = vi.hoisted(() => ({ bottom: 16, fontScale: 1.5 })); vi.mock('react-native', () => ({ - Platform: { OS: 'ios' }, + Platform: { OS: 'android' }, ScrollView: 'ScrollView', - View: 'View', - useWindowDimensions: () => ({ fontScale: 1 }), + useWindowDimensions: () => ({ height: 320, width: 160, fontScale: layout.fontScale }), })); vi.mock('react-native-safe-area-context', () => ({ - useSafeAreaInsets: () => ({ bottom: 34 }), + useSafeAreaInsets: () => ({ bottom: layout.bottom, left: 0, right: 0, top: 0 }), })); -vi.mock('@/components/ui/refresh-progress', () => ({ RefreshProgress: 'RefreshProgress' })); -describe('TabScreenScrollView', () => { - it('reserves the tab bar and final gap outside the scroll viewport', async () => { - const { renderer, unmount } = await renderWithProviders( - createElement(TabScreenScrollView, null, createElement('Content')) +function Probe() { + return createElement('Probe', { padding: useTabBarBottomPadding() }); +} + +/** The clearance `useTabBarBottomPadding` reserves under an optional label decision. */ +async function clearanceFor(showLabel: boolean | null): Promise { + const probe = createElement(Probe); + const ui = + showLabel === null ? probe : createElement(TabBarLabelContext, { value: showLabel }, probe); + const mounted = await renderWithProviders(ui); + try { + return mounted.renderer.root.findByType('Probe').props.padding as number; + } finally { + mounted.unmount(); + } +} + +afterEach(() => { + layout.fontScale = DEFAULT_FONT_SCALE; +}); + +describe('tab screen bottom clearance', () => { + it('follows the published label decision, not just the font scale', async () => { + // At 1.5 the font-scale rule alone keeps the labels (the default answer); + // the width rule dropped them, so the clearance must match the compact bar. + await expect(clearanceFor(false)).resolves.toBe( + getTabBarIconForwardHeight(layout.bottom, 'android') + TAB_SCREEN_BOTTOM_GAP ); - const findByType = (type: string) => - renderer.root.findAll(node => typeof node.type === 'string' && node.type === type); + }); - expect(findByType('ScrollView')[0]?.props.style).toEqual([undefined, { marginBottom: 100 }]); - expect(findByType('View')).toHaveLength(0); - unmount(); + it('keeps the label-inclusive height when the layout keeps the labels', async () => { + await expect(clearanceFor(true)).resolves.toBe( + getTabBarOverlayHeight(layout.bottom, 'android', layout.fontScale) + TAB_SCREEN_BOTTOM_GAP + ); + }); + + it('falls back to the font-scale rule outside the tabs navigator', async () => { + layout.fontScale = 2.5; + await expect(clearanceFor(null)).resolves.toBe( + getTabBarIconForwardHeight(layout.bottom, 'android') + TAB_SCREEN_BOTTOM_GAP + ); }); }); diff --git a/apps/mobile/src/components/tab-screen.tsx b/apps/mobile/src/components/tab-screen.tsx index eb4cd291f5..d807c19671 100644 --- a/apps/mobile/src/components/tab-screen.tsx +++ b/apps/mobile/src/components/tab-screen.tsx @@ -1,19 +1,13 @@ import { type Ref } from 'react'; -import { Platform, ScrollView, type ScrollViewProps, useWindowDimensions } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { ScrollView, type ScrollViewProps } from 'react-native'; -import { getEffectiveTabBarHeight } from '@/lib/tab-bar-layout'; +import { useEffectiveTabBarHeight } from '@/lib/tab-bar-clearance'; const TAB_SCREEN_BOTTOM_GAP = 16; // FlatList/FlashList screens use this directly for contentContainerStyle.paddingBottom. export function useTabBarBottomPadding() { - const { bottom } = useSafeAreaInsets(); - const { fontScale } = useWindowDimensions(); - return ( - getEffectiveTabBarHeight({ bottomInset: bottom, platform: Platform.OS, fontScale }) + - TAB_SCREEN_BOTTOM_GAP - ); + return useEffectiveTabBarHeight() + TAB_SCREEN_BOTTOM_GAP; } export function TabScreenScrollView({ diff --git a/apps/mobile/src/lib/tab-bar-clearance.ts b/apps/mobile/src/lib/tab-bar-clearance.ts new file mode 100644 index 0000000000..366b83eda3 --- /dev/null +++ b/apps/mobile/src/lib/tab-bar-clearance.ts @@ -0,0 +1,33 @@ +import { createContext, useContext } from 'react'; +import { Platform, useWindowDimensions } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { getEffectiveTabBarHeight } from '@/lib/tab-bar-layout'; + +/** + * The tab layout's label decision for the current window, published to the tab + * screens so their content clearance matches the rendered bar height. The + * decision turns on the window width, which a clearance caller cannot see from + * `fontScale` alone: without it a caller reserves the label-inclusive height + * while the bar renders compact (labels dropped for width). `null` outside the + * tabs navigator, where `getEffectiveTabBarHeight` keeps its font-scale-only + * default. + */ +export const TabBarLabelContext = createContext(null); + +/** + * Effective rendered tab bar height for a screen's content clearance: the tabs + * layout's label decision (`TabBarLabelContext`), the safe-area bottom inset and + * the platform. + */ +export function useEffectiveTabBarHeight(): number { + const { bottom } = useSafeAreaInsets(); + const { fontScale } = useWindowDimensions(); + const showLabel = useContext(TabBarLabelContext) ?? undefined; + return getEffectiveTabBarHeight({ + bottomInset: bottom, + platform: Platform.OS, + fontScale, + showLabel, + }); +} diff --git a/apps/mobile/src/lib/tab-bar-layout.test.ts b/apps/mobile/src/lib/tab-bar-layout.test.ts index 43e1f6b8f1..9a58ea1825 100644 --- a/apps/mobile/src/lib/tab-bar-layout.test.ts +++ b/apps/mobile/src/lib/tab-bar-layout.test.ts @@ -1,3 +1,8 @@ +// eslint-disable-next-line import/no-nodejs-modules -- vitest-only parity check, runs in node, never bundled into the app +import { readFileSync } from 'node:fs'; +// eslint-disable-next-line import/no-nodejs-modules -- vitest-only parity check, runs in node, never bundled into the app +import { fileURLToPath } from 'node:url'; + import { describe, expect, it } from 'vitest'; import { i18n } from '@/i18n'; @@ -13,10 +18,21 @@ import { TAB_LABEL_WRAP_FONT_SCALE, tabAccessibilityLabel, tabBarPosition, + tabLabelFits, tabLabelNumberOfLines, + tabLabelWidth, visibleTabCount, } from '@/lib/tab-bar-layout'; +const LAYOUT_SOURCE = readFileSync( + fileURLToPath(new URL('tab-bar-layout.ts', import.meta.url)), + 'utf8' +); +const LABEL_SOURCE = readFileSync( + fileURLToPath(new URL('../components/tab-bar-label.tsx', import.meta.url)), + 'utf8' +); + describe('getTabBarOverlayHeight', () => { it('includes the bottom safe area on iOS', () => { expect(getTabBarOverlayHeight(34, 'ios')).toBe(84); @@ -83,6 +99,17 @@ describe('getEffectiveTabBarHeight', () => { 70 ); }); + + it('honours a caller-supplied label decision at the default font scale', () => { + expect( + getEffectiveTabBarHeight({ + bottomInset: 16, + platform: 'android', + fontScale: 1, + showLabel: false, + }) + ).toBe(getTabBarIconForwardHeight(16, 'android')); + }); }); describe('getTabBarIconForwardHeight', () => { it('collapses to the base height when labels are hidden at large font scale', () => { @@ -138,6 +165,51 @@ describe('getTabBarHorizontalInset', () => { }); }); +describe('tabLabelWidth', () => { + it('measures a single unbroken word at the tab label size', () => { + // "Profile" is 7 glyphs at (0.6em x 11) + 0.2 tracking = 47.6 + expect(tabLabelWidth('Profile')).toBeCloseTo(47.6, 5); + }); + + it('measures a wrapped label line by line, but a space-separated label as one line', () => { + // Only the explicit break splits the label into two lines; a plain space + // stays on the single line the renderer lays out. + expect(tabLabelWidth('Kilo\nClaw')).toBeCloseTo(tabLabelWidth('Kilo'), 5); + expect(tabLabelWidth('Bogga shakhsiga')).toBeCloseTo(15 * (0.6 * 11 + 0.2), 5); + }); + + it('counts a plain space as part of the single rendered line', () => { + // "Kilo Claw" is one line of 9 glyphs, not the 4-glyph widest word. + expect(tabLabelWidth('Kilo Claw')).toBeCloseTo(9 * (0.6 * 11 + 0.2), 5); + }); + + it('scales the glyph advance (not the tracking) with the system font scale', () => { + expect(tabLabelWidth('Profile', 2)).toBeCloseTo(7 * (0.6 * 11 * 2 + 0.2), 5); + }); + + it('counts CJK/Kana/Hangul glyphs as one em wide', () => { + expect(tabLabelWidth('設定')).toBeCloseTo(2 * (11 + 0.2), 5); + }); +}); + +describe('tabLabelFits', () => { + it('rejects a label wider than its tab minus the item padding', () => { + // 160dp / 3 tabs = 53.3dp box, less 10dp padding = 43.3dp for a 47.6dp word + expect(tabLabelFits('Profile', 160 / 3)).toBe(false); + }); + + it('accepts the same label on a normal phone width', () => { + expect(tabLabelFits('Profile', 360 / 3)).toBe(true); + }); + + it('rejects a space-separated label that overflows its single rendered line', () => { + // "KILO CLAW" renders on one 9-glyph line (61.2dp), so it cannot fit the + // 43.3dp label box even though either word alone would. + expect(tabLabelFits('Kilo Claw', 160 / 3)).toBe(false); + expect(tabLabelFits('Bogga shakhsiga', 160 / 3)).toBe(false); + }); +}); + describe('shouldShowTabLabel', () => { it('keeps the label below the icon-forward threshold', () => { expect(shouldShowTabLabel(1)).toBe(true); @@ -149,6 +221,37 @@ describe('shouldShowTabLabel', () => { expect(shouldShowTabLabel(2.5)).toBe(false); expect(shouldShowTabLabel(3)).toBe(false); }); + + it('drops the labels when any label is too wide for its tab', () => { + // Reported geometry: 160dp / 5 tabs = 32dp per tab, 22dp for the label + expect(shouldShowTabLabel(1, 160 / 5, ['Home', 'KiloClaw', 'Agents', 'Chat', 'Profile'])).toBe( + false + ); + }); + + it('keeps the labels when every label fits its tab', () => { + expect(shouldShowTabLabel(1, 360 / 5, ['Home', 'KiloClaw', 'Agents', 'Chat', 'Profile'])).toBe( + true + ); + }); + + it('drops the labels when a space-separated label overflows its single line', () => { + // `so` `common.profile` = "Bogga shakhsiga" (15 glyphs = 102dp) does not + // fit a 43.3dp label box at 160dp, so the bar goes icon-only instead of + // rendering a tail-ellipsized label. + expect(shouldShowTabLabel(1, 160 / 3, ['Home', 'Agents', 'Bogga shakhsiga'])).toBe(false); + // "Quick Chat" (10 glyphs = 68dp) overflows even though each word alone + // fits, so the widest-word measure would wrongly keep the labels. + expect(shouldShowTabLabel(1, 160 / 3, ['Home', 'Agents', 'Quick Chat'])).toBe(false); + }); + + it('keeps a space-separated label once its whole line fits the tab', () => { + expect(shouldShowTabLabel(1, 360 / 3, ['Home', 'Agents', 'Bogga shakhsiga'])).toBe(true); + }); + + it('keeps the width rule from overriding the font-scale rule', () => { + expect(shouldShowTabLabel(2, 160 / 5, ['Home'])).toBe(false); + }); }); describe('tabLabelNumberOfLines', () => { @@ -166,6 +269,27 @@ describe('tabLabelNumberOfLines', () => { }); }); +describe('mirrored tab label metrics', () => { + // The metrics at the top of this module are copied from the label + // component's style, so the "keep these in step" pointer must name the file + // that actually owns that style, and the copied tokens must still match it. + it('points at the component that owns the tab label style', () => { + expect(LAYOUT_SOURCE).toContain('apps/mobile/src/components/tab-bar-label.tsx'); + expect(LABEL_SOURCE).toContain('export function TabBarLabel'); + }); + + it('mirrors the component font size and tracking in the width estimate', () => { + // JetBrains Mono advances 0.6em per glyph; the label adds 0.2px tracking. + // `tabLabelWidth` must use the same 11px/0.2px the component's class sets, + // and the component must render one line via `tabLabelNumberOfLines`. + expect(LABEL_SOURCE).toContain( + 'font-mono-medium text-[11px] leading-4 uppercase tracking-[0.2px]' + ); + expect(LABEL_SOURCE).toContain('numberOfLines={tabLabelNumberOfLines(label)}'); + expect(tabLabelWidth('A')).toBeCloseTo(0.6 * 11 + 0.2); + }); +}); + describe('shouldHideTabBar', () => { it('hides tabs for full-screen nested routes', () => { expect(shouldHideTabBar('/chat/sandbox-1/instance-picker')).toBe(true); diff --git a/apps/mobile/src/lib/tab-bar-layout.ts b/apps/mobile/src/lib/tab-bar-layout.ts index c9972c4314..ecbc466b89 100644 --- a/apps/mobile/src/lib/tab-bar-layout.ts +++ b/apps/mobile/src/lib/tab-bar-layout.ts @@ -16,6 +16,21 @@ export const TAB_ICON_FORWARD_FONT_SCALE = 2; const TAB_ICON_BASE_SIZE = 22; const TAB_ICON_MAX_SIZE = 26; +// Label metrics mirrored from `TabBarLabel` +// (`apps/mobile/src/components/tab-bar-label.tsx`): `font-mono-medium text-[11px] +// leading-4 uppercase tracking-[0.2px]`. Keep these in step with that style. +const TAB_LABEL_FONT_SIZE = 11; +const TAB_LABEL_LETTER_SPACING = 0.2; +/** JetBrains Mono (the `font-mono-medium` label) advances 0.6em per glyph. */ +const MONO_ADVANCE_EM = 0.6; +/** CJK/Kana/Hangul/fullwidth glyphs render one em wide in the same stack. */ +const FULL_WIDTH_ADVANCE_EM = 1; +/** + * react-navigation's vertical tab item (`tabVerticalUiKit: { padding: 5 }` in + * `BottomTabItem`), so a tab's label box is `tabWidth - 10`. + */ +const TAB_ITEM_HORIZONTAL_PADDING = 10; + type TabBarPlatform = 'android' | 'ios' | 'macos' | 'windows' | 'web'; export function getTabBarOverlayHeight( @@ -59,18 +74,24 @@ export function getTabBarIconSize(fontScale = 1): number { * Effective rendered tab bar height for the current platform/font scale. This * is the single source of truth for both the tab bar itself and the content * clearance below it: it switches to the compact icon-forward height once labels - * are hidden, and otherwise uses the label-inclusive overlay height. + * are hidden, and otherwise uses the label-inclusive overlay height. Callers + * that already decided the label state (from the window width) pass it as + * `showLabel`; the default keeps the font-scale-only answer. Tab screens use + * `useEffectiveTabBarHeight` (`tab-bar-clearance.ts`), which supplies the tab + * layout's decision so the clearance cannot drift from the rendered height. */ export function getEffectiveTabBarHeight({ bottomInset, platform, fontScale = 1, + showLabel = shouldShowTabLabel(fontScale), }: { bottomInset: number; platform: TabBarPlatform; fontScale?: number; + showLabel?: boolean; }): number { - return shouldShowTabLabel(fontScale) + return showLabel ? getTabBarOverlayHeight(bottomInset, platform, fontScale) : getTabBarIconForwardHeight(bottomInset, platform); } @@ -94,8 +115,73 @@ export function getTabBarHorizontalInset({ }; } -export function shouldShowTabLabel(fontScale = 1): boolean { - return fontScale < TAB_ICON_FORWARD_FONT_SCALE; +/** + * Whether the tab bar shows visible labels. Labels are dropped at and above + * `TAB_ICON_FORWARD_FONT_SCALE` (the bar would balloon with the scaled label), + * and when any label is too wide for its tab at the current window width (RN + * would tail-ellipsize it, or wrap a `\n` line mid-word). With no `labels` the + * per-tab width is unknown, so only the font-scale rule applies — the previous + * behaviour, unchanged for callers that do not pass a tab width. + */ +export function shouldShowTabLabel( + fontScale = 1, + tabWidth = Number.POSITIVE_INFINITY, + labels: readonly string[] = [] +): boolean { + if (fontScale >= TAB_ICON_FORWARD_FONT_SCALE) { + return false; + } + if (labels.length === 0) { + return true; + } + return labels.every(label => tabLabelFits(label, tabWidth, fontScale)); +} + +/** + * Characters the tab label's monospace stack draws one em wide: CJK + * ideographs, Kana, Hangul and fullwidth forms/punctuation. A conservative + * class — it only bounds the glyph advance, so a stray match over-estimates + * the width and hides the labels slightly earlier rather than leaving a + * mid-word break on screen. Ranges mirror the `is-fullwidth-code-point` class. + */ +const FULL_WIDTH_CHARACTER = + /[\u1100-\u115F\u2329\u232A\u2E80-\u3247\u3250-\u4DBF\u4E00-\uA4C6\uA960-\uA97C\uAC00-\uD7A3\uF900-\uFAFF\uFE10-\uFE19\uFE30-\uFE6B\uFF01-\uFF60\uFFE0-\uFFE6\u{1F200}-\u{1F251}\u{20000}-\u{3FFFD}]/u; + +function isFullWidthCharacter(character: string): boolean { + return FULL_WIDTH_CHARACTER.test(character); +} + +/** + * Estimated rendered width (dp) of the widest line of a tab label. A label is + * one line (`tabLabelNumberOfLines`), except copy that carries its own break + * (`Kilo\nClaw`), whose wider line is the one that has to fit. Only an explicit + * break starts a new line: an ordinary space stays on the same one line and + * counts toward its width (the renderer tail-ellipsizes, it does not wrap on a + * space). The estimate is deliberately conservative: it only decides whether + * the labels are dropped, so over-estimating hides them slightly early and + * never leaves a clipped label on screen. + */ +export function tabLabelWidth(label: string, fontScale = 1): number { + let widest = 0; + for (const line of label.split('\n')) { + let lineWidth = 0; + for (const character of line) { + const advanceEm = isFullWidthCharacter(character) ? FULL_WIDTH_ADVANCE_EM : MONO_ADVANCE_EM; + lineWidth += advanceEm * TAB_LABEL_FONT_SIZE * fontScale + TAB_LABEL_LETTER_SPACING; + } + widest = Math.max(widest, lineWidth); + } + return widest; +} + +/** + * Whether `label` fits a tab item `tabWidth` dp wide without wrapping mid-word. + * The item's own horizontal padding is removed first, matching + * react-navigation's tab item (`padding: 5` on each side). + */ +export function tabLabelFits(label: string, tabWidth: number, fontScale = 1): boolean { + const available = Math.max(tabWidth - TAB_ITEM_HORIZONTAL_PADDING, 0); + return tabLabelWidth(label, fontScale) <= available; } /**