From 0fda10580f10392b7d7c57aadff8c10f59e9b2a6 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Wed, 11 Nov 2026 19:22:40 -0500 Subject: [PATCH] fix(mobile): iOS composer text follows the system text size On iOS, text typed in the message composer stayed at the in-app size when Larger Text was raised, while the conversation around it scaled. The native editor builds a fixed UIFont, so adjustsFontForContentSizeCategory had no effect. The composer now multiplies its body font size and line height by the system font scale on iOS, and uses the same values for the editor and its height math. Android already sizes the editor in sp and is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../src/components/ComposerEditor.ios.test.tsx | 2 +- .../settings/appearance/useScaledTextRole.ts | 10 +++++++++- apps/mobile/src/features/threads/ThreadComposer.tsx | 4 ++-- apps/mobile/src/lib/appearancePreferences.test.ts | 12 +++++++++++- apps/mobile/src/lib/appearancePreferences.ts | 11 +++++++++++ apps/mobile/src/native/T3ComposerEditor.ios.tsx | 4 ++-- 6 files changed, 36 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/components/ComposerEditor.ios.test.tsx b/apps/mobile/src/components/ComposerEditor.ios.test.tsx index f7ce1f9918d85..029f454b902f7 100644 --- a/apps/mobile/src/components/ComposerEditor.ios.test.tsx +++ b/apps/mobile/src/components/ComposerEditor.ios.test.tsx @@ -128,7 +128,7 @@ vi.mock("../lib/useUniwindTheme", () => ({ useUniwindTheme: () => ({}) })); vi.mock("../lib/nativeComposerTheme", () => ({ createNativeComposerTheme: () => ({}) })); vi.mock("../lib/contextChipIconSource", () => ({ contextChipIconUri: () => "" })); vi.mock("../features/settings/appearance/useScaledTextRole", () => ({ - useScaledTextRole: () => ({ fontSize: 16, lineHeight: 22 }), + useComposerTextRole: () => ({ fontSize: 16, lineHeight: 22 }), })); vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid", randomHex: () => "0000" })); vi.mock("../state/assets", () => ({ assetEnvironment: {} })); diff --git a/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts b/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts index ffeb6114c17a5..c71298a89822a 100644 --- a/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts +++ b/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts @@ -1,4 +1,6 @@ -import { resolveScaledTextRole } from "../../../lib/appearancePreferences"; +import { Platform, useWindowDimensions } from "react-native"; + +import { resolveComposerTextRole, resolveScaledTextRole } from "../../../lib/appearancePreferences"; import { MOBILE_TYPOGRAPHY } from "../../../lib/typography"; import { useAppearancePreferences } from "./AppearancePreferencesProvider"; @@ -12,3 +14,9 @@ export function useScaledTextRole(role: keyof typeof MOBILE_TYPOGRAPHY): ScaledT const { appearance } = useAppearancePreferences(); return resolveScaledTextRole(role, appearance.baseFontSize); } + +export function useComposerTextRole(): ScaledTextRole { + const body = useScaledTextRole("body"); + const { fontScale } = useWindowDimensions(); + return resolveComposerTextRole(body, fontScale, Platform.OS); +} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index e36900736e8e2..86fdc55de72a1 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -87,7 +87,7 @@ import { type DraftComposerFileAttachment, } from "../../lib/composerImages"; import { buildModelOptions, isModelSelectionUnavailable } from "../../lib/modelOptions"; -import { useScaledTextRole, type ScaledTextRole } from "../settings/appearance/useScaledTextRole"; +import { useComposerTextRole, type ScaledTextRole } from "../settings/appearance/useScaledTextRole"; import type { RemoteClientConnectionState } from "../../lib/connection"; import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { Menu } from "../../components/ui/Menu"; @@ -417,7 +417,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps const composerPanel = materialTheme["--color-composer-panel"]; const navigation = useNavigation(); const foregroundColor = useUniwindTheme()["--color-foreground"]; - const bodyText = useScaledTextRole("body"); + const bodyText = useComposerTextRole(); const fallbackInputRef = useRef(null); const inputRef = props.editorRef ?? fallbackInputRef; const [isFocused, setIsFocused] = useState(false); diff --git a/apps/mobile/src/lib/appearancePreferences.test.ts b/apps/mobile/src/lib/appearancePreferences.test.ts index 1bfedc7529c7c..5b1e9c33a90e1 100644 --- a/apps/mobile/src/lib/appearancePreferences.test.ts +++ b/apps/mobile/src/lib/appearancePreferences.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolveAppearancePreferences } from "./appearancePreferences"; +import { resolveAppearancePreferences, resolveComposerTextRole } from "./appearancePreferences"; describe("appearancePreferences", () => { it("migrates the legacy markdownFontSize key to baseFontSize", () => { @@ -9,4 +9,14 @@ describe("appearancePreferences", () => { resolveAppearancePreferences({ baseFontSize: 16, markdownFontSize: 18 }).baseFontSize, ).toBe(16); }); + + it("scales the iOS composer text with the system text size", () => { + const body = { fontSize: 16, lineHeight: 23 }; + expect(resolveComposerTextRole(body, 1, "ios")).toEqual(body); + expect(resolveComposerTextRole(body, 2.643, "ios")).toEqual({ + fontSize: 16 * 2.643, + lineHeight: 23 * 2.643, + }); + expect(resolveComposerTextRole(body, 2.643, "android")).toEqual(body); + }); }); diff --git a/apps/mobile/src/lib/appearancePreferences.ts b/apps/mobile/src/lib/appearancePreferences.ts index 9b0be82b2c03f..6a7485e07c722 100644 --- a/apps/mobile/src/lib/appearancePreferences.ts +++ b/apps/mobile/src/lib/appearancePreferences.ts @@ -205,6 +205,17 @@ export function resolveScaledTextRole(role: keyof typeof MOBILE_TYPOGRAPHY, base }; } +// React Native text applies the system text size itself, but the iOS composer sets a fixed +// UIFont, so it needs pre-scaled metrics. Android's editor already sizes text in sp. +export function resolveComposerTextRole( + role: { readonly fontSize: number; readonly lineHeight: number }, + fontScale: number, + os: string, +) { + if (os !== "ios") return role; + return { fontSize: role.fontSize * fontScale, lineHeight: role.lineHeight * fontScale }; +} + export function resolveNativeMarkdownTypography(baseFontSize: number): NativeMarkdownTypography { const fontSizes = resolveMarkdownFontSizes(baseFontSize); return { diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index 437bcda4d012d..56f80be2e7be8 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -27,7 +27,7 @@ import { contextChipIconUri, modelChipIcon, modelChipIconUri } from "../lib/cont import { useUniwindTheme } from "../lib/useUniwindTheme"; import { createNativeComposerTheme } from "../lib/nativeComposerTheme"; import { FONT_FAMILIES } from "../lib/fontFamilies"; -import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; +import { useComposerTextRole } from "../features/settings/appearance/useScaledTextRole"; import { acknowledgeComposerNativeEvent, assumeComposerControlledState, @@ -176,7 +176,7 @@ export function ComposerEditor({ }); setConfirmedTokens({ value: props.value, tokens }); } - const bodyText = useScaledTextRole("body"); + const bodyText = useComposerTextRole(); const theme = useUniwindTheme(); const fontFamily = FONT_FAMILIES.regular;