diff --git a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx index 119f6a8bc9..1cbe6eb571 100644 --- a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx @@ -10,6 +10,7 @@ import { StateSurfaceInsets } from '@/components/centered-state-surface'; import { TabBarButton } from '@/components/tab-bar-button'; import { TabBarLabel } from '@/components/tab-bar-label'; import { BlurBar } from '@/components/ui/blur-bar'; +import { Text } from '@/components/ui/text'; import { FEATURE_FLAG_QUICK_CHAT, useFeatureFlag } from '@/lib/analytics/posthog'; import { usePendingAppAction } from '@/lib/app-actions/use-pending-app-action'; import { PROFILE_TAB_ROOT } from '@/lib/finding-detail-back'; @@ -30,9 +31,11 @@ import { getTabBarIconSize, shouldHideTabBar, shouldShowTabLabel, + TAB_LABEL_MINIMUM_FONT_SCALE, TAB_LABEL_WRAP_FONT_SCALE, tabAccessibilityLabel, tabBarPosition, + tabLabelLineCount, visibleTabCount, } from '@/lib/tab-bar-layout'; @@ -56,6 +59,48 @@ function TabBarBackground() { ); } +/** + * One tab label, on the lines the bar reserves for it (`tabLabelLineCount`). + * On the single reserved line a label too wide for its tab would wrap into a + * second line the bar has no height for and render clipped at the bar's edge + * ("PROFIL E" at 160 dp, e1, 2026-09-21), so the label is pinned to one line and + * asks the platform to shrink it to fit (`adjustsFontSizeToFit`, implemented on + * both iOS and Android) and to tail-truncate whatever still overflows. That + * shrink is a residual guard, not the legibility rule: the window-width rule + * (`shouldShowTabLabel`) drops the labels whenever a full-size label would not + * fit its tab, so a label that renders never has to shrink that far on either + * platform. `minimumFontScale` caps the residual shrink on iOS only — Android's + * Fabric autosize floors at RN's own platform minimum. Where the bar does + * reserve a second line (the wrap font scale) the shared `TabBarLabel` renders + * it: the two-line copy that carries its own break, and one truncated line for + * every other label. + */ +function TabLabel({ + label, + focused, + allowWrap, +}: Readonly<{ label: string; focused: boolean; allowWrap: boolean }>) { + if (allowWrap) { + return ; + } + return ( + + {label} + + ); +} + export default function TabsLayout() { const router = useRouter(); const pathname = usePathname(); @@ -63,6 +108,8 @@ export default function TabsLayout() { const colors = useThemeColors(); const { bottom, left, right } = useSafeAreaInsets(); const { width, fontScale } = useWindowDimensions(); + const tabLabelLines = tabLabelLineCount(fontScale); + const allowLabelWrap = tabLabelLines > 1; const hideTabs = shouldHideTabBar(pathname); const showKiloClawTab = useKiloClawTabVisible(); const showQuickChatTab = useFeatureFlag(FEATURE_FLAG_QUICK_CHAT, false); @@ -205,7 +252,9 @@ export default function TabsLayout() { tabBarPosition('home', tabFlags) ?? 1, tabCount ), - tabBarLabel: ({ focused }) => , + tabBarLabel: ({ focused }) => ( + + ), tabBarIcon: ({ color, focused }) => ( ), @@ -225,7 +274,9 @@ export default function TabsLayout() { // 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 }) => , + tabBarLabel: ({ focused }) => ( + + ), tabBarIcon: ({ color, focused }) => ( ), @@ -250,7 +301,9 @@ export default function TabsLayout() { tabBarPosition('agents', tabFlags) ?? 2, tabCount ), - tabBarLabel: ({ focused }) => , + tabBarLabel: ({ focused }) => ( + + ), tabBarIcon: ({ color, focused }) => ( ), @@ -267,7 +320,9 @@ export default function TabsLayout() { tabBarPosition('chat', tabFlags) ?? 3, tabCount ), - tabBarLabel: ({ focused }) => , + tabBarLabel: ({ focused }) => ( + + ), tabBarIcon: ({ color, focused }) => ( ), @@ -283,7 +338,9 @@ export default function TabsLayout() { tabCount, tabCount ), - tabBarLabel: ({ focused }) => , + tabBarLabel: ({ focused }) => ( + + ), tabBarIcon: ({ color, focused }) => ( ), diff --git a/apps/mobile/src/components/add-credits-row.mounted.test.tsx b/apps/mobile/src/components/add-credits-row.mounted.test.tsx new file mode 100644 index 0000000000..cb5a59b0b7 --- /dev/null +++ b/apps/mobile/src/components/add-credits-row.mounted.test.tsx @@ -0,0 +1,75 @@ +/* eslint-disable typescript-eslint/no-deprecated -- DOM-free mounted React Native layout regression tests. */ +import { createElement, type ElementType } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { act, TestRenderer } from '@/test/renderer'; + +import { AddCreditsRow } from './add-credits-row'; + +const windowDims = vi.hoisted(() => ({ width: 390, height: 844, fontScale: 1, scale: 2 })); +const platform = vi.hoisted(() => ({ OS: 'android' as string })); + +vi.mock('react-native', () => ({ + Platform: platform, + View: 'View', + useWindowDimensions: () => windowDims, +})); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/lib/external-link', () => ({ openExternalUrl: vi.fn() })); + +const BUTTON = 'Button' as ElementType; + +let renderer: TestRenderer.ReactTestRenderer | undefined = undefined; + +function renderRow() { + act(() => { + const element = createElement(AddCreditsRow, { url: 'https://example.com/credits' }); + if (renderer) { + renderer.update(element); + } else { + renderer = TestRenderer.create(element); + } + }); + if (!renderer) { + throw new Error('Missing AddCreditsRow renderer'); + } + return renderer.root; +} + +/** The row's only View: the container the copy and the button sit in. */ +function containerClasses(root: TestRenderer.ReactTestInstance): string[] { + return String(root.find(node => Object.is(node.type, 'View')).props.className).split(' '); +} + +afterEach(() => { + act(() => renderer?.unmount()); + renderer = undefined; + windowDims.width = 390; + platform.OS = 'android'; +}); + +describe('AddCreditsRow mounted layout', () => { + // At 160 dp the "Add credits" button kept its natural width beside the copy + // and collapsed the description to a one-letter column ("A", e1, 2026-09-21). + it('stacks the copy above a full-width button in a narrow window', () => { + windowDims.width = 160; + const root = renderRow(); + expect(containerClasses(root)).toContain('gap-2'); + expect(containerClasses(root)).not.toContain('flex-row'); + expect(String(root.findByType(BUTTON).props.className).split(' ')).toContain('w-full'); + }); + + it('keeps the copy and the button on one row at phone widths', () => { + const root = renderRow(); + expect(containerClasses(root)).toEqual( + expect.arrayContaining(['flex-row', 'items-center', 'justify-between']) + ); + expect(String(root.findByType(BUTTON).props.className)).not.toContain('w-full'); + }); + + it('renders nothing on iOS', () => { + platform.OS = 'ios'; + expect(renderRow().findAllByType(BUTTON)).toHaveLength(0); + }); +}); diff --git a/apps/mobile/src/components/add-credits-row.tsx b/apps/mobile/src/components/add-credits-row.tsx index c60863f328..65c4cf7537 100644 --- a/apps/mobile/src/components/add-credits-row.tsx +++ b/apps/mobile/src/components/add-credits-row.tsx @@ -1,9 +1,10 @@ import { useTranslation } from 'react-i18next'; -import { Platform, View } from 'react-native'; +import { Platform, useWindowDimensions, View } from 'react-native'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { openExternalUrl } from '@/lib/external-link'; +import { isNarrowLayout } from '@/lib/narrow-layout'; import { cn } from '@/lib/utils'; type AddCreditsRowProps = Readonly<{ @@ -14,6 +15,11 @@ type AddCreditsRowProps = Readonly<{ /** Zero-balance CTA row: muted copy + an "Add credits" button to the web billing page. */ export function AddCreditsRow({ url, className }: AddCreditsRowProps) { const { t } = useTranslation(); + const { width } = useWindowDimensions(); + // The button keeps its natural width, so in a narrow window the description + // beside it collapses to a column of single letters ("A", 160 dp, e1, + // 2026-09-21). Stacking gives the copy the row's full width. + const narrow = isNarrowLayout(width); // App Store review: iOS must not show an in-app CTA that opens an external // purchase/billing page. Credits are managed on the web there, so this row is // Android-only — gate it here so no call site can surface it on iOS. @@ -21,13 +27,14 @@ export function AddCreditsRow({ url, className }: AddCreditsRowProps) { return null; } return ( - - + + {t('addCredits.description')}