authorgravatar for n.anisimov.23@gmail.comNick Anisimov <n.anisimov.23@gmail.com> 2026-09-25 21:14:50+04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-25 10:14:50-07:00
log7a12aff471ffe2b22b9fee495b04b32c43f45a37
treea18716507d47b3b0743e31a0cea43d484ab3d6c1
parentd06f0ff1048d42a156824765316c5a2dc54f5bca
signature Signed by PGP key B5690EEEBB952194

fix(mobile): scale Android controls with appearance text size (#13356)


30 files changed, 390 insertions(+), 179 deletions(-)

apps/mobile/src/components/AndroidAnchoredMenu.tsx+14-8
......@@ -7,9 +7,9 @@ import { useKeyboardState } from "react-native-keyboard-controller";
77import Animated, { FadeIn } from "react-native-reanimated";
88
99import { OverlayPortal } from "./OverlayPortal";
10import { useAndroidControlSizing } from "./useAndroidControlSizing";
1011import { MaterialMenuPopup } from "./MaterialMenuPopup";
1112
12const MENU_WIDTH = 250;
1313const SCREEN_MARGIN = 12;
1414const ANCHOR_GAP = 6;
1515
......@@ -55,6 +55,7 @@ export type AndroidAnchoredMenuProps = {
5555 * menus use the native popup for placement, animation and dismissal.
5656 */
5757export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
58 const { scale, menuWidth: desiredMenuWidth } = useAndroidControlSizing();
5859 const [anchor, setAnchor] = useState<AnchorSnapshot | null>(null);
5960 const [path, setPath] = useState<readonly MenuAction[]>([]);
6061 // Height of the modal's root view, in the modal's own coordinate space.
......@@ -68,6 +69,10 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
6869 // are converted into this frame, so the menu lands correctly no matter
6970 // where the portal host sits (status bar, keyboard resize, etc.).
7071 const [overlay, setOverlay] = useState<OverlayFrame | null>(null);
72 const menuWidth =
73 overlay === null
74 ? desiredMenuWidth
75 : Math.min(desiredMenuWidth, Math.max(0, overlay.width - 2 * SCREEN_MARGIN));
7176 const anchorRef = useRef<View>(null);
7277 const overlayRef = useRef<View>(null);
7378
......@@ -131,14 +136,11 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
131136 ? 0
132137 : local.x + local.width / 2 <= overlay.width / 2
133138 ? local.x
134 : local.x + local.width - MENU_WIDTH;
139 : local.x + local.width - menuWidth;
135140 const left =
136141 overlay === null
137142 ? 0
138 : Math.min(
139 Math.max(preferredLeft, SCREEN_MARGIN),
140 overlay.width - MENU_WIDTH - SCREEN_MARGIN,
141 );
143 : Math.min(Math.max(preferredLeft, SCREEN_MARGIN), overlay.width - menuWidth - SCREEN_MARGIN);
142144 // The keyboard stays up while the menu is open (in-window overlay, no
143145 // focus change), so the space it covers is not usable — without this the
144146 // composer-pill menus "open down" into the IME and can't be tapped.
......@@ -201,6 +203,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
201203 {!placeable || local === null ? null : !anchor.keyboardWasVisible ? (
202204 <MaterialMenuPopup
203205 anchor={local}
206 menuWidth={menuWidth}
204207 actions={levelActions}
205208 title={props.title}
206209 parent={parent}
......@@ -211,10 +214,12 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
211214 ) : (
212215 <Animated.View
213216 entering={FadeIn.duration(120)}
214 className="absolute w-[250px] overflow-hidden rounded-[4px] bg-card-alt shadow-md"
217 className="absolute overflow-hidden bg-card-alt shadow-md"
215218 style={{
216219 left,
217220 maxHeight,
221 width: menuWidth,
222 borderRadius: 4 * scale,
218223 ...(opensDown
219224 ? { top: local.y + local.height + ANCHOR_GAP }
220225 : { bottom: (rootHeight ?? 0) - local.y + ANCHOR_GAP }),
......@@ -227,7 +232,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
227232 active editor; the first item tap must act, not just
228233 dismiss the keyboard. */}
229234 <ScrollView
230 contentContainerClassName="py-2"
235 contentContainerStyle={{ paddingVertical: 7 * scale }}
231236 bounces={false}
232237 keyboardShouldPersistTaps="always"
233238 showsVerticalScrollIndicator={false}
......@@ -235,6 +240,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
235240 <MaterialMenuPopup
236241 inline
237242 anchor={local}
243 menuWidth={menuWidth}
238244 actions={levelActions}
239245 title={props.title}
240246 parent={parent}
apps/mobile/src/components/AndroidScreenHeader.tsx+7-10
......@@ -1,6 +1,5 @@
11import { useState, type ReactNode } from "react";
22import { View } from "react-native";
3import { useSafeAreaInsets } from "react-native-safe-area-context";
43
54import type { AppSymbolName } from "./AppSymbol";
65import { AppText as Text } from "./AppText";
......@@ -8,7 +7,7 @@ import { cn } from "../lib/cn";
87import { MaterialIconButton } from "./MaterialIconButton";
98import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu";
109import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
11import { useMaterialToolbarHeight } from "./useMaterialToolbarHeight";
10import { useMaterialToolbarLayout } from "./useMaterialToolbarLayout";
1211
1312export interface AndroidHeaderAction {
1413 readonly accessibilityLabel: string;
......@@ -38,10 +37,11 @@ export function AndroidScreenHeader(props: {
3837 readonly embedded?: boolean;
3938 readonly hideBottomBorder?: boolean;
4039}) {
41 const insets = useSafeAreaInsets();
4240 const titleTypography = useScaledTextRole("title");
4341 const subtitleTypography = useScaledTextRole("label");
44 const materialToolbarHeight = useMaterialToolbarHeight();
42 const { height: materialToolbarHeight, ...headerPadding } = useMaterialToolbarLayout(
43 props.embedded,
44 );
4545 const [headerWidth, setHeaderWidth] = useState(0);
4646 const actions = props.actions ?? [];
4747 const directCount = actions.length > 2 ? (headerWidth >= 600 ? 3 : 1) : actions.length;
......@@ -51,16 +51,13 @@ export function AndroidScreenHeader(props: {
5151 return (
5252 <View
5353 onLayout={(event) => setHeaderWidth(event.nativeEvent.layout.width)}
54 className="border-b border-header-border bg-header px-2 pb-2"
54 className="border-b border-header-border bg-header px-2"
5555 style={{
56 paddingTop: props.embedded ? 8 : Math.max(insets.top, 12),
56 ...headerPadding,
5757 borderBottomWidth: props.hideBottomBorder ? 0 : undefined,
5858 }}
5959 >
60 <View
61 style={{ minHeight: materialToolbarHeight }}
62 className="min-h-14 flex-row items-center gap-1"
63 >
60 <View style={{ minHeight: materialToolbarHeight }} className="flex-row items-center gap-1">
6461 {props.onBack ? (
6562 <MaterialIconButton
6663 accessibilityLabel="Navigate up"
apps/mobile/src/components/CompactBrandTitle.tsx+16-5
......@@ -6,6 +6,7 @@ import { AppText as Text } from "./AppText";
66import { T3Wordmark } from "./T3Wordmark";
77import { IPAD_HOME_TITLE_OFFSET } from "../lib/layoutMetrics";
88import { resolveMobileStageLabel } from "../lib/mobileBranding";
9import { useAndroidControlSizing } from "./useAndroidControlSizing";
910
1011/**
1112 * Horizontal correction applied to content rendered in the brand title slot,
......@@ -26,6 +27,7 @@ export function CompactBrandTitle(
2627) {
2728 const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant);
2829 const titleOffset = brandTitleOffset();
30 const { scale } = useAndroidControlSizing();
2931
3032 return (
3133 <View
......@@ -34,19 +36,28 @@ export function CompactBrandTitle(
3436 accessible
3537 role="heading"
3638 className="flex-row items-center gap-1.5"
37 style={{ marginLeft: titleOffset }}
39 style={[{ marginLeft: titleOffset }, Platform.OS === "android" && { gap: 5.25 * scale }]}
3840 >
39 <T3Wordmark colorClassName="accent-icon" height={15} />
41 <T3Wordmark colorClassName="accent-icon" height={Math.round(15 * scale)} />
4042 <Text
4143 allowFontScaling={props.allowFontScaling}
42 className="font-t3-medium text-[21px] tracking-[-0.5px] text-foreground-muted"
44 className="font-t3-medium text-foreground-muted"
45 style={{ fontSize: 21 * scale, letterSpacing: -0.5 * scale }}
4346 >
4447 Code
4548 </Text>
46 <View className="rounded-full bg-subtle px-1.5 py-0.5">
49 <View
50 className="rounded-full bg-subtle px-1.5 py-0.5"
51 style={
52 Platform.OS === "android"
53 ? { paddingHorizontal: 5.25 * scale, paddingVertical: 1.75 * scale }
54 : undefined
55 }
56 >
4757 <Text
4858 allowFontScaling={props.allowFontScaling}
49 className="font-t3-bold text-[9px] tracking-[0.9px] text-foreground-muted uppercase"
59 className="font-t3-bold text-foreground-muted uppercase"
60 style={{ fontSize: 9 * scale, letterSpacing: 0.9 * scale }}
5061 >
5162 {stageLabel}
5263 </Text>
apps/mobile/src/components/ComposerAttachmentButton.tsx+3-1
......@@ -1,6 +1,7 @@
11import type { MenuAction } from "@react-native-menu/menu";
22import { Pressable } from "react-native";
33
4import { useAndroidControlSizing } from "./useAndroidControlSizing";
45import { SymbolView } from "./AppSymbol";
56import { ControlPillMenu } from "./ControlPill";
67
......@@ -15,6 +16,7 @@ export function ComposerAttachmentButton(props: {
1516 readonly onPickMedia: () => Promise<void>;
1617 readonly onPickFiles: () => Promise<void>;
1718}) {
19 const { scale } = useAndroidControlSizing();
1820 const button = (
1921 <Pressable
2022 accessibilityLabel="Add attachment"
......@@ -26,7 +28,7 @@ export function ComposerAttachmentButton(props: {
2628 >
2729 <SymbolView
2830 name="plus"
29 size={20}
31 size={Math.round(20 * scale)}
3032 weight="regular"
3133 tintColorClassName="accent-icon"
3234 type="monochrome"
apps/mobile/src/components/ComposerToolbar.tsx+18-7
......@@ -1,6 +1,7 @@
11import type { ComponentProps, ReactNode } from "react";
22import { useCallback, useMemo, useState } from "react";
33import {
4 Platform,
45 Pressable,
56 ScrollView,
67 View,
......@@ -14,6 +15,7 @@ import {
1415import { cn } from "../lib/cn";
1516import { AppText as Text } from "./AppText";
1617import { SymbolView } from "./AppSymbol";
18import { useAndroidControlSizing } from "./useAndroidControlSizing";
1719
1820const COMPOSER_TOOLBAR_GAP = 8;
1921const COMPOSER_TOOLBAR_FADE_WIDTH = 18;
......@@ -30,7 +32,7 @@ export function ComposerInlineControl(props: {
3032 readonly disabled?: boolean;
3133 readonly emphasized?: boolean;
3234 readonly icon?: ComponentProps<typeof SymbolView>["name"];
33 readonly iconNode?: ReactNode;
35 readonly renderIcon?: (size: number) => ReactNode;
3436 readonly label: string;
3537 readonly maxWidth?: ViewStyle["maxWidth"];
3638 readonly onPress?: () => void;
......@@ -39,6 +41,7 @@ export function ComposerInlineControl(props: {
3941 readonly chevronDirection?: "down" | "right";
4042 readonly showChevron?: boolean;
4143}) {
44 const { scale, smallIconSize } = useAndroidControlSizing();
4245 return (
4346 <Pressable
4447 accessibilityLabel={props.accessibilityLabel ?? props.label}
......@@ -52,12 +55,17 @@ export function ComposerInlineControl(props: {
5255 onPress={props.onPress}
5356 style={{ maxWidth: props.maxWidth ?? 190, opacity: props.disabled ? 0.45 : 1 }}
5457 >
55 {props.iconNode ? (
56 <View className="size-4 shrink-0 items-center justify-center">{props.iconNode}</View>
58 {props.renderIcon ? (
59 <View
60 className="size-4 shrink-0 items-center justify-center"
61 style={Platform.OS === "android" ? { width: 14 * scale, height: 14 * scale } : undefined}
62 >
63 {props.renderIcon(smallIconSize)}
64 </View>
5765 ) : props.icon ? (
5866 <SymbolView
5967 name={props.icon}
60 size={16}
68 size={smallIconSize}
6169 tintColorClassName={
6270 props.emphasized || props.selected ? "accent-icon" : "accent-icon-muted"
6371 }
......@@ -76,7 +84,7 @@ export function ComposerInlineControl(props: {
7684 {props.showChevron === false ? null : (
7785 <SymbolView
7886 name={props.chevronDirection === "right" ? "chevron.right" : "chevron.down"}
79 size={10}
87 size={Math.round(10 * scale)}
8088 tintColorClassName={
8189 props.emphasized || props.selected ? "accent-icon" : "accent-icon-muted"
8290 }
......@@ -224,6 +232,8 @@ export function ComposerActionButton(props: {
224232 readonly onPress: () => void;
225233 readonly variant?: "primary" | "danger";
226234}) {
235 const { scale, smallIconSize } = useAndroidControlSizing();
236 const circleSize = Math.round(30 * scale);
227237 return (
228238 <Pressable
229239 accessibilityLabel={props.accessibilityLabel}
......@@ -234,8 +244,9 @@ export function ComposerActionButton(props: {
234244 onPress={props.onPress}
235245 >
236246 <View
247 style={{ width: circleSize, height: circleSize }}
237248 className={cn(
238 "size-[30px] items-center justify-center rounded-full",
249 "items-center justify-center rounded-full",
239250 props.variant === "danger"
240251 ? "bg-danger"
241252 : props.disabled
......@@ -245,7 +256,7 @@ export function ComposerActionButton(props: {
245256 >
246257 <SymbolView
247258 name={props.icon}
248 size={16}
259 size={smallIconSize}
249260 weight="semibold"
250261 tintColorClassName={
251262 props.variant === "danger" ? "accent-danger-foreground" : "accent-primary-foreground"
apps/mobile/src/components/ControlPill.tsx+3-1
......@@ -5,6 +5,7 @@ import { SymbolView } from "./AppSymbol";
55import { AppText as Text } from "./AppText";
66import { MaterialIconButton } from "./MaterialIconButton";
77import { MaterialButton } from "./MaterialButton";
8import { useAndroidControlSizing } from "./useAndroidControlSizing";
89
910export { ControlPillMenu } from "./ControlPillMenu";
1011
......@@ -20,6 +21,7 @@ export function ControlPill(props: {
2021 readonly className?: string;
2122}) {
2223 const variant = props.variant ?? "circle";
24 const { smallIconSize } = useAndroidControlSizing();
2325 const activatedOnPressInRef = useRef(false);
2426
2527 const handlePressIn = () => {
......@@ -133,7 +135,7 @@ export function ControlPill(props: {
133135 ) : props.icon ? (
134136 <SymbolView
135137 name={props.icon}
136 size={16}
138 size={smallIconSize}
137139 tintColorClassName={iconTintClassName}
138140 type="monochrome"
139141 />
apps/mobile/src/components/MaterialButton.android.tsx+4-2
......@@ -14,10 +14,12 @@ import { View } from "react-native";
1414import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
1515import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
1616import type { MaterialButtonProps } from "./MaterialButton";
17import { useAndroidControlSizing } from "./useAndroidControlSizing";
1718
1819export function MaterialButton(props: MaterialButtonProps) {
1920 const { themeAppearance, themeVariables: colors } = useAppearancePreferences();
2021 const typography = useScaledTextRole("footnote");
22 const { scale, mediumIconSize } = useAndroidControlSizing();
2123 const tone = props.tone ?? "secondary";
2224 const Component =
2325 tone === "text" ? TextButton : tone === "secondary" ? FilledTonalButton : Button;
......@@ -77,11 +79,11 @@ export function MaterialButton(props: MaterialButtonProps) {
7779 {props.loading ? (
7880 <>
7981 <CircularProgressIndicator
80 modifiers={[size(18, 18)]}
82 modifiers={[size(mediumIconSize, mediumIconSize)]}
8183 strokeWidth={2}
8284 color={colors["--color-foreground-muted"]}
8385 />
84 <Box modifiers={[size(8, 1)]} />
86 <Box modifiers={[size(8 * scale, 1)]} />
8587 </>
8688 ) : null}
8789 <Text style={{ ...typography, fontWeight: "500" }}>{props.label}</Text>
apps/mobile/src/components/MaterialFloatingActionButton.android.tsx+10-2
......@@ -6,8 +6,9 @@ import {
66 LargeFloatingActionButton,
77 Text,
88} from "@expo/ui/jetpack-compose";
9import { size } from "@expo/ui/jetpack-compose/modifiers";
9import { defaultMinSize, height, size, width } from "@expo/ui/jetpack-compose/modifiers";
1010import { View, type StyleProp, type ViewStyle } from "react-native";
11import { useAndroidControlSizing } from "./useAndroidControlSizing";
1112import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
1213import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
1314import { SymbolView, type AppSymbolName } from "./AppSymbol";
......@@ -24,6 +25,8 @@ export function MaterialFloatingActionButton(props: {
2425}) {
2526 const { themeAppearance, themeVariables: colors } = useAppearancePreferences();
2627 const typography = useScaledTextRole("footnote");
28 const { scale, iconSize: standardIconSize, fabSize, largeFabSize } = useAndroidControlSizing();
29 const buttonSize = props.variant === "large" ? largeFabSize : fabSize;
2730 const primary = props.tone === "primary";
2831 const containerColor = colors[primary ? "--color-primary" : "--color-secondary"];
2932 const contentColor =
......@@ -34,7 +37,7 @@ export function MaterialFloatingActionButton(props: {
3437 : props.variant === "large"
3538 ? LargeFloatingActionButton
3639 : FloatingActionButton;
37 const iconSize = props.variant === "large" ? 36 : 24;
40 const iconSize = props.variant === "large" ? Math.round(36 * scale) : standardIconSize;
3841 return (
3942 <View
4043 accessible
......@@ -48,6 +51,11 @@ export function MaterialFloatingActionButton(props: {
4851 <View importantForAccessibility="no-hide-descendants">
4952 <Host matchContents colorScheme={themeAppearance} ignoreSafeAreaKeyboardInsets>
5053 <Component
54 modifiers={[
55 defaultMinSize({ minWidth: buttonSize }),
56 height(buttonSize),
57 ...(props.variant === "extended" ? [] : [width(buttonSize)]),
58 ]}
5159 containerColor={containerColor}
5260 onClick={props.onPress}
5361 expanded={props.expanded}
apps/mobile/src/components/MaterialIconButton.android.tsx+6-4
......@@ -8,6 +8,7 @@ import { size } from "@expo/ui/jetpack-compose/modifiers";
88import { View } from "react-native";
99
1010import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
11import { useAndroidControlSizing } from "./useAndroidControlSizing";
1112import { SymbolView, type AppSymbolName } from "./AppSymbol";
1213
1314export function MaterialIconButton(props: {
......@@ -20,6 +21,7 @@ export function MaterialIconButton(props: {
2021 readonly tintColorClassName?: string;
2122}) {
2223 const { themeAppearance, themeVariables: colors } = useAppearancePreferences();
24 const { iconSize, buttonSize } = useAndroidControlSizing();
2325 const variant = props.variant ?? "standard";
2426 const Component =
2527 variant === "standard"
......@@ -52,18 +54,18 @@ export function MaterialIconButton(props: {
5254 onAccessibilityAction={() => {
5355 if (!props.disabled) props.onPress?.();
5456 }}
55 style={{ width: 48, height: 48 }}
57 style={{ width: buttonSize, height: buttonSize }}
5658 >
5759 <View importantForAccessibility="no-hide-descendants">
5860 <Host
5961 colorScheme={themeAppearance}
6062 ignoreSafeAreaKeyboardInsets
61 style={{ width: 48, height: 48 }}
63 style={{ width: buttonSize, height: buttonSize }}
6264 >
6365 <Component
6466 onClick={props.onPress}
6567 enabled={!props.disabled}
66 modifiers={[size(48, 48)]}
68 modifiers={[size(buttonSize, buttonSize)]}
6769 colors={
6870 variant === "standard"
6971 ? undefined
......@@ -78,7 +80,7 @@ export function MaterialIconButton(props: {
7880 <View pointerEvents="none" className="absolute inset-0 items-center justify-center">
7981 <SymbolView
8082 name={props.icon === "ellipsis" ? { ios: "ellipsis", android: "more_vert" } : props.icon}
81 size={24}
83 size={iconSize}
8284 tintColorClassName={iconTint}
8385 type="monochrome"
8486 />
apps/mobile/src/components/MaterialListRow.tsx+3-1
......@@ -5,6 +5,7 @@ import { useAppearancePreferences } from "../features/settings/appearance/Appear
55import { cn } from "../lib/cn";
66import { AppText } from "./AppText";
77import { SymbolView } from "./AppSymbol";
8import { useAndroidControlSizing } from "./useAndroidControlSizing";
89
910/** Shared geometry for Material navigation and selection lists. Group rows in one card. */
1011export function MaterialListRow({
......@@ -23,6 +24,7 @@ export function MaterialListRow({
2324 readonly trailing?: ReactNode;
2425}) {
2526 const { themeVariables } = useAppearancePreferences();
27 const { smallIconSize } = useAndroidControlSizing();
2628 return (
2729 <Pressable
2830 accessibilityRole="button"
......@@ -52,7 +54,7 @@ export function MaterialListRow({
5254 {trailing !== undefined ? (
5355 trailing
5456 ) : !props.disabled ? (
55 <SymbolView name="chevron.right" size={16} tintColorClassName="accent-chevron" />
57 <SymbolView name="chevron.right" size={smallIconSize} tintColorClassName="accent-chevron" />
5658 ) : null}
5759 </Pressable>
5860 );
apps/mobile/src/components/MaterialMenuPopup.android.tsx+36-27
......@@ -7,9 +7,11 @@ import {
77 RNHostView,
88 Text,
99} from "@expo/ui/jetpack-compose";
10import { padding, size, width } from "@expo/ui/jetpack-compose/modifiers";
10import { defaultMinSize, padding, size, width } from "@expo/ui/jetpack-compose/modifiers";
1111import { View } from "react-native";
12import { resolveScaledTextRole } from "../lib/appearancePreferences";
1213
14import { useAndroidControlSizing } from "./useAndroidControlSizing";
1315import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
1416import type { MaterialMenuPopupProps } from "./MaterialMenuPopup";
1517import { isAppSymbolName, SymbolView, type AppSymbolName } from "./AppSymbol";
......@@ -19,12 +21,16 @@ function MenuIcon(props: {
1921 readonly destructive?: boolean;
2022 readonly disabled?: boolean;
2123}) {
24 const { iconSize } = useAndroidControlSizing();
2225 return (
23 <RNHostView matchContents modifiers={[size(24, 24)]}>
24 <View style={{ width: 24, height: 24 }} importantForAccessibility="no-hide-descendants">
26 <RNHostView matchContents modifiers={[size(iconSize, iconSize)]}>
27 <View
28 style={{ width: iconSize, height: iconSize }}
29 importantForAccessibility="no-hide-descendants"
30 >
2531 <SymbolView
2632 name={props.name}
27 size={24}
33 size={iconSize}
2834 type="monochrome"
2935 tintColorClassName={
3036 props.disabled
......@@ -41,24 +47,33 @@ function MenuIcon(props: {
4147
4248/** Native popup positioned at the original trigger, outside virtualized rows. */
4349export function MaterialMenuPopup(props: MaterialMenuPopupProps) {
44 const { themeAppearance, themeVariables: colors } = useAppearancePreferences();
50 const { appearance, themeAppearance, themeVariables: colors } = useAppearancePreferences();
51 const { scale, menuItemHeight } = useAndroidControlSizing();
52 const body = resolveScaledTextRole("body", appearance.baseFontSize);
53 const caption = resolveScaledTextRole("caption", appearance.baseFontSize);
4554 const foreground = colors["--color-foreground"];
4655 const muted = colors["--color-foreground-muted"];
56 // A fixed native item height clips wrapped labels; a minimum lets each row grow.
57 const itemModifiers = [width(props.menuWidth), defaultMinSize({ minHeight: menuItemHeight })];
4758 const items = (
4859 <>
4960 {props.parent ? (
50 <DropdownMenuItem onClick={props.onBack} modifiers={[width(250)]}>
61 <DropdownMenuItem onClick={props.onBack} modifiers={itemModifiers}>
5162 <DropdownMenuItem.LeadingIcon>
5263 <MenuIcon name="arrow.left" />
5364 </DropdownMenuItem.LeadingIcon>
5465 <DropdownMenuItem.Text>
55 <Text color={foreground} style={{ typography: "bodyLarge" }}>
66 <Text color={foreground} style={{ typography: "bodyLarge", ...body }}>
5667 {props.parent.title}
5768 </Text>
5869 </DropdownMenuItem.Text>
5970 </DropdownMenuItem>
6071 ) : props.title ? (
61 <Text color={muted} style={{ typography: "bodySmall" }} modifiers={[padding(16, 8, 16, 8)]}>
72 <Text
73 color={muted}
74 style={{ typography: "bodySmall", ...caption }}
75 modifiers={[padding(16 * scale, 8 * scale, 16 * scale, 8 * scale)]}
76 >
6277 {props.title}
6378 </Text>
6479 ) : null}
......@@ -66,19 +81,22 @@ export function MaterialMenuPopup(props: MaterialMenuPopupProps) {
6681 <DropdownMenuItem
6782 key={action.id ?? `${index}-${action.title}`}
6883 enabled={!action.attributes?.disabled}
69 modifiers={[width(250)]}
70 elementColors={{
71 textColor: action.attributes?.destructive
72 ? colors["--color-danger-foreground"]
73 : foreground,
74 disabledTextColor: muted,
75 }}
84 modifiers={itemModifiers}
7685 onClick={() => props.onPress(action)}
7786 >
87 {action.image && isAppSymbolName(action.image) ? (
88 <DropdownMenuItem.LeadingIcon>
89 <MenuIcon
90 name={action.image}
91 destructive={action.attributes?.destructive}
92 disabled={action.attributes?.disabled}
93 />
94 </DropdownMenuItem.LeadingIcon>
95 ) : null}
7896 <DropdownMenuItem.Text>
7997 <Column>
8098 <Text
81 style={{ typography: "bodyLarge" }}
99 style={{ typography: "bodyLarge", ...body }}
82100 color={
83101 action.attributes?.disabled
84102 ? muted
......@@ -90,21 +108,12 @@ export function MaterialMenuPopup(props: MaterialMenuPopupProps) {
90108 {action.title}
91109 </Text>
92110 {action.subtitle ? (
93 <Text color={muted} style={{ typography: "bodySmall" }}>
111 <Text color={muted} style={{ typography: "bodySmall", ...caption }}>
94112 {action.subtitle}
95113 </Text>
96114 ) : null}
97115 </Column>
98116 </DropdownMenuItem.Text>
99 {action.image && isAppSymbolName(action.image) ? (
100 <DropdownMenuItem.LeadingIcon>
101 <MenuIcon
102 name={action.image}
103 destructive={action.attributes?.destructive}
104 disabled={action.attributes?.disabled}
105 />
106 </DropdownMenuItem.LeadingIcon>
107 ) : null}
108117 {(action.subactions?.length ?? 0) > 0 ? (
109118 <DropdownMenuItem.TrailingIcon>
110119 <MenuIcon name="chevron.right" disabled={action.attributes?.disabled} />
......@@ -124,7 +133,7 @@ export function MaterialMenuPopup(props: MaterialMenuPopupProps) {
124133 colorScheme={themeAppearance}
125134 ignoreSafeAreaKeyboardInsets
126135 matchContents
127 style={{ width: 250 }}
136 style={{ width: props.menuWidth }}
128137 >
129138 <Column>{items}</Column>
130139 </Host>
apps/mobile/src/components/MaterialMenuPopup.tsx+1
......@@ -1,6 +1,7 @@
11import type { MenuAction } from "@react-native-menu/menu";
22
33export interface MaterialMenuPopupProps {
4 readonly menuWidth: number;
45 readonly anchor: {
56 readonly x: number;
67 readonly y: number;
apps/mobile/src/components/MaterialScrollComposeButton.android.tsx+45-13
......@@ -1,9 +1,17 @@
11import { Box, ExtendedFloatingActionButton, Host, Icon, Text } from "@expo/ui/jetpack-compose";
2import { fillMaxWidth, onSizeChanged, size } from "@expo/ui/jetpack-compose/modifiers";
2import {
3 defaultMinSize,
4 fillMaxWidth,
5 graphicsLayer,
6 height,
7 onSizeChanged,
8 size,
9} from "@expo/ui/jetpack-compose/modifiers";
310import { useCallback, useState } from "react";
411import { Pressable, View, type StyleProp, type ViewStyle } from "react-native";
12import { useAndroidControlSizing } from "./useAndroidControlSizing";
513import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
6import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
14import { resolveScaledTextRole } from "../lib/appearancePreferences";
715
816/** Keep the animated width and icon positioning entirely inside Compose, not Yoga. */
917export function MaterialScrollComposeButton(props: {
......@@ -12,14 +20,23 @@ export function MaterialScrollComposeButton(props: {
1220 readonly className?: string;
1321 readonly style?: StyleProp<ViewStyle>;
1422}) {
15 const { themeAppearance, themeVariables: colors } = useAppearancePreferences();
16 const typography = useScaledTextRole("footnote");
17 const [expandedWidth, setExpandedWidth] = useState(56);
23 const { appearance, themeAppearance, themeVariables: colors } = useAppearancePreferences();
24 const typography = resolveScaledTextRole("footnote", appearance.baseFontSize);
25 const { iconSize, fabSize } = useAndroidControlSizing();
26 // Scale the native 56dp minimum; keep text and icons at their requested sizes.
27 const nativeSize = Math.max(56, fabSize);
28 const scale = fabSize / nativeSize;
29 const nativeIconSize = Math.round(iconSize / scale);
30 const [buttonWidth, setButtonWidth] = useState(nativeSize);
1831 const rememberWidth = useCallback(({ width }: { width: number }) => {
19 setExpandedWidth((previous) => Math.max(previous, width));
32 setButtonWidth((previous) => Math.max(previous, width));
2033 }, []);
2134 return (
22 <View pointerEvents="box-none" className={props.className} style={[props.style, { left: 20 }]}>
35 <View
36 pointerEvents="box-none"
37 className={props.className}
38 style={[props.style, { left: 20, height: fabSize }]}
39 >
2340 <View pointerEvents="none" importantForAccessibility="no-hide-descendants">
2441 <Host
2542 matchContents={{ vertical: true }}
......@@ -31,13 +48,23 @@ export function MaterialScrollComposeButton(props: {
3148 <ExtendedFloatingActionButton
3249 expanded={props.expanded}
3350 containerColor={colors["--color-primary"]}
34 modifiers={[onSizeChanged(rememberWidth)]}
51 modifiers={[
52 defaultMinSize({ minWidth: nativeSize }),
53 height(nativeSize),
54 graphicsLayer({
55 scaleX: scale,
56 scaleY: scale,
57 transformOriginX: 1,
58 transformOriginY: 0,
59 }),
60 onSizeChanged(rememberWidth),
61 ]}
3562 >
3663 <ExtendedFloatingActionButton.Icon>
37 <Box modifiers={[size(24, 24)]}>
64 <Box modifiers={[size(nativeIconSize, nativeIconSize)]}>
3865 <Icon
3966 source={require("../../assets/icons/compose.xml")}
40 size={24}
67 size={nativeIconSize}
4168 tint={colors["--color-primary-foreground"]}
4269 />
4370 </Box>
......@@ -45,7 +72,11 @@ export function MaterialScrollComposeButton(props: {
4572 <ExtendedFloatingActionButton.Text>
4673 <Text
4774 color={colors["--color-primary-foreground"]}
48 style={{ ...typography, fontWeight: "500" }}
75 style={{
76 fontSize: typography.fontSize / scale,
77 lineHeight: typography.lineHeight / scale,
78 fontWeight: "500",
79 }}
4980 >
5081 New thread
5182 </Text>
......@@ -65,8 +96,9 @@ export function MaterialScrollComposeButton(props: {
6596 right: 0,
6697 top: 0,
6798 bottom: 0,
68 width: props.expanded ? expandedWidth : 56,
69 borderRadius: 16,
99 // Release the label area as soon as collapse starts, before native measurements arrive.
100 width: props.expanded ? buttonWidth * scale : fabSize,
101 borderRadius: 16 * scale,
70102 overflow: "hidden",
71103 }}
72104 />
apps/mobile/src/components/MaterialSearchField.tsx+18-4
......@@ -2,6 +2,7 @@ import type { RefObject } from "react";
22import { Pressable, TextInput, View } from "react-native";
33
44import { SymbolView } from "./AppSymbol";
5import { useAndroidControlSizing } from "./useAndroidControlSizing";
56
67export function MaterialSearchField({
78 inputRef,
......@@ -18,9 +19,21 @@ export function MaterialSearchField({
1819 readonly value: string;
1920 readonly onChangeText: (value: string) => void;
2021}) {
22 const { scale, mediumIconSize } = useAndroidControlSizing();
2123 return (
22 <View className="h-12 min-w-0 flex-1 flex-row items-center gap-2 rounded-full border border-input-border bg-input px-3">
23 <SymbolView name="magnifyingglass" size={18} tintColorClassName="accent-foreground-muted" />
24 <View
25 className="min-w-0 flex-1 flex-row items-center rounded-full border border-input-border bg-input"
26 style={{
27 minHeight: Math.max(48, 42 * scale),
28 gap: 7 * scale,
29 paddingHorizontal: 10.5 * scale,
30 }}
31 >
32 <SymbolView
33 name="magnifyingglass"
34 size={mediumIconSize}
35 tintColorClassName="accent-foreground-muted"
36 />
2437 <TextInput
2538 ref={inputRef}
2639 accessibilityLabel={accessibilityLabel}
......@@ -33,7 +46,8 @@ export function MaterialSearchField({
3346 selectionColorClassName="accent-focus/32"
3447 cursorColorClassName="accent-focus"
3548 selectionHandleColorClassName="accent-focus"
36 className="min-w-0 flex-1 py-2 font-sans text-base text-foreground"
49 className="min-w-0 flex-1 font-sans text-base text-foreground"
50 style={{ paddingVertical: 7 * scale }}
3751 value={value}
3852 onChangeText={onChangeText}
3953 />
......@@ -49,7 +63,7 @@ export function MaterialSearchField({
4963 >
5064 <SymbolView
5165 name="xmark.circle.fill"
52 size={18}
66 size={mediumIconSize}
5367 tintColorClassName="accent-foreground-muted"
5468 />
5569 </Pressable>
apps/mobile/src/components/ScreenHeader.android.tsx+36-18
......@@ -1,6 +1,6 @@
11import { useCallback, useEffect, useRef, useState } from "react";
22import { BackHandler, Keyboard, Pressable, TextInput, View } from "react-native";
3import { useSafeAreaInsets } from "react-native-safe-area-context";
3import { useMaterialToolbarLayout } from "./useMaterialToolbarLayout";
44import { NativeStackScreenOptions } from "../native/StackHeader";
55import { AndroidWorkspaceSidebarButton } from "../features/layout/workspace-sidebar-toolbar";
66import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
......@@ -11,10 +11,12 @@ import { ControlPillMenu } from "./ControlPill";
1111import { MaterialSearchField } from "./MaterialSearchField";
1212import { androidHeaderMenuActions, findHeaderMenuAction } from "./headerMenu.android";
1313import type { ScreenHeaderProps } from "./ScreenHeader.types";
14import { useAndroidControlSizing } from "./useAndroidControlSizing";
1415
1516export function ScreenHeader(props: ScreenHeaderProps) {
1617 const { search } = props;
17 const insets = useSafeAreaInsets();
18 const { paddingTop, paddingBottom } = useMaterialToolbarLayout();
19 const { scale, buttonSize, iconSize, smallIconSize } = useAndroidControlSizing();
1820 const { themeVariables } = useAppearancePreferences();
1921 const inputRef = useRef<TextInput>(null);
2022 const [searchOpen, setSearchOpen] = useState(false);
......@@ -48,11 +50,12 @@ export function ScreenHeader(props: ScreenHeaderProps) {
4850 <Pressable
4951 accessibilityLabel={menu.title}
5052 accessibilityRole="button"
51 className="size-11 items-center justify-center rounded-full bg-subtle"
53 className="items-center justify-center rounded-full bg-subtle"
54 style={{ width: buttonSize, height: buttonSize }}
5255 >
5356 <SymbolView
5457 name={menu.icon}
55 size={16}
58 size={smallIconSize}
5659 tintColorClassName="accent-header-foreground"
5760 type="monochrome"
5861 />
......@@ -80,30 +83,43 @@ export function ScreenHeader(props: ScreenHeaderProps) {
8083 <>
8184 {options}
8285 <View
83 className="border-b border-header-border bg-header px-3 pb-2.5"
84 style={{ paddingTop: Math.max(insets.top, 12), borderBottomWidth: 0 }}
86 className="border-b border-header-border bg-header px-3"
87 style={{
88 paddingTop,
89 paddingBottom: 8.75 * scale,
90 borderBottomWidth: 0,
91 }}
8592 >
86 <View className="min-h-12 flex-row items-center gap-2">
93 <View className="flex-row items-center gap-2" style={{ minHeight: buttonSize }}>
8794 {props.onBack ? (
8895 <Pressable
8996 accessibilityLabel="Navigate up"
9097 accessibilityRole="button"
9198 hitSlop={8}
9299 onPress={props.onBack}
93 className="size-11 items-center justify-center"
100 className="items-center justify-center"
101 style={{ width: buttonSize, height: buttonSize }}
94102 >
95103 <SymbolView
96104 name="chevron.left"
97 size={24}
105 size={iconSize}
98106 tintColorClassName="accent-header-foreground"
99107 type="monochrome"
100108 />
101109 </Pressable>
102110 ) : null}
103 <View className="min-h-11 flex-1 flex-row items-center gap-2.5 rounded-2xl bg-input px-3.5">
111 <View
112 className="flex-1 flex-row items-center bg-input"
113 style={{
114 minHeight: Math.max(48, 38.5 * scale),
115 gap: 8.75 * scale,
116 borderRadius: 14 * scale,
117 paddingHorizontal: 12.25 * scale,
118 }}
119 >
104120 <SymbolView
105121 name="magnifyingglass"
106 size={17}
122 size={Math.round(17 * scale)}
107123 tintColorClassName="accent-header-foreground"
108124 type="monochrome"
109125 />
......@@ -112,9 +128,14 @@ export function ScreenHeader(props: ScreenHeaderProps) {
112128 autoCapitalize="none"
113129 onChangeText={search.onChangeText}
114130 value={search.value}
115 placeholder={search.placeholder}
131 placeholder={
132 search.compactToolbar
133 ? (search.compactPlaceholder ?? search.placeholder)
134 : search.placeholder
135 }
116136 placeholderTextColorClassName="accent-placeholder"
117 className="flex-1 py-2 text-base font-sans text-header-foreground"
137 className="flex-1 text-base font-sans text-header-foreground"
138 style={{ paddingVertical: 7 * scale }}
118139 />
119140 </View>
120141 {menuView}
......@@ -173,11 +194,8 @@ export function ScreenHeader(props: ScreenHeaderProps) {
173194 {header}
174195 </View>
175196 {searching ? (
176 <View
177 className="absolute inset-0 bg-header px-2 pb-2"
178 style={{ paddingTop: Math.max(insets.top, 12) }}
179 >
180 <View className="min-h-14 flex-1 flex-row items-center gap-1">
197 <View className="absolute inset-0 bg-header px-2" style={{ paddingTop, paddingBottom }}>
198 <View className="flex-1 flex-row items-center gap-1">
181199 <ScreenHeaderButton
182200 accessibilityLabel={search.closeAccessibilityLabel ?? "Close search"}
183201 icon="arrow.left"
apps/mobile/src/components/useAndroidControlSizing.ts created+13
......@@ -0,0 +1,13 @@
1import { Platform } from "react-native";
2
3import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
4import { DEFAULT_BASE_FONT_SIZE } from "../lib/appearancePreferences";
5import { resolveAndroidControlSizing } from "../lib/androidControlSizing";
6
7/** Shared layouts keep their existing dimensions on iOS. */
8export function useAndroidControlSizing() {
9 const { appearance } = useAppearancePreferences();
10 return resolveAndroidControlSizing(
11 Platform.OS === "android" ? appearance.baseFontSize : DEFAULT_BASE_FONT_SIZE,
12 );
13}
apps/mobile/src/components/useMaterialToolbarHeight.ts deleted-11
......@@ -1,11 +0,0 @@
1import { useWindowDimensions } from "react-native";
2
3import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
4
5/** Reserve the same title/subtitle space in every pane, including icon-only and search headers. */
6export function useMaterialToolbarHeight() {
7 const title = useScaledTextRole("title");
8 const subtitle = useScaledTextRole("label");
9 const { fontScale } = useWindowDimensions();
10 return Math.ceil(Math.max(56, (title.lineHeight + subtitle.lineHeight) * fontScale + 1));
11}
apps/mobile/src/components/useMaterialToolbarLayout.ts created+21
......@@ -0,0 +1,21 @@
1import { useWindowDimensions } from "react-native";
2import { useSafeAreaInsets } from "react-native-safe-area-context";
3
4import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
5import { useAndroidControlSizing } from "./useAndroidControlSizing";
6
7/** Shared Android header geometry; class-based spacing uses the app's 14dp rem. */
8export function useMaterialToolbarLayout(embedded = false) {
9 const insets = useSafeAreaInsets();
10 const title = useScaledTextRole("title");
11 const subtitle = useScaledTextRole("label");
12 const { scale } = useAndroidControlSizing();
13 const { fontScale } = useWindowDimensions();
14 return {
15 height: Math.ceil(
16 Math.max(48, 56 * scale, (title.lineHeight + subtitle.lineHeight) * fontScale + 1),
17 ),
18 paddingTop: embedded ? 8 * scale : Math.max(insets.top, 12 * scale),
19 paddingBottom: 7 * scale,
20 };
21}
apps/mobile/src/features/files/MaterialFilesHeader.tsx+4-7
......@@ -1,10 +1,10 @@
11import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
22import { BackHandler, Keyboard, type TextInput, View } from "react-native";
3import { useSafeAreaInsets } from "react-native-safe-area-context";
43
54import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader";
65import { AndroidAnchoredMenu } from "../../components/AndroidAnchoredMenu";
76import { MaterialSearchField } from "../../components/MaterialSearchField";
7import { useMaterialToolbarLayout } from "../../components/useMaterialToolbarLayout";
88
99/** Keep Files search in the same header row on compact and expanded layouts. */
1010export function MaterialFilesHeader(props: {
......@@ -15,7 +15,7 @@ export function MaterialFilesHeader(props: {
1515 readonly onBack?: () => void;
1616 readonly leading?: ReactNode;
1717}) {
18 const insets = useSafeAreaInsets();
18 const { paddingTop, paddingBottom } = useMaterialToolbarLayout();
1919 const searchRef = useRef<TextInput>(null);
2020 const [searchOpen, setSearchOpen] = useState(false);
2121 const searching = searchOpen || props.searchQuery.length > 0;
......@@ -77,11 +77,8 @@ export function MaterialFilesHeader(props: {
7777 />
7878 </View>
7979 {searching ? (
80 <View
81 className="absolute inset-0 bg-header px-2 pb-2"
82 style={{ paddingTop: Math.max(insets.top, 12) }}
83 >
84 <View className="min-h-14 flex-1 flex-row items-center gap-1">
80 <View className="absolute inset-0 bg-header px-2" style={{ paddingTop, paddingBottom }}>
81 <View className="flex-1 flex-row items-center gap-1">
8582 <AndroidHeaderIconButton
8683 accessibilityLabel="Close file search"
8784 icon="arrow.left"
apps/mobile/src/features/home/AndroidHomeFab.android.tsx+14-3
......@@ -1,14 +1,23 @@
11import { useCallback, useRef, useState, type ComponentProps } from "react";
2import { View, type NativeScrollEvent, type NativeSyntheticEvent } from "react-native";
2import {
3 useWindowDimensions,
4 View,
5 type NativeScrollEvent,
6 type NativeSyntheticEvent,
7} from "react-native";
38import { useSafeAreaInsets } from "react-native-safe-area-context";
49import { MaterialNewThreadButton } from "../../components/MaterialNewThreadButton";
510import type { AndroidHomeFabLayout as SharedAndroidHomeFabLayout } from "./AndroidHomeFab.shared";
611import { useWorkspaceState } from "../../state/workspace";
712import { MaterialFabScrollContext } from "./MaterialFabScrollContext";
813import { updateMaterialFabScroll } from "./material-fab-scroll";
14import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
915
1016export function AndroidHomeFabLayout(props: ComponentProps<typeof SharedAndroidHomeFabLayout>) {
1117 const insets = useSafeAreaInsets();
18 const { appearance } = useAppearancePreferences();
19 const { fontScale } = useWindowDimensions();
20 const [layoutWidth, setLayoutWidth] = useState<number | null>(null);
1221 const { state } = useWorkspaceState();
1322 const [expanded, setExpanded] = useState(true);
1423 const scrollState = useRef({ anchor: 0, expanded: true });
......@@ -23,11 +32,13 @@ export function AndroidHomeFabLayout(props: ComponentProps<typeof SharedAndroidH
2332 scrollState.current = next;
2433 }, []);
2534
35 // Remount only the FAB when its font or pane changes to clear the cached expanded width.
2636 return (
27 <View className="flex-1">
37 <View className="flex-1" onLayout={(event) => setLayoutWidth(event.nativeEvent.layout.width)}>
2838 <MaterialFabScrollContext value={onScroll}>{props.children}</MaterialFabScrollContext>
29 {state.hasConnections ? (
39 {state.hasConnections && layoutWidth !== null ? (
3040 <MaterialNewThreadButton
41 key={`${appearance.baseFontSize}:${fontScale}:${layoutWidth}`}
3142 extended
3243 expanded={expanded}
3344 onPress={props.onStartNewTask}
apps/mobile/src/features/home/HomeScreen.tsx+3-1
......@@ -1,3 +1,4 @@
1import { useAndroidControlSizing } from "../../components/useAndroidControlSizing";
12import type { ThreadMoveDestination } from "../threads/threadOrder";
23import { computeThreadMoveAvailability } from "../threads/threadOrder";
34import { LegendList } from "@legendapp/list/react-native";
......@@ -214,6 +215,7 @@ export function HomeScreen(props: HomeScreenProps) {
214215 const queuedThreadKeys = useQueuedThreadKeys();
215216 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
216217 const insets = useSafeAreaInsets();
218 const { fabClearance } = useAndroidControlSizing();
217219 const iosBottomToolbarClearance =
218220 Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED
219221 ? PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT
......@@ -983,7 +985,7 @@ export function HomeScreen(props: HomeScreenProps) {
983985 paddingBottom:
984986 Platform.OS === "ios"
985987 ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance
986 : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? 148 : 88),
988 : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? fabClearance : 88),
987989 }}
988990 />
989991 </SwipeableScrollGateProvider>
apps/mobile/src/features/home/MaterialThreadListToolbar.tsx+8-8
......@@ -12,7 +12,8 @@ import { MaterialSearchField } from "../../components/MaterialSearchField";
1212import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
1313import { WorkspaceConnectionTitle } from "./WorkspaceConnectionTitle";
1414import { useWorkspaceState } from "../../state/workspace";
15import { useMaterialToolbarHeight } from "../../components/useMaterialToolbarHeight";
15import { useAndroidControlSizing } from "../../components/useAndroidControlSizing";
16import { useMaterialToolbarLayout } from "../../components/useMaterialToolbarLayout";
1617
1718/** One toolbar height for the compact list and expanded sidebar, including search. */
1819export function MaterialThreadListToolbar(props: {
......@@ -28,7 +29,8 @@ export function MaterialThreadListToolbar(props: {
2829 readonly onRequestVisibility?: () => void;
2930}) {
3031 const insets = useSafeAreaInsets();
31 const toolbarHeight = useMaterialToolbarHeight();
32 const { fabSize } = useAndroidControlSizing();
33 const { height: toolbarHeight, ...headerPadding } = useMaterialToolbarLayout();
3234 const { state } = useWorkspaceState();
3335 const { onRequestVisibility, onSearchQueryChange } = props;
3436 const searchRef = useRef<TextInput>(null);
......@@ -76,11 +78,9 @@ export function MaterialThreadListToolbar(props: {
7678 <View
7779 onLayout={props.onLayout}
7880 className={
79 props.sidebar
80 ? "absolute inset-x-0 top-0 z-[4] bg-header px-2 pb-2"
81 : "bg-header px-2 pb-2"
81 props.sidebar ? "absolute inset-x-0 top-0 z-[4] bg-header px-2" : "bg-header px-2"
8282 }
83 style={{ paddingTop: Math.max(insets.top, 12) }}
83 style={headerPadding}
8484 >
8585 <View className="flex-row items-center gap-1" style={{ minHeight: toolbarHeight }}>
8686 {searching ? (
......@@ -116,14 +116,14 @@ export function MaterialThreadListToolbar(props: {
116116 )}
117117 </View>
118118 </View>
119 {/* Sit 8dp above the 56dp extended New thread FAB. */}
119 {/* Keep the filter above the New thread FAB at every text size. */}
120120 {state.hasConnections ? (
121121 <View
122122 className="absolute right-5 z-[5]"
123123 style={{
124124 bottom:
125125 (props.sidebar ? Math.max(insets.bottom, 12) + 6 : Math.max(insets.bottom, 16) + 16) +
126 56 +
126 fabSize +
127127 8,
128128 }}
129129 >
apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx+14-10
......@@ -1,9 +1,10 @@
11import type { NativeStackNavigationOptions } from "@react-navigation/native-stack";
22import { useEffect, useRef, useState, type ReactNode } from "react";
3import { ActivityIndicator, Animated, Pressable, View } from "react-native";
3import { ActivityIndicator, Animated, Platform, Pressable, View } from "react-native";
44
55import { SymbolView } from "../../components/AppSymbol";
66import { AppText as Text } from "../../components/AppText";
7import { useAndroidControlSizing } from "../../components/useAndroidControlSizing";
78import {
89 brandTitleOffset,
910 CompactBrandTitle,
......@@ -105,6 +106,7 @@ export function WorkspaceConnectionTitle(props: {
105106}) {
106107 const status = useDelayedConnectionStatus();
107108 const size = props.size ?? "navbar";
109 const { scale } = useAndroidControlSizing();
108110
109111 if (status === null) {
110112 return props.grow ? (
......@@ -126,26 +128,28 @@ export function WorkspaceConnectionTitle(props: {
126128 hitSlop={8}
127129 onPress={props.onPress}
128130 className="flex-row items-center gap-2"
129 style={{ flexShrink: 1, marginLeft: props.statusOffset ?? 0 }}
131 style={[
132 { flexShrink: 1, marginLeft: props.statusOffset ?? 0 },
133 Platform.OS === "android" && { gap: 7 * scale },
134 ]}
130135 >
131136 {status.showsProgress ? (
132 <ActivityIndicator colorClassName={"accent-icon-muted"} size="small" />
137 <ActivityIndicator
138 colorClassName={"accent-icon-muted"}
139 size={Platform.OS === "android" ? Math.round(20 * scale) : "small"}
140 />
133141 ) : (
134142 <SymbolView
135143 name="wifi.slash"
136 size={size === "pageTitle" ? 17 : 15}
144 size={Math.round((size === "pageTitle" ? 17 : 15) * scale)}
137145 tintColorClassName={"accent-icon-muted"}
138146 type="monochrome"
139147 />
140148 )}
141149 <Text
142 className={
143 size === "pageTitle"
144 ? "text-[20px] font-t3-bold text-foreground-muted"
145 : "text-[16px] font-t3-bold text-foreground-muted"
146 }
150 className="font-t3-bold text-foreground-muted"
147151 numberOfLines={1}
148 style={{ flexShrink: 1 }}
152 style={{ flexShrink: 1, fontSize: (size === "pageTitle" ? 20 : 16) * scale }}
149153 >
150154 {status.label}
151155 </Text>
apps/mobile/src/features/settings/appearance/useScaledTextRole.ts+5-16
......@@ -1,10 +1,6 @@
11import { useMemo } from "react";
22
3import {
4 DEFAULT_BASE_FONT_SIZE,
5 normalizeBaseFontSize,
6 scaledTypographyLineHeight,
7} from "../../../lib/appearancePreferences";
3import { resolveScaledTextRole } from "../../../lib/appearancePreferences";
84import { MOBILE_TYPOGRAPHY } from "../../../lib/typography";
95import { useAppearancePreferences } from "./AppearancePreferencesProvider";
106
......@@ -20,15 +16,8 @@ export interface ScaledTextRole {
2016 */
2117export function useScaledTextRole(role: keyof typeof MOBILE_TYPOGRAPHY): ScaledTextRole {
2218 const { appearance } = useAppearancePreferences();
23 return useMemo(() => {
24 const baseFontSize = normalizeBaseFontSize(appearance.baseFontSize);
25 const typography = MOBILE_TYPOGRAPHY[role];
26 return {
27 fontSize: Math.max(
28 8,
29 Math.round(typography.fontSize * (baseFontSize / DEFAULT_BASE_FONT_SIZE)),
30 ),
31 lineHeight: scaledTypographyLineHeight(typography, baseFontSize),
32 };
33 }, [appearance.baseFontSize, role]);
19 return useMemo(
20 () => resolveScaledTextRole(role, appearance.baseFontSize),
21 [appearance.baseFontSize, role],
22 );
3423}
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+24-13
......@@ -132,30 +132,40 @@ import { fileRoutePathSegments } from "../files/filePath";
132132function NewTaskWorkspaceIcon(props: {
133133 readonly workspaceMode: "local" | "worktree";
134134 readonly worktreePath: string | null;
135 readonly size: number;
135136}) {
136137 if (props.workspaceMode === "local" && props.worktreePath === null) {
137138 return (
138139 <SymbolView
139140 name="folder"
140 size={16}
141 size={props.size}
141142 tintColorClassName="accent-icon-muted"
142143 type="monochrome"
143144 />
144145 );
145146 }
146147
148 const boxSize = (14 * props.size) / 16;
147149 return (
148 <View className="size-4">
150 <View
151 className="size-4"
152 style={Platform.OS === "android" ? { width: boxSize, height: boxSize } : undefined}
153 >
149154 <SymbolView
150155 name="folder"
151 size={16}
156 size={props.size}
152157 tintColorClassName="accent-icon-muted"
153158 type="monochrome"
154159 />
155 <View className="absolute -right-1 -bottom-1">
160 <View
161 className="absolute -right-1 -bottom-1"
162 style={
163 Platform.OS === "android" ? { right: -boxSize / 4, bottom: -boxSize / 4 } : undefined
164 }
165 >
156166 <SymbolView
157167 name="arrow.triangle.branch"
158 size={9}
168 size={Math.round((9 * props.size) / 16)}
159169 tintColorClassName="accent-icon-muted"
160170 type="monochrome"
161171 />
......@@ -1476,13 +1486,13 @@ export function NewTaskDraftScreen(props: {
14761486 accessibilityLabel={`Environment: ${selectedEnvironmentLabel}`}
14771487 chevronDirection="right"
14781488 disabled={isComposerInteractionLocked || voiceInput.isBusy}
1479 iconNode={
1489 renderIcon={(size) => (
14801490 <EnvironmentMachineSymbol
14811491 kind={resolveEnvironmentMachineKind(selectedEnvironmentServerConfig)}
1482 size={16}
1492 size={size}
14831493 tintColorClassName="accent-icon-muted"
14841494 />
1485 }
1495 )}
14861496 label={`on ${selectedEnvironmentLabel}`}
14871497 maxWidth={260}
14881498 onPress={
......@@ -1517,12 +1527,13 @@ export function NewTaskDraftScreen(props: {
15171527 accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`}
15181528 accessibilityLabel={workspaceLabel}
15191529 disabled={isComposerInteractionLocked || voiceInput.isBusy}
1520 iconNode={
1530 renderIcon={(size) => (
15211531 <NewTaskWorkspaceIcon
15221532 workspaceMode={flow.workspaceMode}
15231533 worktreePath={flow.selectedWorktreePath}
1534 size={size}
15241535 />
1525 }
1536 )}
15261537 label={workspaceLabel}
15271538 maxWidth={flow.workspaceMode === "local" ? 220 : 148}
15281539 onPress={() => flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")}
......@@ -1681,12 +1692,12 @@ export function NewTaskDraftScreen(props: {
16811692 accessibilityLabel="Model and reasoning settings"
16821693 disabled={isComposerInteractionLocked}
16831694 emphasized
1684 iconNode={
1695 renderIcon={(size) => (
16851696 <ProviderIcon
16861697 provider={flow.selectedModelOption?.providerDriver}
1687 size={16}
1698 size={size}
16881699 />
1689 }
1700 )}
16901701 label={flow.selectedModelOption?.label ?? "Choose model"}
16911702 maxWidth="100%"
16921703 onPress={settingsSheetPresentation.open}
apps/mobile/src/features/threads/ThreadComposer.tsx+3-3
......@@ -961,9 +961,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
961961 <ComposerInlineControl
962962 accessibilityLabel="Model and reasoning settings"
963963 emphasized
964 iconNode={
965 <ProviderIcon provider={currentModelOption?.providerDriver} size={16} />
966 }
964 renderIcon={(size) => (
965 <ProviderIcon provider={currentModelOption?.providerDriver} size={size} />
966 )}
967967 label={currentModelOption?.label ?? currentModelSelection.model}
968968 maxWidth="100%"
969969 onPress={openSettings}
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+6-4
......@@ -1,3 +1,4 @@
1import { useAndroidControlSizing } from "../../components/useAndroidControlSizing";
12import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
23import { computeThreadMoveAvailability } from "./threadOrder";
34import type {
......@@ -50,7 +51,7 @@ import {
5051} from "../home/WorkspaceConnectionTitle";
5152import { SidebarHeaderActions } from "./sidebar-header-actions";
5253import { MaterialThreadListToolbar } from "../home/MaterialThreadListToolbar";
53import { useMaterialToolbarHeight } from "../../components/useMaterialToolbarHeight";
54import { useMaterialToolbarLayout } from "../../components/useMaterialToolbarLayout";
5455import { useMaterialFabScroll } from "../home/MaterialFabScrollContext";
5556import { SidebarFilterButton } from "./sidebar-filter-button";
5657import { createSidebarHeaderItems } from "./sidebar-native-header-items";
......@@ -133,6 +134,7 @@ function ThreadNavigationSidebarPane(
133134 const drawerColor = materialTheme["--color-drawer"];
134135
135136 const insets = useSafeAreaInsets();
137 const { fabClearance } = useAndroidControlSizing();
136138 const projects = useProjects();
137139 const threads = useThreadShells();
138140 const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState();
......@@ -603,14 +605,14 @@ function ThreadNavigationSidebarPane(
603605 );
604606
605607 const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState<number | null>(null);
606 const materialToolbarHeight = useMaterialToolbarHeight();
608 const { height, paddingTop, paddingBottom } = useMaterialToolbarLayout();
607609 // The sticky header (title row, search field, optional connection status)
608610 // is measured so the list inset always matches its real height — no
609611 // hardcoded per-variant constants.
610612 const stickyHeaderHeight =
611613 measuredHeaderHeight ??
612614 (Platform.OS === "android"
613 ? Math.max(insets.top, 12) + materialToolbarHeight + 8
615 ? paddingTop + height + paddingBottom
614616 : insets.top + SIDEBAR_STICKY_HEADER_HEIGHT);
615617 const topListInset = stickyHeaderHeight + 6;
616618 const handleStickyHeaderLayout = useCallback((event: LayoutChangeEvent) => {
......@@ -1039,7 +1041,7 @@ function ThreadNavigationSidebarPane(
10391041 {
10401042 paddingBottom:
10411043 Platform.OS === "android"
1042 ? Math.max(insets.bottom, 16) + 148 - insets.bottom
1044 ? Math.max(insets.bottom, 16) + fabClearance - insets.bottom
10431045 : 16 + insets.bottom,
10441046 paddingTop: Platform.OS === "android" ? 6 : topListInset,
10451047 },
apps/mobile/src/lib/androidControlSizing.test.ts created+22
......@@ -0,0 +1,22 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { resolveAndroidControlSizing } from "./androidControlSizing";
4
5describe("Android control sizing", () => {
6 it.each([
7 [11, 17, 48, 48, 172, 48],
8 [16, 24, 48, 56, 250, 48],
9 [22, 33, 66, 77, 344, 66],
10 ])(
11 "scales controls at %ipt",
12 (fontSize, iconSize, buttonSize, fabSize, menuWidth, menuItemHeight) => {
13 expect(resolveAndroidControlSizing(fontSize)).toMatchObject({
14 iconSize,
15 buttonSize,
16 fabSize,
17 menuWidth,
18 menuItemHeight,
19 });
20 },
21 );
22});
apps/mobile/src/lib/androidControlSizing.ts created+23
......@@ -0,0 +1,23 @@
1import { DEFAULT_BASE_FONT_SIZE, normalizeBaseFontSize } from "./appearancePreferences";
2
3/** Android controls follow the app's text size; buttons and menu rows retain a 48dp touch target. */
4export function resolveAndroidControlSizing(baseFontSize: number) {
5 const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE;
6 const iconSize = Math.round(24 * scale);
7 const buttonSize = Math.max(48, Math.round(48 * scale));
8 const fabSize = Math.max(48, Math.round(56 * scale));
9
10 return {
11 scale,
12 iconSize,
13 smallIconSize: Math.round(16 * scale),
14 mediumIconSize: Math.round(18 * scale),
15 buttonSize,
16 fabSize,
17 largeFabSize: Math.round(96 * scale),
18 menuWidth: Math.round(250 * scale),
19 menuItemHeight: Math.max(48, Math.round(48 * scale)),
20 // Two floating actions, their gap, and the space below the lower action.
21 fabClearance: fabSize * 2 + 36,
22 };
23}
apps/mobile/src/lib/appearancePreferences.ts+10
......@@ -219,6 +219,16 @@ export function scaledTypographyLineHeight(
219219 return Math.max(10, Math.round(role.lineHeight * scale));
220220}
221221
222/** Text dimensions shared by React Native and Compose consumers of an appearance role. */
223export function resolveScaledTextRole(role: keyof typeof MOBILE_TYPOGRAPHY, baseFontSize: number) {
224 const typography = MOBILE_TYPOGRAPHY[role];
225 const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE;
226 return {
227 fontSize: Math.max(8, Math.round(typography.fontSize * scale)),
228 lineHeight: scaledTypographyLineHeight(typography, baseFontSize),
229 };
230}
231
222232export function resolveNativeMarkdownTypography(baseFontSize: number): NativeMarkdownTypography {
223233 const fontSizes = resolveMarkdownFontSizes(baseFontSize);
224234 return {