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";...@@ -7,9 +7,9 @@ import { useKeyboardState } from "react-native-keyboard-controller";
7import Animated, { FadeIn } from "react-native-reanimated";7import Animated, { FadeIn } from "react-native-reanimated";
88
9import { OverlayPortal } from "./OverlayPortal";9import { OverlayPortal } from "./OverlayPortal";
10import { useAndroidControlSizing } from "./useAndroidControlSizing";
10import { MaterialMenuPopup } from "./MaterialMenuPopup";11import { MaterialMenuPopup } from "./MaterialMenuPopup";
1112
12const MENU_WIDTH = 250;
13const SCREEN_MARGIN = 12;13const SCREEN_MARGIN = 12;
14const ANCHOR_GAP = 6;14const ANCHOR_GAP = 6;
1515
...@@ -55,6 +55,7 @@ export type AndroidAnchoredMenuProps = {...@@ -55,6 +55,7 @@ export type AndroidAnchoredMenuProps = {
55 * menus use the native popup for placement, animation and dismissal.55 * menus use the native popup for placement, animation and dismissal.
56 */56 */
57export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {57export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
58 const { scale, menuWidth: desiredMenuWidth } = useAndroidControlSizing();
58 const [anchor, setAnchor] = useState<AnchorSnapshot | null>(null);59 const [anchor, setAnchor] = useState<AnchorSnapshot | null>(null);
59 const [path, setPath] = useState<readonly MenuAction[]>([]);60 const [path, setPath] = useState<readonly MenuAction[]>([]);
60 // Height of the modal's root view, in the modal's own coordinate space.61 // Height of the modal's root view, in the modal's own coordinate space.
...@@ -68,6 +69,10 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {...@@ -68,6 +69,10 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
68 // are converted into this frame, so the menu lands correctly no matter69 // are converted into this frame, so the menu lands correctly no matter
69 // where the portal host sits (status bar, keyboard resize, etc.).70 // where the portal host sits (status bar, keyboard resize, etc.).
70 const [overlay, setOverlay] = useState<OverlayFrame | null>(null);71 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));
71 const anchorRef = useRef<View>(null);76 const anchorRef = useRef<View>(null);
72 const overlayRef = useRef<View>(null);77 const overlayRef = useRef<View>(null);
7378
...@@ -131,14 +136,11 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {...@@ -131,14 +136,11 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
131 ? 0136 ? 0
132 : local.x + local.width / 2 <= overlay.width / 2137 : local.x + local.width / 2 <= overlay.width / 2
133 ? local.x138 ? local.x
134 : local.x + local.width - MENU_WIDTH;139 : local.x + local.width - menuWidth;
135 const left =140 const left =
136 overlay === null141 overlay === null
137 ? 0142 ? 0
138 : Math.min(143 : Math.min(Math.max(preferredLeft, SCREEN_MARGIN), overlay.width - menuWidth - SCREEN_MARGIN);
139 Math.max(preferredLeft, SCREEN_MARGIN),
140 overlay.width - MENU_WIDTH - SCREEN_MARGIN,
141 );
142 // The keyboard stays up while the menu is open (in-window overlay, no144 // The keyboard stays up while the menu is open (in-window overlay, no
143 // focus change), so the space it covers is not usable — without this the145 // focus change), so the space it covers is not usable — without this the
144 // composer-pill menus "open down" into the IME and can't be tapped.146 // composer-pill menus "open down" into the IME and can't be tapped.
...@@ -201,6 +203,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {...@@ -201,6 +203,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
201 {!placeable || local === null ? null : !anchor.keyboardWasVisible ? (203 {!placeable || local === null ? null : !anchor.keyboardWasVisible ? (
202 <MaterialMenuPopup204 <MaterialMenuPopup
203 anchor={local}205 anchor={local}
206 menuWidth={menuWidth}
204 actions={levelActions}207 actions={levelActions}
205 title={props.title}208 title={props.title}
206 parent={parent}209 parent={parent}
...@@ -211,10 +214,12 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {...@@ -211,10 +214,12 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
211 ) : (214 ) : (
212 <Animated.View215 <Animated.View
213 entering={FadeIn.duration(120)}216 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"
215 style={{218 style={{
216 left,219 left,
217 maxHeight,220 maxHeight,
221 width: menuWidth,
222 borderRadius: 4 * scale,
218 ...(opensDown223 ...(opensDown
219 ? { top: local.y + local.height + ANCHOR_GAP }224 ? { top: local.y + local.height + ANCHOR_GAP }
220 : { bottom: (rootHeight ?? 0) - local.y + ANCHOR_GAP }),225 : { bottom: (rootHeight ?? 0) - local.y + ANCHOR_GAP }),
...@@ -227,7 +232,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {...@@ -227,7 +232,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
227 active editor; the first item tap must act, not just232 active editor; the first item tap must act, not just
228 dismiss the keyboard. */}233 dismiss the keyboard. */}
229 <ScrollView234 <ScrollView
230 contentContainerClassName="py-2"235 contentContainerStyle={{ paddingVertical: 7 * scale }}
231 bounces={false}236 bounces={false}
232 keyboardShouldPersistTaps="always"237 keyboardShouldPersistTaps="always"
233 showsVerticalScrollIndicator={false}238 showsVerticalScrollIndicator={false}
...@@ -235,6 +240,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {...@@ -235,6 +240,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
235 <MaterialMenuPopup240 <MaterialMenuPopup
236 inline241 inline
237 anchor={local}242 anchor={local}
243 menuWidth={menuWidth}
238 actions={levelActions}244 actions={levelActions}
239 title={props.title}245 title={props.title}
240 parent={parent}246 parent={parent}
apps/mobile/src/components/AndroidScreenHeader.tsx+7-10
...@@ -1,6 +1,5 @@...@@ -1,6 +1,5 @@
1import { useState, type ReactNode } from "react";1import { useState, type ReactNode } from "react";
2import { View } from "react-native";2import { View } from "react-native";
3import { useSafeAreaInsets } from "react-native-safe-area-context";
43
5import type { AppSymbolName } from "./AppSymbol";4import type { AppSymbolName } from "./AppSymbol";
6import { AppText as Text } from "./AppText";5import { AppText as Text } from "./AppText";
...@@ -8,7 +7,7 @@ import { cn } from "../lib/cn";...@@ -8,7 +7,7 @@ import { cn } from "../lib/cn";
8import { MaterialIconButton } from "./MaterialIconButton";7import { MaterialIconButton } from "./MaterialIconButton";
9import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu";8import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu";
10import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";9import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
11import { useMaterialToolbarHeight } from "./useMaterialToolbarHeight";10import { useMaterialToolbarLayout } from "./useMaterialToolbarLayout";
1211
13export interface AndroidHeaderAction {12export interface AndroidHeaderAction {
14 readonly accessibilityLabel: string;13 readonly accessibilityLabel: string;
...@@ -38,10 +37,11 @@ export function AndroidScreenHeader(props: {...@@ -38,10 +37,11 @@ export function AndroidScreenHeader(props: {
38 readonly embedded?: boolean;37 readonly embedded?: boolean;
39 readonly hideBottomBorder?: boolean;38 readonly hideBottomBorder?: boolean;
40}) {39}) {
41 const insets = useSafeAreaInsets();
42 const titleTypography = useScaledTextRole("title");40 const titleTypography = useScaledTextRole("title");
43 const subtitleTypography = useScaledTextRole("label");41 const subtitleTypography = useScaledTextRole("label");
44 const materialToolbarHeight = useMaterialToolbarHeight();42 const { height: materialToolbarHeight, ...headerPadding } = useMaterialToolbarLayout(
43 props.embedded,
44 );
45 const [headerWidth, setHeaderWidth] = useState(0);45 const [headerWidth, setHeaderWidth] = useState(0);
46 const actions = props.actions ?? [];46 const actions = props.actions ?? [];
47 const directCount = actions.length > 2 ? (headerWidth >= 600 ? 3 : 1) : actions.length;47 const directCount = actions.length > 2 ? (headerWidth >= 600 ? 3 : 1) : actions.length;
...@@ -51,16 +51,13 @@ export function AndroidScreenHeader(props: {...@@ -51,16 +51,13 @@ export function AndroidScreenHeader(props: {
51 return (51 return (
52 <View52 <View
53 onLayout={(event) => setHeaderWidth(event.nativeEvent.layout.width)}53 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"
55 style={{55 style={{
56 paddingTop: props.embedded ? 8 : Math.max(insets.top, 12),56 ...headerPadding,
57 borderBottomWidth: props.hideBottomBorder ? 0 : undefined,57 borderBottomWidth: props.hideBottomBorder ? 0 : undefined,
58 }}58 }}
59 >59 >
60 <View60 <View style={{ minHeight: materialToolbarHeight }} className="flex-row items-center gap-1">
61 style={{ minHeight: materialToolbarHeight }}
62 className="min-h-14 flex-row items-center gap-1"
63 >
64 {props.onBack ? (61 {props.onBack ? (
65 <MaterialIconButton62 <MaterialIconButton
66 accessibilityLabel="Navigate up"63 accessibilityLabel="Navigate up"
apps/mobile/src/components/CompactBrandTitle.tsx+16-5
...@@ -6,6 +6,7 @@ import { AppText as Text } from "./AppText";...@@ -6,6 +6,7 @@ import { AppText as Text } from "./AppText";
6import { T3Wordmark } from "./T3Wordmark";6import { T3Wordmark } from "./T3Wordmark";
7import { IPAD_HOME_TITLE_OFFSET } from "../lib/layoutMetrics";7import { IPAD_HOME_TITLE_OFFSET } from "../lib/layoutMetrics";
8import { resolveMobileStageLabel } from "../lib/mobileBranding";8import { resolveMobileStageLabel } from "../lib/mobileBranding";
9import { useAndroidControlSizing } from "./useAndroidControlSizing";
910
10/**11/**
11 * Horizontal correction applied to content rendered in the brand title slot,12 * Horizontal correction applied to content rendered in the brand title slot,
...@@ -26,6 +27,7 @@ export function CompactBrandTitle(...@@ -26,6 +27,7 @@ export function CompactBrandTitle(
26) {27) {
27 const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant);28 const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant);
28 const titleOffset = brandTitleOffset();29 const titleOffset = brandTitleOffset();
30 const { scale } = useAndroidControlSizing();
2931
30 return (32 return (
31 <View33 <View
...@@ -34,19 +36,28 @@ export function CompactBrandTitle(...@@ -34,19 +36,28 @@ export function CompactBrandTitle(
34 accessible36 accessible
35 role="heading"37 role="heading"
36 className="flex-row items-center gap-1.5"38 className="flex-row items-center gap-1.5"
37 style={{ marginLeft: titleOffset }}39 style={[{ marginLeft: titleOffset }, Platform.OS === "android" && { gap: 5.25 * scale }]}
38 >40 >
39 <T3Wordmark colorClassName="accent-icon" height={15} />41 <T3Wordmark colorClassName="accent-icon" height={Math.round(15 * scale)} />
40 <Text42 <Text
41 allowFontScaling={props.allowFontScaling}43 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 }}
43 >46 >
44 Code47 Code
45 </Text>48 </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 >
47 <Text57 <Text
48 allowFontScaling={props.allowFontScaling}58 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 }}
50 >61 >
51 {stageLabel}62 {stageLabel}
52 </Text>63 </Text>
apps/mobile/src/components/ComposerAttachmentButton.tsx+3-1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import type { MenuAction } from "@react-native-menu/menu";1import type { MenuAction } from "@react-native-menu/menu";
2import { Pressable } from "react-native";2import { Pressable } from "react-native";
33
4import { useAndroidControlSizing } from "./useAndroidControlSizing";
4import { SymbolView } from "./AppSymbol";5import { SymbolView } from "./AppSymbol";
5import { ControlPillMenu } from "./ControlPill";6import { ControlPillMenu } from "./ControlPill";
67
...@@ -15,6 +16,7 @@ export function ComposerAttachmentButton(props: {...@@ -15,6 +16,7 @@ export function ComposerAttachmentButton(props: {
15 readonly onPickMedia: () => Promise<void>;16 readonly onPickMedia: () => Promise<void>;
16 readonly onPickFiles: () => Promise<void>;17 readonly onPickFiles: () => Promise<void>;
17}) {18}) {
19 const { scale } = useAndroidControlSizing();
18 const button = (20 const button = (
19 <Pressable21 <Pressable
20 accessibilityLabel="Add attachment"22 accessibilityLabel="Add attachment"
...@@ -26,7 +28,7 @@ export function ComposerAttachmentButton(props: {...@@ -26,7 +28,7 @@ export function ComposerAttachmentButton(props: {
26 >28 >
27 <SymbolView29 <SymbolView
28 name="plus"30 name="plus"
29 size={20}31 size={Math.round(20 * scale)}
30 weight="regular"32 weight="regular"
31 tintColorClassName="accent-icon"33 tintColorClassName="accent-icon"
32 type="monochrome"34 type="monochrome"
apps/mobile/src/components/ComposerToolbar.tsx+18-7
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import type { ComponentProps, ReactNode } from "react";1import type { ComponentProps, ReactNode } from "react";
2import { useCallback, useMemo, useState } from "react";2import { useCallback, useMemo, useState } from "react";
3import {3import {
4 Platform,
4 Pressable,5 Pressable,
5 ScrollView,6 ScrollView,
6 View,7 View,
...@@ -14,6 +15,7 @@ import {...@@ -14,6 +15,7 @@ import {
14import { cn } from "../lib/cn";15import { cn } from "../lib/cn";
15import { AppText as Text } from "./AppText";16import { AppText as Text } from "./AppText";
16import { SymbolView } from "./AppSymbol";17import { SymbolView } from "./AppSymbol";
18import { useAndroidControlSizing } from "./useAndroidControlSizing";
1719
18const COMPOSER_TOOLBAR_GAP = 8;20const COMPOSER_TOOLBAR_GAP = 8;
19const COMPOSER_TOOLBAR_FADE_WIDTH = 18;21const COMPOSER_TOOLBAR_FADE_WIDTH = 18;
...@@ -30,7 +32,7 @@ export function ComposerInlineControl(props: {...@@ -30,7 +32,7 @@ export function ComposerInlineControl(props: {
30 readonly disabled?: boolean;32 readonly disabled?: boolean;
31 readonly emphasized?: boolean;33 readonly emphasized?: boolean;
32 readonly icon?: ComponentProps<typeof SymbolView>["name"];34 readonly icon?: ComponentProps<typeof SymbolView>["name"];
33 readonly iconNode?: ReactNode;35 readonly renderIcon?: (size: number) => ReactNode;
34 readonly label: string;36 readonly label: string;
35 readonly maxWidth?: ViewStyle["maxWidth"];37 readonly maxWidth?: ViewStyle["maxWidth"];
36 readonly onPress?: () => void;38 readonly onPress?: () => void;
...@@ -39,6 +41,7 @@ export function ComposerInlineControl(props: {...@@ -39,6 +41,7 @@ export function ComposerInlineControl(props: {
39 readonly chevronDirection?: "down" | "right";41 readonly chevronDirection?: "down" | "right";
40 readonly showChevron?: boolean;42 readonly showChevron?: boolean;
41}) {43}) {
44 const { scale, smallIconSize } = useAndroidControlSizing();
42 return (45 return (
43 <Pressable46 <Pressable
44 accessibilityLabel={props.accessibilityLabel ?? props.label}47 accessibilityLabel={props.accessibilityLabel ?? props.label}
...@@ -52,12 +55,17 @@ export function ComposerInlineControl(props: {...@@ -52,12 +55,17 @@ export function ComposerInlineControl(props: {
52 onPress={props.onPress}55 onPress={props.onPress}
53 style={{ maxWidth: props.maxWidth ?? 190, opacity: props.disabled ? 0.45 : 1 }}56 style={{ maxWidth: props.maxWidth ?? 190, opacity: props.disabled ? 0.45 : 1 }}
54 >57 >
55 {props.iconNode ? (58 {props.renderIcon ? (
56 <View className="size-4 shrink-0 items-center justify-center">{props.iconNode}</View>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>
57 ) : props.icon ? (65 ) : props.icon ? (
58 <SymbolView66 <SymbolView
59 name={props.icon}67 name={props.icon}
60 size={16}68 size={smallIconSize}
61 tintColorClassName={69 tintColorClassName={
62 props.emphasized || props.selected ? "accent-icon" : "accent-icon-muted"70 props.emphasized || props.selected ? "accent-icon" : "accent-icon-muted"
63 }71 }
...@@ -76,7 +84,7 @@ export function ComposerInlineControl(props: {...@@ -76,7 +84,7 @@ export function ComposerInlineControl(props: {
76 {props.showChevron === false ? null : (84 {props.showChevron === false ? null : (
77 <SymbolView85 <SymbolView
78 name={props.chevronDirection === "right" ? "chevron.right" : "chevron.down"}86 name={props.chevronDirection === "right" ? "chevron.right" : "chevron.down"}
79 size={10}87 size={Math.round(10 * scale)}
80 tintColorClassName={88 tintColorClassName={
81 props.emphasized || props.selected ? "accent-icon" : "accent-icon-muted"89 props.emphasized || props.selected ? "accent-icon" : "accent-icon-muted"
82 }90 }
...@@ -224,6 +232,8 @@ export function ComposerActionButton(props: {...@@ -224,6 +232,8 @@ export function ComposerActionButton(props: {
224 readonly onPress: () => void;232 readonly onPress: () => void;
225 readonly variant?: "primary" | "danger";233 readonly variant?: "primary" | "danger";
226}) {234}) {
235 const { scale, smallIconSize } = useAndroidControlSizing();
236 const circleSize = Math.round(30 * scale);
227 return (237 return (
228 <Pressable238 <Pressable
229 accessibilityLabel={props.accessibilityLabel}239 accessibilityLabel={props.accessibilityLabel}
...@@ -234,8 +244,9 @@ export function ComposerActionButton(props: {...@@ -234,8 +244,9 @@ export function ComposerActionButton(props: {
234 onPress={props.onPress}244 onPress={props.onPress}
235 >245 >
236 <View246 <View
247 style={{ width: circleSize, height: circleSize }}
237 className={cn(248 className={cn(
238 "size-[30px] items-center justify-center rounded-full",249 "items-center justify-center rounded-full",
239 props.variant === "danger"250 props.variant === "danger"
240 ? "bg-danger"251 ? "bg-danger"
241 : props.disabled252 : props.disabled
...@@ -245,7 +256,7 @@ export function ComposerActionButton(props: {...@@ -245,7 +256,7 @@ export function ComposerActionButton(props: {
245 >256 >
246 <SymbolView257 <SymbolView
247 name={props.icon}258 name={props.icon}
248 size={16}259 size={smallIconSize}
249 weight="semibold"260 weight="semibold"
250 tintColorClassName={261 tintColorClassName={
251 props.variant === "danger" ? "accent-danger-foreground" : "accent-primary-foreground"262 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";...@@ -5,6 +5,7 @@ import { SymbolView } from "./AppSymbol";
5import { AppText as Text } from "./AppText";5import { AppText as Text } from "./AppText";
6import { MaterialIconButton } from "./MaterialIconButton";6import { MaterialIconButton } from "./MaterialIconButton";
7import { MaterialButton } from "./MaterialButton";7import { MaterialButton } from "./MaterialButton";
8import { useAndroidControlSizing } from "./useAndroidControlSizing";
89
9export { ControlPillMenu } from "./ControlPillMenu";10export { ControlPillMenu } from "./ControlPillMenu";
1011
...@@ -20,6 +21,7 @@ export function ControlPill(props: {...@@ -20,6 +21,7 @@ export function ControlPill(props: {
20 readonly className?: string;21 readonly className?: string;
21}) {22}) {
22 const variant = props.variant ?? "circle";23 const variant = props.variant ?? "circle";
24 const { smallIconSize } = useAndroidControlSizing();
23 const activatedOnPressInRef = useRef(false);25 const activatedOnPressInRef = useRef(false);
2426
25 const handlePressIn = () => {27 const handlePressIn = () => {
...@@ -133,7 +135,7 @@ export function ControlPill(props: {...@@ -133,7 +135,7 @@ export function ControlPill(props: {
133 ) : props.icon ? (135 ) : props.icon ? (
134 <SymbolView136 <SymbolView
135 name={props.icon}137 name={props.icon}
136 size={16}138 size={smallIconSize}
137 tintColorClassName={iconTintClassName}139 tintColorClassName={iconTintClassName}
138 type="monochrome"140 type="monochrome"
139 />141 />
apps/mobile/src/components/MaterialButton.android.tsx+4-2
...@@ -14,10 +14,12 @@ import { View } from "react-native";...@@ -14,10 +14,12 @@ import { View } from "react-native";
14import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";14import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
15import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";15import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
16import type { MaterialButtonProps } from "./MaterialButton";16import type { MaterialButtonProps } from "./MaterialButton";
17import { useAndroidControlSizing } from "./useAndroidControlSizing";
1718
18export function MaterialButton(props: MaterialButtonProps) {19export function MaterialButton(props: MaterialButtonProps) {
19 const { themeAppearance, themeVariables: colors } = useAppearancePreferences();20 const { themeAppearance, themeVariables: colors } = useAppearancePreferences();
20 const typography = useScaledTextRole("footnote");21 const typography = useScaledTextRole("footnote");
22 const { scale, mediumIconSize } = useAndroidControlSizing();
21 const tone = props.tone ?? "secondary";23 const tone = props.tone ?? "secondary";
22 const Component =24 const Component =
23 tone === "text" ? TextButton : tone === "secondary" ? FilledTonalButton : Button;25 tone === "text" ? TextButton : tone === "secondary" ? FilledTonalButton : Button;
...@@ -77,11 +79,11 @@ export function MaterialButton(props: MaterialButtonProps) {...@@ -77,11 +79,11 @@ export function MaterialButton(props: MaterialButtonProps) {
77 {props.loading ? (79 {props.loading ? (
78 <>80 <>
79 <CircularProgressIndicator81 <CircularProgressIndicator
80 modifiers={[size(18, 18)]}82 modifiers={[size(mediumIconSize, mediumIconSize)]}
81 strokeWidth={2}83 strokeWidth={2}
82 color={colors["--color-foreground-muted"]}84 color={colors["--color-foreground-muted"]}
83 />85 />
84 <Box modifiers={[size(8, 1)]} />86 <Box modifiers={[size(8 * scale, 1)]} />
85 </>87 </>
86 ) : null}88 ) : null}
87 <Text style={{ ...typography, fontWeight: "500" }}>{props.label}</Text>89 <Text style={{ ...typography, fontWeight: "500" }}>{props.label}</Text>
apps/mobile/src/components/MaterialFloatingActionButton.android.tsx+10-2
...@@ -6,8 +6,9 @@ import {...@@ -6,8 +6,9 @@ import {
6 LargeFloatingActionButton,6 LargeFloatingActionButton,
7 Text,7 Text,
8} from "@expo/ui/jetpack-compose";8} from "@expo/ui/jetpack-compose";
9import { size } from "@expo/ui/jetpack-compose/modifiers";9import { defaultMinSize, height, size, width } from "@expo/ui/jetpack-compose/modifiers";
10import { View, type StyleProp, type ViewStyle } from "react-native";10import { View, type StyleProp, type ViewStyle } from "react-native";
11import { useAndroidControlSizing } from "./useAndroidControlSizing";
11import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";12import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
12import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";13import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
13import { SymbolView, type AppSymbolName } from "./AppSymbol";14import { SymbolView, type AppSymbolName } from "./AppSymbol";
...@@ -24,6 +25,8 @@ export function MaterialFloatingActionButton(props: {...@@ -24,6 +25,8 @@ export function MaterialFloatingActionButton(props: {
24}) {25}) {
25 const { themeAppearance, themeVariables: colors } = useAppearancePreferences();26 const { themeAppearance, themeVariables: colors } = useAppearancePreferences();
26 const typography = useScaledTextRole("footnote");27 const typography = useScaledTextRole("footnote");
28 const { scale, iconSize: standardIconSize, fabSize, largeFabSize } = useAndroidControlSizing();
29 const buttonSize = props.variant === "large" ? largeFabSize : fabSize;
27 const primary = props.tone === "primary";30 const primary = props.tone === "primary";
28 const containerColor = colors[primary ? "--color-primary" : "--color-secondary"];31 const containerColor = colors[primary ? "--color-primary" : "--color-secondary"];
29 const contentColor =32 const contentColor =
...@@ -34,7 +37,7 @@ export function MaterialFloatingActionButton(props: {...@@ -34,7 +37,7 @@ export function MaterialFloatingActionButton(props: {
34 : props.variant === "large"37 : props.variant === "large"
35 ? LargeFloatingActionButton38 ? LargeFloatingActionButton
36 : FloatingActionButton;39 : FloatingActionButton;
37 const iconSize = props.variant === "large" ? 36 : 24;40 const iconSize = props.variant === "large" ? Math.round(36 * scale) : standardIconSize;
38 return (41 return (
39 <View42 <View
40 accessible43 accessible
...@@ -48,6 +51,11 @@ export function MaterialFloatingActionButton(props: {...@@ -48,6 +51,11 @@ export function MaterialFloatingActionButton(props: {
48 <View importantForAccessibility="no-hide-descendants">51 <View importantForAccessibility="no-hide-descendants">
49 <Host matchContents colorScheme={themeAppearance} ignoreSafeAreaKeyboardInsets>52 <Host matchContents colorScheme={themeAppearance} ignoreSafeAreaKeyboardInsets>
50 <Component53 <Component
54 modifiers={[
55 defaultMinSize({ minWidth: buttonSize }),
56 height(buttonSize),
57 ...(props.variant === "extended" ? [] : [width(buttonSize)]),
58 ]}
51 containerColor={containerColor}59 containerColor={containerColor}
52 onClick={props.onPress}60 onClick={props.onPress}
53 expanded={props.expanded}61 expanded={props.expanded}
apps/mobile/src/components/MaterialIconButton.android.tsx+6-4
...@@ -8,6 +8,7 @@ import { size } from "@expo/ui/jetpack-compose/modifiers";...@@ -8,6 +8,7 @@ import { size } from "@expo/ui/jetpack-compose/modifiers";
8import { View } from "react-native";8import { View } from "react-native";
99
10import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";10import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
11import { useAndroidControlSizing } from "./useAndroidControlSizing";
11import { SymbolView, type AppSymbolName } from "./AppSymbol";12import { SymbolView, type AppSymbolName } from "./AppSymbol";
1213
13export function MaterialIconButton(props: {14export function MaterialIconButton(props: {
...@@ -20,6 +21,7 @@ export function MaterialIconButton(props: {...@@ -20,6 +21,7 @@ export function MaterialIconButton(props: {
20 readonly tintColorClassName?: string;21 readonly tintColorClassName?: string;
21}) {22}) {
22 const { themeAppearance, themeVariables: colors } = useAppearancePreferences();23 const { themeAppearance, themeVariables: colors } = useAppearancePreferences();
24 const { iconSize, buttonSize } = useAndroidControlSizing();
23 const variant = props.variant ?? "standard";25 const variant = props.variant ?? "standard";
24 const Component =26 const Component =
25 variant === "standard"27 variant === "standard"
...@@ -52,18 +54,18 @@ export function MaterialIconButton(props: {...@@ -52,18 +54,18 @@ export function MaterialIconButton(props: {
52 onAccessibilityAction={() => {54 onAccessibilityAction={() => {
53 if (!props.disabled) props.onPress?.();55 if (!props.disabled) props.onPress?.();
54 }}56 }}
55 style={{ width: 48, height: 48 }}57 style={{ width: buttonSize, height: buttonSize }}
56 >58 >
57 <View importantForAccessibility="no-hide-descendants">59 <View importantForAccessibility="no-hide-descendants">
58 <Host60 <Host
59 colorScheme={themeAppearance}61 colorScheme={themeAppearance}
60 ignoreSafeAreaKeyboardInsets62 ignoreSafeAreaKeyboardInsets
61 style={{ width: 48, height: 48 }}63 style={{ width: buttonSize, height: buttonSize }}
62 >64 >
63 <Component65 <Component
64 onClick={props.onPress}66 onClick={props.onPress}
65 enabled={!props.disabled}67 enabled={!props.disabled}
66 modifiers={[size(48, 48)]}68 modifiers={[size(buttonSize, buttonSize)]}
67 colors={69 colors={
68 variant === "standard"70 variant === "standard"
69 ? undefined71 ? undefined
...@@ -78,7 +80,7 @@ export function MaterialIconButton(props: {...@@ -78,7 +80,7 @@ export function MaterialIconButton(props: {
78 <View pointerEvents="none" className="absolute inset-0 items-center justify-center">80 <View pointerEvents="none" className="absolute inset-0 items-center justify-center">
79 <SymbolView81 <SymbolView
80 name={props.icon === "ellipsis" ? { ios: "ellipsis", android: "more_vert" } : props.icon}82 name={props.icon === "ellipsis" ? { ios: "ellipsis", android: "more_vert" } : props.icon}
81 size={24}83 size={iconSize}
82 tintColorClassName={iconTint}84 tintColorClassName={iconTint}
83 type="monochrome"85 type="monochrome"
84 />86 />
apps/mobile/src/components/MaterialListRow.tsx+3-1
...@@ -5,6 +5,7 @@ import { useAppearancePreferences } from "../features/settings/appearance/Appear...@@ -5,6 +5,7 @@ import { useAppearancePreferences } from "../features/settings/appearance/Appear
5import { cn } from "../lib/cn";5import { cn } from "../lib/cn";
6import { AppText } from "./AppText";6import { AppText } from "./AppText";
7import { SymbolView } from "./AppSymbol";7import { SymbolView } from "./AppSymbol";
8import { useAndroidControlSizing } from "./useAndroidControlSizing";
89
9/** Shared geometry for Material navigation and selection lists. Group rows in one card. */10/** Shared geometry for Material navigation and selection lists. Group rows in one card. */
10export function MaterialListRow({11export function MaterialListRow({
...@@ -23,6 +24,7 @@ export function MaterialListRow({...@@ -23,6 +24,7 @@ export function MaterialListRow({
23 readonly trailing?: ReactNode;24 readonly trailing?: ReactNode;
24}) {25}) {
25 const { themeVariables } = useAppearancePreferences();26 const { themeVariables } = useAppearancePreferences();
27 const { smallIconSize } = useAndroidControlSizing();
26 return (28 return (
27 <Pressable29 <Pressable
28 accessibilityRole="button"30 accessibilityRole="button"
...@@ -52,7 +54,7 @@ export function MaterialListRow({...@@ -52,7 +54,7 @@ export function MaterialListRow({
52 {trailing !== undefined ? (54 {trailing !== undefined ? (
53 trailing55 trailing
54 ) : !props.disabled ? (56 ) : !props.disabled ? (
55 <SymbolView name="chevron.right" size={16} tintColorClassName="accent-chevron" />57 <SymbolView name="chevron.right" size={smallIconSize} tintColorClassName="accent-chevron" />
56 ) : null}58 ) : null}
57 </Pressable>59 </Pressable>
58 );60 );
apps/mobile/src/components/MaterialMenuPopup.android.tsx+36-27
...@@ -7,9 +7,11 @@ import {...@@ -7,9 +7,11 @@ import {
7 RNHostView,7 RNHostView,
8 Text,8 Text,
9} from "@expo/ui/jetpack-compose";9} 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";
11import { View } from "react-native";11import { View } from "react-native";
12import { resolveScaledTextRole } from "../lib/appearancePreferences";
1213
14import { useAndroidControlSizing } from "./useAndroidControlSizing";
13import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";15import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
14import type { MaterialMenuPopupProps } from "./MaterialMenuPopup";16import type { MaterialMenuPopupProps } from "./MaterialMenuPopup";
15import { isAppSymbolName, SymbolView, type AppSymbolName } from "./AppSymbol";17import { isAppSymbolName, SymbolView, type AppSymbolName } from "./AppSymbol";
...@@ -19,12 +21,16 @@ function MenuIcon(props: {...@@ -19,12 +21,16 @@ function MenuIcon(props: {
19 readonly destructive?: boolean;21 readonly destructive?: boolean;
20 readonly disabled?: boolean;22 readonly disabled?: boolean;
21}) {23}) {
24 const { iconSize } = useAndroidControlSizing();
22 return (25 return (
23 <RNHostView matchContents modifiers={[size(24, 24)]}>26 <RNHostView matchContents modifiers={[size(iconSize, iconSize)]}>
24 <View style={{ width: 24, height: 24 }} importantForAccessibility="no-hide-descendants">27 <View
28 style={{ width: iconSize, height: iconSize }}
29 importantForAccessibility="no-hide-descendants"
30 >
25 <SymbolView31 <SymbolView
26 name={props.name}32 name={props.name}
27 size={24}33 size={iconSize}
28 type="monochrome"34 type="monochrome"
29 tintColorClassName={35 tintColorClassName={
30 props.disabled36 props.disabled
...@@ -41,24 +47,33 @@ function MenuIcon(props: {...@@ -41,24 +47,33 @@ function MenuIcon(props: {
4147
42/** Native popup positioned at the original trigger, outside virtualized rows. */48/** Native popup positioned at the original trigger, outside virtualized rows. */
43export function MaterialMenuPopup(props: MaterialMenuPopupProps) {49export 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);
45 const foreground = colors["--color-foreground"];54 const foreground = colors["--color-foreground"];
46 const muted = colors["--color-foreground-muted"];55 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 })];
47 const items = (58 const items = (
48 <>59 <>
49 {props.parent ? (60 {props.parent ? (
50 <DropdownMenuItem onClick={props.onBack} modifiers={[width(250)]}>61 <DropdownMenuItem onClick={props.onBack} modifiers={itemModifiers}>
51 <DropdownMenuItem.LeadingIcon>62 <DropdownMenuItem.LeadingIcon>
52 <MenuIcon name="arrow.left" />63 <MenuIcon name="arrow.left" />
53 </DropdownMenuItem.LeadingIcon>64 </DropdownMenuItem.LeadingIcon>
54 <DropdownMenuItem.Text>65 <DropdownMenuItem.Text>
55 <Text color={foreground} style={{ typography: "bodyLarge" }}>66 <Text color={foreground} style={{ typography: "bodyLarge", ...body }}>
56 {props.parent.title}67 {props.parent.title}
57 </Text>68 </Text>
58 </DropdownMenuItem.Text>69 </DropdownMenuItem.Text>
59 </DropdownMenuItem>70 </DropdownMenuItem>
60 ) : props.title ? (71 ) : 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 >
62 {props.title}77 {props.title}
63 </Text>78 </Text>
64 ) : null}79 ) : null}
...@@ -66,19 +81,22 @@ export function MaterialMenuPopup(props: MaterialMenuPopupProps) {...@@ -66,19 +81,22 @@ export function MaterialMenuPopup(props: MaterialMenuPopupProps) {
66 <DropdownMenuItem81 <DropdownMenuItem
67 key={action.id ?? `${index}-${action.title}`}82 key={action.id ?? `${index}-${action.title}`}
68 enabled={!action.attributes?.disabled}83 enabled={!action.attributes?.disabled}
69 modifiers={[width(250)]}84 modifiers={itemModifiers}
70 elementColors={{
71 textColor: action.attributes?.destructive
72 ? colors["--color-danger-foreground"]
73 : foreground,
74 disabledTextColor: muted,
75 }}
76 onClick={() => props.onPress(action)}85 onClick={() => props.onPress(action)}
77 >86 >
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}
78 <DropdownMenuItem.Text>96 <DropdownMenuItem.Text>
79 <Column>97 <Column>
80 <Text98 <Text
81 style={{ typography: "bodyLarge" }}99 style={{ typography: "bodyLarge", ...body }}
82 color={100 color={
83 action.attributes?.disabled101 action.attributes?.disabled
84 ? muted102 ? muted
...@@ -90,21 +108,12 @@ export function MaterialMenuPopup(props: MaterialMenuPopupProps) {...@@ -90,21 +108,12 @@ export function MaterialMenuPopup(props: MaterialMenuPopupProps) {
90 {action.title}108 {action.title}
91 </Text>109 </Text>
92 {action.subtitle ? (110 {action.subtitle ? (
93 <Text color={muted} style={{ typography: "bodySmall" }}>111 <Text color={muted} style={{ typography: "bodySmall", ...caption }}>
94 {action.subtitle}112 {action.subtitle}
95 </Text>113 </Text>
96 ) : null}114 ) : null}
97 </Column>115 </Column>
98 </DropdownMenuItem.Text>116 </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}
108 {(action.subactions?.length ?? 0) > 0 ? (117 {(action.subactions?.length ?? 0) > 0 ? (
109 <DropdownMenuItem.TrailingIcon>118 <DropdownMenuItem.TrailingIcon>
110 <MenuIcon name="chevron.right" disabled={action.attributes?.disabled} />119 <MenuIcon name="chevron.right" disabled={action.attributes?.disabled} />
...@@ -124,7 +133,7 @@ export function MaterialMenuPopup(props: MaterialMenuPopupProps) {...@@ -124,7 +133,7 @@ export function MaterialMenuPopup(props: MaterialMenuPopupProps) {
124 colorScheme={themeAppearance}133 colorScheme={themeAppearance}
125 ignoreSafeAreaKeyboardInsets134 ignoreSafeAreaKeyboardInsets
126 matchContents135 matchContents
127 style={{ width: 250 }}136 style={{ width: props.menuWidth }}
128 >137 >
129 <Column>{items}</Column>138 <Column>{items}</Column>
130 </Host>139 </Host>
apps/mobile/src/components/MaterialMenuPopup.tsx+1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import type { MenuAction } from "@react-native-menu/menu";1import type { MenuAction } from "@react-native-menu/menu";
22
3export interface MaterialMenuPopupProps {3export interface MaterialMenuPopupProps {
4 readonly menuWidth: number;
4 readonly anchor: {5 readonly anchor: {
5 readonly x: number;6 readonly x: number;
6 readonly y: number;7 readonly y: number;
apps/mobile/src/components/MaterialScrollComposeButton.android.tsx+45-13
...@@ -1,9 +1,17 @@...@@ -1,9 +1,17 @@
1import { Box, ExtendedFloatingActionButton, Host, Icon, Text } from "@expo/ui/jetpack-compose";1import { 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";
3import { useCallback, useState } from "react";10import { useCallback, useState } from "react";
4import { Pressable, View, type StyleProp, type ViewStyle } from "react-native";11import { Pressable, View, type StyleProp, type ViewStyle } from "react-native";
12import { useAndroidControlSizing } from "./useAndroidControlSizing";
5import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";13import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
6import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";14import { resolveScaledTextRole } from "../lib/appearancePreferences";
715
8/** Keep the animated width and icon positioning entirely inside Compose, not Yoga. */16/** Keep the animated width and icon positioning entirely inside Compose, not Yoga. */
9export function MaterialScrollComposeButton(props: {17export function MaterialScrollComposeButton(props: {
...@@ -12,14 +20,23 @@ export function MaterialScrollComposeButton(props: {...@@ -12,14 +20,23 @@ export function MaterialScrollComposeButton(props: {
12 readonly className?: string;20 readonly className?: string;
13 readonly style?: StyleProp<ViewStyle>;21 readonly style?: StyleProp<ViewStyle>;
14}) {22}) {
15 const { themeAppearance, themeVariables: colors } = useAppearancePreferences();23 const { appearance, themeAppearance, themeVariables: colors } = useAppearancePreferences();
16 const typography = useScaledTextRole("footnote");24 const typography = resolveScaledTextRole("footnote", appearance.baseFontSize);
17 const [expandedWidth, setExpandedWidth] = useState(56);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);
18 const rememberWidth = useCallback(({ width }: { width: number }) => {31 const rememberWidth = useCallback(({ width }: { width: number }) => {
19 setExpandedWidth((previous) => Math.max(previous, width));32 setButtonWidth((previous) => Math.max(previous, width));
20 }, []);33 }, []);
21 return (34 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 >
23 <View pointerEvents="none" importantForAccessibility="no-hide-descendants">40 <View pointerEvents="none" importantForAccessibility="no-hide-descendants">
24 <Host41 <Host
25 matchContents={{ vertical: true }}42 matchContents={{ vertical: true }}
...@@ -31,13 +48,23 @@ export function MaterialScrollComposeButton(props: {...@@ -31,13 +48,23 @@ export function MaterialScrollComposeButton(props: {
31 <ExtendedFloatingActionButton48 <ExtendedFloatingActionButton
32 expanded={props.expanded}49 expanded={props.expanded}
33 containerColor={colors["--color-primary"]}50 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 ]}
35 >62 >
36 <ExtendedFloatingActionButton.Icon>63 <ExtendedFloatingActionButton.Icon>
37 <Box modifiers={[size(24, 24)]}>64 <Box modifiers={[size(nativeIconSize, nativeIconSize)]}>
38 <Icon65 <Icon
39 source={require("../../assets/icons/compose.xml")}66 source={require("../../assets/icons/compose.xml")}
40 size={24}67 size={nativeIconSize}
41 tint={colors["--color-primary-foreground"]}68 tint={colors["--color-primary-foreground"]}
42 />69 />
43 </Box>70 </Box>
...@@ -45,7 +72,11 @@ export function MaterialScrollComposeButton(props: {...@@ -45,7 +72,11 @@ export function MaterialScrollComposeButton(props: {
45 <ExtendedFloatingActionButton.Text>72 <ExtendedFloatingActionButton.Text>
46 <Text73 <Text
47 color={colors["--color-primary-foreground"]}74 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 }}
49 >80 >
50 New thread81 New thread
51 </Text>82 </Text>
...@@ -65,8 +96,9 @@ export function MaterialScrollComposeButton(props: {...@@ -65,8 +96,9 @@ export function MaterialScrollComposeButton(props: {
65 right: 0,96 right: 0,
66 top: 0,97 top: 0,
67 bottom: 0,98 bottom: 0,
68 width: props.expanded ? expandedWidth : 56,99 // Release the label area as soon as collapse starts, before native measurements arrive.
69 borderRadius: 16,100 width: props.expanded ? buttonWidth * scale : fabSize,
101 borderRadius: 16 * scale,
70 overflow: "hidden",102 overflow: "hidden",
71 }}103 }}
72 />104 />
apps/mobile/src/components/MaterialSearchField.tsx+18-4
...@@ -2,6 +2,7 @@ import type { RefObject } from "react";...@@ -2,6 +2,7 @@ import type { RefObject } from "react";
2import { Pressable, TextInput, View } from "react-native";2import { Pressable, TextInput, View } from "react-native";
33
4import { SymbolView } from "./AppSymbol";4import { SymbolView } from "./AppSymbol";
5import { useAndroidControlSizing } from "./useAndroidControlSizing";
56
6export function MaterialSearchField({7export function MaterialSearchField({
7 inputRef,8 inputRef,
...@@ -18,9 +19,21 @@ export function MaterialSearchField({...@@ -18,9 +19,21 @@ export function MaterialSearchField({
18 readonly value: string;19 readonly value: string;
19 readonly onChangeText: (value: string) => void;20 readonly onChangeText: (value: string) => void;
20}) {21}) {
22 const { scale, mediumIconSize } = useAndroidControlSizing();
21 return (23 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">24 <View
23 <SymbolView name="magnifyingglass" size={18} tintColorClassName="accent-foreground-muted" />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 />
24 <TextInput37 <TextInput
25 ref={inputRef}38 ref={inputRef}
26 accessibilityLabel={accessibilityLabel}39 accessibilityLabel={accessibilityLabel}
...@@ -33,7 +46,8 @@ export function MaterialSearchField({...@@ -33,7 +46,8 @@ export function MaterialSearchField({
33 selectionColorClassName="accent-focus/32"46 selectionColorClassName="accent-focus/32"
34 cursorColorClassName="accent-focus"47 cursorColorClassName="accent-focus"
35 selectionHandleColorClassName="accent-focus"48 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 }}
37 value={value}51 value={value}
38 onChangeText={onChangeText}52 onChangeText={onChangeText}
39 />53 />
...@@ -49,7 +63,7 @@ export function MaterialSearchField({...@@ -49,7 +63,7 @@ export function MaterialSearchField({
49 >63 >
50 <SymbolView64 <SymbolView
51 name="xmark.circle.fill"65 name="xmark.circle.fill"
52 size={18}66 size={mediumIconSize}
53 tintColorClassName="accent-foreground-muted"67 tintColorClassName="accent-foreground-muted"
54 />68 />
55 </Pressable>69 </Pressable>
apps/mobile/src/components/ScreenHeader.android.tsx+36-18
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { useCallback, useEffect, useRef, useState } from "react";1import { useCallback, useEffect, useRef, useState } from "react";
2import { BackHandler, Keyboard, Pressable, TextInput, View } from "react-native";2import { BackHandler, Keyboard, Pressable, TextInput, View } from "react-native";
3import { useSafeAreaInsets } from "react-native-safe-area-context";3import { useMaterialToolbarLayout } from "./useMaterialToolbarLayout";
4import { NativeStackScreenOptions } from "../native/StackHeader";4import { NativeStackScreenOptions } from "../native/StackHeader";
5import { AndroidWorkspaceSidebarButton } from "../features/layout/workspace-sidebar-toolbar";5import { AndroidWorkspaceSidebarButton } from "../features/layout/workspace-sidebar-toolbar";
6import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";6import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
...@@ -11,10 +11,12 @@ import { ControlPillMenu } from "./ControlPill";...@@ -11,10 +11,12 @@ import { ControlPillMenu } from "./ControlPill";
11import { MaterialSearchField } from "./MaterialSearchField";11import { MaterialSearchField } from "./MaterialSearchField";
12import { androidHeaderMenuActions, findHeaderMenuAction } from "./headerMenu.android";12import { androidHeaderMenuActions, findHeaderMenuAction } from "./headerMenu.android";
13import type { ScreenHeaderProps } from "./ScreenHeader.types";13import type { ScreenHeaderProps } from "./ScreenHeader.types";
14import { useAndroidControlSizing } from "./useAndroidControlSizing";
1415
15export function ScreenHeader(props: ScreenHeaderProps) {16export function ScreenHeader(props: ScreenHeaderProps) {
16 const { search } = props;17 const { search } = props;
17 const insets = useSafeAreaInsets();18 const { paddingTop, paddingBottom } = useMaterialToolbarLayout();
19 const { scale, buttonSize, iconSize, smallIconSize } = useAndroidControlSizing();
18 const { themeVariables } = useAppearancePreferences();20 const { themeVariables } = useAppearancePreferences();
19 const inputRef = useRef<TextInput>(null);21 const inputRef = useRef<TextInput>(null);
20 const [searchOpen, setSearchOpen] = useState(false);22 const [searchOpen, setSearchOpen] = useState(false);
...@@ -48,11 +50,12 @@ export function ScreenHeader(props: ScreenHeaderProps) {...@@ -48,11 +50,12 @@ export function ScreenHeader(props: ScreenHeaderProps) {
48 <Pressable50 <Pressable
49 accessibilityLabel={menu.title}51 accessibilityLabel={menu.title}
50 accessibilityRole="button"52 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 }}
52 >55 >
53 <SymbolView56 <SymbolView
54 name={menu.icon}57 name={menu.icon}
55 size={16}58 size={smallIconSize}
56 tintColorClassName="accent-header-foreground"59 tintColorClassName="accent-header-foreground"
57 type="monochrome"60 type="monochrome"
58 />61 />
...@@ -80,30 +83,43 @@ export function ScreenHeader(props: ScreenHeaderProps) {...@@ -80,30 +83,43 @@ export function ScreenHeader(props: ScreenHeaderProps) {
80 <>83 <>
81 {options}84 {options}
82 <View85 <View
83 className="border-b border-header-border bg-header px-3 pb-2.5"86 className="border-b border-header-border bg-header px-3"
84 style={{ paddingTop: Math.max(insets.top, 12), borderBottomWidth: 0 }}87 style={{
88 paddingTop,
89 paddingBottom: 8.75 * scale,
90 borderBottomWidth: 0,
91 }}
85 >92 >
86 <View className="min-h-12 flex-row items-center gap-2">93 <View className="flex-row items-center gap-2" style={{ minHeight: buttonSize }}>
87 {props.onBack ? (94 {props.onBack ? (
88 <Pressable95 <Pressable
89 accessibilityLabel="Navigate up"96 accessibilityLabel="Navigate up"
90 accessibilityRole="button"97 accessibilityRole="button"
91 hitSlop={8}98 hitSlop={8}
92 onPress={props.onBack}99 onPress={props.onBack}
93 className="size-11 items-center justify-center"100 className="items-center justify-center"
101 style={{ width: buttonSize, height: buttonSize }}
94 >102 >
95 <SymbolView103 <SymbolView
96 name="chevron.left"104 name="chevron.left"
97 size={24}105 size={iconSize}
98 tintColorClassName="accent-header-foreground"106 tintColorClassName="accent-header-foreground"
99 type="monochrome"107 type="monochrome"
100 />108 />
101 </Pressable>109 </Pressable>
102 ) : null}110 ) : 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 >
104 <SymbolView120 <SymbolView
105 name="magnifyingglass"121 name="magnifyingglass"
106 size={17}122 size={Math.round(17 * scale)}
107 tintColorClassName="accent-header-foreground"123 tintColorClassName="accent-header-foreground"
108 type="monochrome"124 type="monochrome"
109 />125 />
...@@ -112,9 +128,14 @@ export function ScreenHeader(props: ScreenHeaderProps) {...@@ -112,9 +128,14 @@ export function ScreenHeader(props: ScreenHeaderProps) {
112 autoCapitalize="none"128 autoCapitalize="none"
113 onChangeText={search.onChangeText}129 onChangeText={search.onChangeText}
114 value={search.value}130 value={search.value}
115 placeholder={search.placeholder}131 placeholder={
132 search.compactToolbar
133 ? (search.compactPlaceholder ?? search.placeholder)
134 : search.placeholder
135 }
116 placeholderTextColorClassName="accent-placeholder"136 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 }}
118 />139 />
119 </View>140 </View>
120 {menuView}141 {menuView}
...@@ -173,11 +194,8 @@ export function ScreenHeader(props: ScreenHeaderProps) {...@@ -173,11 +194,8 @@ export function ScreenHeader(props: ScreenHeaderProps) {
173 {header}194 {header}
174 </View>195 </View>
175 {searching ? (196 {searching ? (
176 <View197 <View className="absolute inset-0 bg-header px-2" style={{ paddingTop, paddingBottom }}>
177 className="absolute inset-0 bg-header px-2 pb-2"198 <View className="flex-1 flex-row items-center gap-1">
178 style={{ paddingTop: Math.max(insets.top, 12) }}
179 >
180 <View className="min-h-14 flex-1 flex-row items-center gap-1">
181 <ScreenHeaderButton199 <ScreenHeaderButton
182 accessibilityLabel={search.closeAccessibilityLabel ?? "Close search"}200 accessibilityLabel={search.closeAccessibilityLabel ?? "Close search"}
183 icon="arrow.left"201 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 @@...@@ -1,10 +1,10 @@
1import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";1import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
2import { BackHandler, Keyboard, type TextInput, View } from "react-native";2import { BackHandler, Keyboard, type TextInput, View } from "react-native";
3import { useSafeAreaInsets } from "react-native-safe-area-context";
43
5import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader";4import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader";
6import { AndroidAnchoredMenu } from "../../components/AndroidAnchoredMenu";5import { AndroidAnchoredMenu } from "../../components/AndroidAnchoredMenu";
7import { MaterialSearchField } from "../../components/MaterialSearchField";6import { MaterialSearchField } from "../../components/MaterialSearchField";
7import { useMaterialToolbarLayout } from "../../components/useMaterialToolbarLayout";
88
9/** Keep Files search in the same header row on compact and expanded layouts. */9/** Keep Files search in the same header row on compact and expanded layouts. */
10export function MaterialFilesHeader(props: {10export function MaterialFilesHeader(props: {
...@@ -15,7 +15,7 @@ export function MaterialFilesHeader(props: {...@@ -15,7 +15,7 @@ export function MaterialFilesHeader(props: {
15 readonly onBack?: () => void;15 readonly onBack?: () => void;
16 readonly leading?: ReactNode;16 readonly leading?: ReactNode;
17}) {17}) {
18 const insets = useSafeAreaInsets();18 const { paddingTop, paddingBottom } = useMaterialToolbarLayout();
19 const searchRef = useRef<TextInput>(null);19 const searchRef = useRef<TextInput>(null);
20 const [searchOpen, setSearchOpen] = useState(false);20 const [searchOpen, setSearchOpen] = useState(false);
21 const searching = searchOpen || props.searchQuery.length > 0;21 const searching = searchOpen || props.searchQuery.length > 0;
...@@ -77,11 +77,8 @@ export function MaterialFilesHeader(props: {...@@ -77,11 +77,8 @@ export function MaterialFilesHeader(props: {
77 />77 />
78 </View>78 </View>
79 {searching ? (79 {searching ? (
80 <View80 <View className="absolute inset-0 bg-header px-2" style={{ paddingTop, paddingBottom }}>
81 className="absolute inset-0 bg-header px-2 pb-2"81 <View className="flex-1 flex-row items-center gap-1">
82 style={{ paddingTop: Math.max(insets.top, 12) }}
83 >
84 <View className="min-h-14 flex-1 flex-row items-center gap-1">
85 <AndroidHeaderIconButton82 <AndroidHeaderIconButton
86 accessibilityLabel="Close file search"83 accessibilityLabel="Close file search"
87 icon="arrow.left"84 icon="arrow.left"
apps/mobile/src/features/home/AndroidHomeFab.android.tsx+14-3
...@@ -1,14 +1,23 @@...@@ -1,14 +1,23 @@
1import { useCallback, useRef, useState, type ComponentProps } from "react";1import { 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";
3import { useSafeAreaInsets } from "react-native-safe-area-context";8import { useSafeAreaInsets } from "react-native-safe-area-context";
4import { MaterialNewThreadButton } from "../../components/MaterialNewThreadButton";9import { MaterialNewThreadButton } from "../../components/MaterialNewThreadButton";
5import type { AndroidHomeFabLayout as SharedAndroidHomeFabLayout } from "./AndroidHomeFab.shared";10import type { AndroidHomeFabLayout as SharedAndroidHomeFabLayout } from "./AndroidHomeFab.shared";
6import { useWorkspaceState } from "../../state/workspace";11import { useWorkspaceState } from "../../state/workspace";
7import { MaterialFabScrollContext } from "./MaterialFabScrollContext";12import { MaterialFabScrollContext } from "./MaterialFabScrollContext";
8import { updateMaterialFabScroll } from "./material-fab-scroll";13import { updateMaterialFabScroll } from "./material-fab-scroll";
14import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
915
10export function AndroidHomeFabLayout(props: ComponentProps<typeof SharedAndroidHomeFabLayout>) {16export function AndroidHomeFabLayout(props: ComponentProps<typeof SharedAndroidHomeFabLayout>) {
11 const insets = useSafeAreaInsets();17 const insets = useSafeAreaInsets();
18 const { appearance } = useAppearancePreferences();
19 const { fontScale } = useWindowDimensions();
20 const [layoutWidth, setLayoutWidth] = useState<number | null>(null);
12 const { state } = useWorkspaceState();21 const { state } = useWorkspaceState();
13 const [expanded, setExpanded] = useState(true);22 const [expanded, setExpanded] = useState(true);
14 const scrollState = useRef({ anchor: 0, expanded: true });23 const scrollState = useRef({ anchor: 0, expanded: true });
...@@ -23,11 +32,13 @@ export function AndroidHomeFabLayout(props: ComponentProps<typeof SharedAndroidH...@@ -23,11 +32,13 @@ export function AndroidHomeFabLayout(props: ComponentProps<typeof SharedAndroidH
23 scrollState.current = next;32 scrollState.current = next;
24 }, []);33 }, []);
2534
35 // Remount only the FAB when its font or pane changes to clear the cached expanded width.
26 return (36 return (
27 <View className="flex-1">37 <View className="flex-1" onLayout={(event) => setLayoutWidth(event.nativeEvent.layout.width)}>
28 <MaterialFabScrollContext value={onScroll}>{props.children}</MaterialFabScrollContext>38 <MaterialFabScrollContext value={onScroll}>{props.children}</MaterialFabScrollContext>
29 {state.hasConnections ? (39 {state.hasConnections && layoutWidth !== null ? (
30 <MaterialNewThreadButton40 <MaterialNewThreadButton
41 key={`${appearance.baseFontSize}:${fontScale}:${layoutWidth}`}
31 extended42 extended
32 expanded={expanded}43 expanded={expanded}
33 onPress={props.onStartNewTask}44 onPress={props.onStartNewTask}
apps/mobile/src/features/home/HomeScreen.tsx+3-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { useAndroidControlSizing } from "../../components/useAndroidControlSizing";
1import type { ThreadMoveDestination } from "../threads/threadOrder";2import type { ThreadMoveDestination } from "../threads/threadOrder";
2import { computeThreadMoveAvailability } from "../threads/threadOrder";3import { computeThreadMoveAvailability } from "../threads/threadOrder";
3import { LegendList } from "@legendapp/list/react-native";4import { LegendList } from "@legendapp/list/react-native";
...@@ -214,6 +215,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -214,6 +215,7 @@ export function HomeScreen(props: HomeScreenProps) {
214 const queuedThreadKeys = useQueuedThreadKeys();215 const queuedThreadKeys = useQueuedThreadKeys();
215 const openSwipeableRef = useRef<SwipeableMethods | null>(null);216 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
216 const insets = useSafeAreaInsets();217 const insets = useSafeAreaInsets();
218 const { fabClearance } = useAndroidControlSizing();
217 const iosBottomToolbarClearance =219 const iosBottomToolbarClearance =
218 Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED220 Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED
219 ? PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT221 ? PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT
...@@ -983,7 +985,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -983,7 +985,7 @@ export function HomeScreen(props: HomeScreenProps) {
983 paddingBottom:985 paddingBottom:
984 Platform.OS === "ios"986 Platform.OS === "ios"
985 ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance987 ? 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),
987 }}989 }}
988 />990 />
989 </SwipeableScrollGateProvider>991 </SwipeableScrollGateProvider>
apps/mobile/src/features/home/MaterialThreadListToolbar.tsx+8-8
...@@ -12,7 +12,8 @@ import { MaterialSearchField } from "../../components/MaterialSearchField";...@@ -12,7 +12,8 @@ import { MaterialSearchField } from "../../components/MaterialSearchField";
12import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";12import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
13import { WorkspaceConnectionTitle } from "./WorkspaceConnectionTitle";13import { WorkspaceConnectionTitle } from "./WorkspaceConnectionTitle";
14import { useWorkspaceState } from "../../state/workspace";14import { useWorkspaceState } from "../../state/workspace";
15import { useMaterialToolbarHeight } from "../../components/useMaterialToolbarHeight";15import { useAndroidControlSizing } from "../../components/useAndroidControlSizing";
16import { useMaterialToolbarLayout } from "../../components/useMaterialToolbarLayout";
1617
17/** One toolbar height for the compact list and expanded sidebar, including search. */18/** One toolbar height for the compact list and expanded sidebar, including search. */
18export function MaterialThreadListToolbar(props: {19export function MaterialThreadListToolbar(props: {
...@@ -28,7 +29,8 @@ export function MaterialThreadListToolbar(props: {...@@ -28,7 +29,8 @@ export function MaterialThreadListToolbar(props: {
28 readonly onRequestVisibility?: () => void;29 readonly onRequestVisibility?: () => void;
29}) {30}) {
30 const insets = useSafeAreaInsets();31 const insets = useSafeAreaInsets();
31 const toolbarHeight = useMaterialToolbarHeight();32 const { fabSize } = useAndroidControlSizing();
33 const { height: toolbarHeight, ...headerPadding } = useMaterialToolbarLayout();
32 const { state } = useWorkspaceState();34 const { state } = useWorkspaceState();
33 const { onRequestVisibility, onSearchQueryChange } = props;35 const { onRequestVisibility, onSearchQueryChange } = props;
34 const searchRef = useRef<TextInput>(null);36 const searchRef = useRef<TextInput>(null);
...@@ -76,11 +78,9 @@ export function MaterialThreadListToolbar(props: {...@@ -76,11 +78,9 @@ export function MaterialThreadListToolbar(props: {
76 <View78 <View
77 onLayout={props.onLayout}79 onLayout={props.onLayout}
78 className={80 className={
79 props.sidebar81 props.sidebar ? "absolute inset-x-0 top-0 z-[4] bg-header px-2" : "bg-header px-2"
80 ? "absolute inset-x-0 top-0 z-[4] bg-header px-2 pb-2"
81 : "bg-header px-2 pb-2"
82 }82 }
83 style={{ paddingTop: Math.max(insets.top, 12) }}83 style={headerPadding}
84 >84 >
85 <View className="flex-row items-center gap-1" style={{ minHeight: toolbarHeight }}>85 <View className="flex-row items-center gap-1" style={{ minHeight: toolbarHeight }}>
86 {searching ? (86 {searching ? (
...@@ -116,14 +116,14 @@ export function MaterialThreadListToolbar(props: {...@@ -116,14 +116,14 @@ export function MaterialThreadListToolbar(props: {
116 )}116 )}
117 </View>117 </View>
118 </View>118 </View>
119 {/* Sit 8dp above the 56dp extended New thread FAB. */}119 {/* Keep the filter above the New thread FAB at every text size. */}
120 {state.hasConnections ? (120 {state.hasConnections ? (
121 <View121 <View
122 className="absolute right-5 z-[5]"122 className="absolute right-5 z-[5]"
123 style={{123 style={{
124 bottom:124 bottom:
125 (props.sidebar ? Math.max(insets.bottom, 12) + 6 : Math.max(insets.bottom, 16) + 16) +125 (props.sidebar ? Math.max(insets.bottom, 12) + 6 : Math.max(insets.bottom, 16) + 16) +
126 56 +126 fabSize +
127 8,127 8,
128 }}128 }}
129 >129 >
apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx+14-10
...@@ -1,9 +1,10 @@...@@ -1,9 +1,10 @@
1import type { NativeStackNavigationOptions } from "@react-navigation/native-stack";1import type { NativeStackNavigationOptions } from "@react-navigation/native-stack";
2import { useEffect, useRef, useState, type ReactNode } from "react";2import { 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
5import { SymbolView } from "../../components/AppSymbol";5import { SymbolView } from "../../components/AppSymbol";
6import { AppText as Text } from "../../components/AppText";6import { AppText as Text } from "../../components/AppText";
7import { useAndroidControlSizing } from "../../components/useAndroidControlSizing";
7import {8import {
8 brandTitleOffset,9 brandTitleOffset,
9 CompactBrandTitle,10 CompactBrandTitle,
...@@ -105,6 +106,7 @@ export function WorkspaceConnectionTitle(props: {...@@ -105,6 +106,7 @@ export function WorkspaceConnectionTitle(props: {
105}) {106}) {
106 const status = useDelayedConnectionStatus();107 const status = useDelayedConnectionStatus();
107 const size = props.size ?? "navbar";108 const size = props.size ?? "navbar";
109 const { scale } = useAndroidControlSizing();
108110
109 if (status === null) {111 if (status === null) {
110 return props.grow ? (112 return props.grow ? (
...@@ -126,26 +128,28 @@ export function WorkspaceConnectionTitle(props: {...@@ -126,26 +128,28 @@ export function WorkspaceConnectionTitle(props: {
126 hitSlop={8}128 hitSlop={8}
127 onPress={props.onPress}129 onPress={props.onPress}
128 className="flex-row items-center gap-2"130 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 ]}
130 >135 >
131 {status.showsProgress ? (136 {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 />
133 ) : (141 ) : (
134 <SymbolView142 <SymbolView
135 name="wifi.slash"143 name="wifi.slash"
136 size={size === "pageTitle" ? 17 : 15}144 size={Math.round((size === "pageTitle" ? 17 : 15) * scale)}
137 tintColorClassName={"accent-icon-muted"}145 tintColorClassName={"accent-icon-muted"}
138 type="monochrome"146 type="monochrome"
139 />147 />
140 )}148 )}
141 <Text149 <Text
142 className={150 className="font-t3-bold text-foreground-muted"
143 size === "pageTitle"
144 ? "text-[20px] font-t3-bold text-foreground-muted"
145 : "text-[16px] font-t3-bold text-foreground-muted"
146 }
147 numberOfLines={1}151 numberOfLines={1}
148 style={{ flexShrink: 1 }}152 style={{ flexShrink: 1, fontSize: (size === "pageTitle" ? 20 : 16) * scale }}
149 >153 >
150 {status.label}154 {status.label}
151 </Text>155 </Text>
apps/mobile/src/features/settings/appearance/useScaledTextRole.ts+5-16
...@@ -1,10 +1,6 @@...@@ -1,10 +1,6 @@
1import { useMemo } from "react";1import { useMemo } from "react";
22
3import {3import { resolveScaledTextRole } from "../../../lib/appearancePreferences";
4 DEFAULT_BASE_FONT_SIZE,
5 normalizeBaseFontSize,
6 scaledTypographyLineHeight,
7} from "../../../lib/appearancePreferences";
8import { MOBILE_TYPOGRAPHY } from "../../../lib/typography";4import { MOBILE_TYPOGRAPHY } from "../../../lib/typography";
9import { useAppearancePreferences } from "./AppearancePreferencesProvider";5import { useAppearancePreferences } from "./AppearancePreferencesProvider";
106
...@@ -20,15 +16,8 @@ export interface ScaledTextRole {...@@ -20,15 +16,8 @@ export interface ScaledTextRole {
20 */16 */
21export function useScaledTextRole(role: keyof typeof MOBILE_TYPOGRAPHY): ScaledTextRole {17export function useScaledTextRole(role: keyof typeof MOBILE_TYPOGRAPHY): ScaledTextRole {
22 const { appearance } = useAppearancePreferences();18 const { appearance } = useAppearancePreferences();
23 return useMemo(() => {19 return useMemo(
24 const baseFontSize = normalizeBaseFontSize(appearance.baseFontSize);20 () => resolveScaledTextRole(role, appearance.baseFontSize),
25 const typography = MOBILE_TYPOGRAPHY[role];21 [appearance.baseFontSize, role],
26 return {22 );
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]);
34}23}
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+24-13
...@@ -132,30 +132,40 @@ import { fileRoutePathSegments } from "../files/filePath";...@@ -132,30 +132,40 @@ import { fileRoutePathSegments } from "../files/filePath";
132function NewTaskWorkspaceIcon(props: {132function NewTaskWorkspaceIcon(props: {
133 readonly workspaceMode: "local" | "worktree";133 readonly workspaceMode: "local" | "worktree";
134 readonly worktreePath: string | null;134 readonly worktreePath: string | null;
135 readonly size: number;
135}) {136}) {
136 if (props.workspaceMode === "local" && props.worktreePath === null) {137 if (props.workspaceMode === "local" && props.worktreePath === null) {
137 return (138 return (
138 <SymbolView139 <SymbolView
139 name="folder"140 name="folder"
140 size={16}141 size={props.size}
141 tintColorClassName="accent-icon-muted"142 tintColorClassName="accent-icon-muted"
142 type="monochrome"143 type="monochrome"
143 />144 />
144 );145 );
145 }146 }
146147
148 const boxSize = (14 * props.size) / 16;
147 return (149 return (
148 <View className="size-4">150 <View
151 className="size-4"
152 style={Platform.OS === "android" ? { width: boxSize, height: boxSize } : undefined}
153 >
149 <SymbolView154 <SymbolView
150 name="folder"155 name="folder"
151 size={16}156 size={props.size}
152 tintColorClassName="accent-icon-muted"157 tintColorClassName="accent-icon-muted"
153 type="monochrome"158 type="monochrome"
154 />159 />
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 >
156 <SymbolView166 <SymbolView
157 name="arrow.triangle.branch"167 name="arrow.triangle.branch"
158 size={9}168 size={Math.round((9 * props.size) / 16)}
159 tintColorClassName="accent-icon-muted"169 tintColorClassName="accent-icon-muted"
160 type="monochrome"170 type="monochrome"
161 />171 />
...@@ -1476,13 +1486,13 @@ export function NewTaskDraftScreen(props: {...@@ -1476,13 +1486,13 @@ export function NewTaskDraftScreen(props: {
1476 accessibilityLabel={`Environment: ${selectedEnvironmentLabel}`}1486 accessibilityLabel={`Environment: ${selectedEnvironmentLabel}`}
1477 chevronDirection="right"1487 chevronDirection="right"
1478 disabled={isComposerInteractionLocked || voiceInput.isBusy}1488 disabled={isComposerInteractionLocked || voiceInput.isBusy}
1479 iconNode={1489 renderIcon={(size) => (
1480 <EnvironmentMachineSymbol1490 <EnvironmentMachineSymbol
1481 kind={resolveEnvironmentMachineKind(selectedEnvironmentServerConfig)}1491 kind={resolveEnvironmentMachineKind(selectedEnvironmentServerConfig)}
1482 size={16}1492 size={size}
1483 tintColorClassName="accent-icon-muted"1493 tintColorClassName="accent-icon-muted"
1484 />1494 />
1485 }1495 )}
1486 label={`on ${selectedEnvironmentLabel}`}1496 label={`on ${selectedEnvironmentLabel}`}
1487 maxWidth={260}1497 maxWidth={260}
1488 onPress={1498 onPress={
...@@ -1517,12 +1527,13 @@ export function NewTaskDraftScreen(props: {...@@ -1517,12 +1527,13 @@ export function NewTaskDraftScreen(props: {
1517 accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`}1527 accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`}
1518 accessibilityLabel={workspaceLabel}1528 accessibilityLabel={workspaceLabel}
1519 disabled={isComposerInteractionLocked || voiceInput.isBusy}1529 disabled={isComposerInteractionLocked || voiceInput.isBusy}
1520 iconNode={1530 renderIcon={(size) => (
1521 <NewTaskWorkspaceIcon1531 <NewTaskWorkspaceIcon
1522 workspaceMode={flow.workspaceMode}1532 workspaceMode={flow.workspaceMode}
1523 worktreePath={flow.selectedWorktreePath}1533 worktreePath={flow.selectedWorktreePath}
1534 size={size}
1524 />1535 />
1525 }1536 )}
1526 label={workspaceLabel}1537 label={workspaceLabel}
1527 maxWidth={flow.workspaceMode === "local" ? 220 : 148}1538 maxWidth={flow.workspaceMode === "local" ? 220 : 148}
1528 onPress={() => flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")}1539 onPress={() => flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")}
...@@ -1681,12 +1692,12 @@ export function NewTaskDraftScreen(props: {...@@ -1681,12 +1692,12 @@ export function NewTaskDraftScreen(props: {
1681 accessibilityLabel="Model and reasoning settings"1692 accessibilityLabel="Model and reasoning settings"
1682 disabled={isComposerInteractionLocked}1693 disabled={isComposerInteractionLocked}
1683 emphasized1694 emphasized
1684 iconNode={1695 renderIcon={(size) => (
1685 <ProviderIcon1696 <ProviderIcon
1686 provider={flow.selectedModelOption?.providerDriver}1697 provider={flow.selectedModelOption?.providerDriver}
1687 size={16}1698 size={size}
1688 />1699 />
1689 }1700 )}
1690 label={flow.selectedModelOption?.label ?? "Choose model"}1701 label={flow.selectedModelOption?.label ?? "Choose model"}
1691 maxWidth="100%"1702 maxWidth="100%"
1692 onPress={settingsSheetPresentation.open}1703 onPress={settingsSheetPresentation.open}
apps/mobile/src/features/threads/ThreadComposer.tsx+3-3
...@@ -961,9 +961,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -961,9 +961,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
961 <ComposerInlineControl961 <ComposerInlineControl
962 accessibilityLabel="Model and reasoning settings"962 accessibilityLabel="Model and reasoning settings"
963 emphasized963 emphasized
964 iconNode={964 renderIcon={(size) => (
965 <ProviderIcon provider={currentModelOption?.providerDriver} size={16} />965 <ProviderIcon provider={currentModelOption?.providerDriver} size={size} />
966 }966 )}
967 label={currentModelOption?.label ?? currentModelSelection.model}967 label={currentModelOption?.label ?? currentModelSelection.model}
968 maxWidth="100%"968 maxWidth="100%"
969 onPress={openSettings}969 onPress={openSettings}
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+6-4
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { useAndroidControlSizing } from "../../components/useAndroidControlSizing";
1import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";2import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
2import { computeThreadMoveAvailability } from "./threadOrder";3import { computeThreadMoveAvailability } from "./threadOrder";
3import type {4import type {
...@@ -50,7 +51,7 @@ import {...@@ -50,7 +51,7 @@ import {
50} from "../home/WorkspaceConnectionTitle";51} from "../home/WorkspaceConnectionTitle";
51import { SidebarHeaderActions } from "./sidebar-header-actions";52import { SidebarHeaderActions } from "./sidebar-header-actions";
52import { MaterialThreadListToolbar } from "../home/MaterialThreadListToolbar";53import { MaterialThreadListToolbar } from "../home/MaterialThreadListToolbar";
53import { useMaterialToolbarHeight } from "../../components/useMaterialToolbarHeight";54import { useMaterialToolbarLayout } from "../../components/useMaterialToolbarLayout";
54import { useMaterialFabScroll } from "../home/MaterialFabScrollContext";55import { useMaterialFabScroll } from "../home/MaterialFabScrollContext";
55import { SidebarFilterButton } from "./sidebar-filter-button";56import { SidebarFilterButton } from "./sidebar-filter-button";
56import { createSidebarHeaderItems } from "./sidebar-native-header-items";57import { createSidebarHeaderItems } from "./sidebar-native-header-items";
...@@ -133,6 +134,7 @@ function ThreadNavigationSidebarPane(...@@ -133,6 +134,7 @@ function ThreadNavigationSidebarPane(
133 const drawerColor = materialTheme["--color-drawer"];134 const drawerColor = materialTheme["--color-drawer"];
134135
135 const insets = useSafeAreaInsets();136 const insets = useSafeAreaInsets();
137 const { fabClearance } = useAndroidControlSizing();
136 const projects = useProjects();138 const projects = useProjects();
137 const threads = useThreadShells();139 const threads = useThreadShells();
138 const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState();140 const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState();
...@@ -603,14 +605,14 @@ function ThreadNavigationSidebarPane(...@@ -603,14 +605,14 @@ function ThreadNavigationSidebarPane(
603 );605 );
604606
605 const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState<number | null>(null);607 const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState<number | null>(null);
606 const materialToolbarHeight = useMaterialToolbarHeight();608 const { height, paddingTop, paddingBottom } = useMaterialToolbarLayout();
607 // The sticky header (title row, search field, optional connection status)609 // The sticky header (title row, search field, optional connection status)
608 // is measured so the list inset always matches its real height — no610 // is measured so the list inset always matches its real height — no
609 // hardcoded per-variant constants.611 // hardcoded per-variant constants.
610 const stickyHeaderHeight =612 const stickyHeaderHeight =
611 measuredHeaderHeight ??613 measuredHeaderHeight ??
612 (Platform.OS === "android"614 (Platform.OS === "android"
613 ? Math.max(insets.top, 12) + materialToolbarHeight + 8615 ? paddingTop + height + paddingBottom
614 : insets.top + SIDEBAR_STICKY_HEADER_HEIGHT);616 : insets.top + SIDEBAR_STICKY_HEADER_HEIGHT);
615 const topListInset = stickyHeaderHeight + 6;617 const topListInset = stickyHeaderHeight + 6;
616 const handleStickyHeaderLayout = useCallback((event: LayoutChangeEvent) => {618 const handleStickyHeaderLayout = useCallback((event: LayoutChangeEvent) => {
...@@ -1039,7 +1041,7 @@ function ThreadNavigationSidebarPane(...@@ -1039,7 +1041,7 @@ function ThreadNavigationSidebarPane(
1039 {1041 {
1040 paddingBottom:1042 paddingBottom:
1041 Platform.OS === "android"1043 Platform.OS === "android"
1042 ? Math.max(insets.bottom, 16) + 148 - insets.bottom1044 ? Math.max(insets.bottom, 16) + fabClearance - insets.bottom
1043 : 16 + insets.bottom,1045 : 16 + insets.bottom,
1044 paddingTop: Platform.OS === "android" ? 6 : topListInset,1046 paddingTop: Platform.OS === "android" ? 6 : topListInset,
1045 },1047 },
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(...@@ -219,6 +219,16 @@ export function scaledTypographyLineHeight(
219 return Math.max(10, Math.round(role.lineHeight * scale));219 return Math.max(10, Math.round(role.lineHeight * scale));
220}220}
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
222export function resolveNativeMarkdownTypography(baseFontSize: number): NativeMarkdownTypography {232export function resolveNativeMarkdownTypography(baseFontSize: number): NativeMarkdownTypography {
223 const fontSizes = resolveMarkdownFontSizes(baseFontSize);233 const fontSizes = resolveMarkdownFontSizes(baseFontSize);
224 return {234 return {