diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts
index 97fe5cf977acc..3d2fad198cb13 100644
--- a/apps/mobile/app.config.ts
+++ b/apps/mobile/app.config.ts
@@ -264,6 +264,7 @@ const config: ExpoConfig = {
`applinks:${HOSTED_APP_HOST}`,
],
entitlements: {
+ "com.apple.developer.user-fonts": ["app-usage"],
"keychain-access-groups": [`$(AppIdentifierPrefix)${variant.iosBundleIdentifier}`],
},
infoPlist: {
diff --git a/apps/mobile/global.css b/apps/mobile/global.css
index 7a4cd4eff5046..9886ef4a3cc1f 100644
--- a/apps/mobile/global.css
+++ b/apps/mobile/global.css
@@ -46,10 +46,12 @@
/* ─── Custom utilities ──────────────────────────────────────────────── */
@utility font-t3-medium {
font-family: var(--font-medium);
+ font-weight: 500;
}
@utility font-t3-bold {
font-family: var(--font-bold);
+ font-weight: 700;
}
@utility font-t3-extrabold {
diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt
index 2dbbad891ae1f..f24b3f054820c 100644
--- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt
+++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt
@@ -284,11 +284,11 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
}
fun setFontFamily(fontFamily: String) {
- editor.typeface = if (fontFamily.contains("Mono", ignoreCase = true)) {
- Typeface.MONOSPACE
- } else {
- ReactFontManager.getInstance().getTypeface(fontFamily, Typeface.NORMAL, context.assets)
- }
+ editor.typeface = ReactFontManager.getInstance().getTypeface(
+ fontFamily,
+ Typeface.NORMAL,
+ context.assets,
+ )
applyLineHeight()
editor.applyPlaceholder()
}
diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift
index 87029ef29fdaf..ba18fec6c724c 100644
--- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift
+++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift
@@ -1151,9 +1151,21 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
return nil
}
+ private func resolvedComposerFont() -> UIFont {
+ if let font = UIFont(name: fontFamily, size: fontSize) {
+ return font
+ }
+ // Expo registers aliases through the family-name lookup, not UIFont(name:).
+ for name in UIFont.fontNames(forFamilyName: fontFamily) {
+ if let font = UIFont(name: name, size: fontSize) {
+ return font
+ }
+ }
+ return UIFont.systemFont(ofSize: fontSize)
+ }
+
private func baseAttributes() -> [NSAttributedString.Key: Any] {
- let font = UIFont(name: fontFamily, size: fontSize)
- ?? UIFont.systemFont(ofSize: fontSize)
+ let font = resolvedComposerFont()
let paragraph = NSMutableParagraphStyle()
paragraph.minimumLineHeight = lineHeight
paragraph.maximumLineHeight = lineHeight
@@ -1165,8 +1177,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
}
private func applyTypography() {
- let font = UIFont(name: fontFamily, size: fontSize)
- ?? UIFont.systemFont(ofSize: fontSize)
+ let font = resolvedComposerFont()
textView.font = font
restoreBaseTypingAttributes()
placeholderLabel.font = font
diff --git a/apps/mobile/modules/t3-native-controls/ios/T3InstalledFonts.swift b/apps/mobile/modules/t3-native-controls/ios/T3InstalledFonts.swift
new file mode 100644
index 0000000000000..e1a47ba4e7f3d
--- /dev/null
+++ b/apps/mobile/modules/t3-native-controls/ios/T3InstalledFonts.swift
@@ -0,0 +1,100 @@
+import CoreText
+import UIKit
+
+@MainActor
+enum T3InstalledFonts {
+ static func describe(_ font: UIFont, name: String? = nil) -> [String: Any] {
+ func variant(_ weight: UIFont.Weight) -> String {
+ let descriptor = UIFontDescriptor(fontAttributes: [
+ .family: font.familyName,
+ .traits: [UIFontDescriptor.TraitKey.weight: weight],
+ ])
+ let candidate = UIFont(descriptor: descriptor, size: font.pointSize)
+ return candidate.familyName == font.familyName ? candidate.fontName : font.fontName
+ }
+ // CoreText reports medium as 0.20 while UIKit requests 0.23.
+ var emphasisFaces: [String: String] = [:]
+ for (key, weight) in [("500", UIFont.Weight.medium), ("600", .semibold), ("700", .bold)] {
+ let name = variant(weight)
+ guard let candidate = UIFont(name: name, size: font.pointSize),
+ let traits = candidate.fontDescriptor.object(forKey: .traits) as? [UIFontDescriptor.TraitKey: Any],
+ let actual = traits[.weight] as? NSNumber,
+ abs(actual.doubleValue - Double(weight.rawValue)) < 0.04 else { continue }
+ emphasisFaces[key] = name
+ }
+ return [
+ "name": name ?? font.fontName,
+ "label": font.familyName,
+ "regular": font.fontName,
+ "medium": variant(.medium),
+ "bold": variant(.bold),
+ "emphasisFaces": emphasisFaces,
+ ]
+ }
+
+ static func resolve(_ names: [String], completion: @escaping ([[String: Any]]) -> Void) {
+ guard !names.isEmpty else { completion([]); return }
+ // Provider fonts must be requested again for each process. Persist names,
+ // not file URLs: iOS owns the files and the picker grants family access.
+ let descriptors = names.map { CTFontDescriptorCreateWithAttributes([kCTFontNameAttribute: $0] as CFDictionary) }
+ CTFontManagerRequestFonts(descriptors as CFArray) { _ in
+ DispatchQueue.main.async {
+ completion(names.compactMap { name in
+ guard let font = UIFont(name: name, size: 16) else { return nil }
+ return describe(font, name: name)
+ })
+ }
+ }
+ }
+}
+
+@MainActor
+final class T3FontPickerPresentation: NSObject, UIFontPickerViewControllerDelegate, UIAdaptivePresentationControllerDelegate {
+ private let completion: ([String: Any]?) -> Void
+ private var picker: UIFontPickerViewController?
+ private var finished = false
+
+ init(completion: @escaping ([String: Any]?) -> Void) {
+ self.completion = completion
+ }
+
+ func present(from presenter: UIViewController, selectedName: String?) {
+ let configuration = UIFontPickerViewController.Configuration()
+ configuration.includeFaces = false
+ let picker = UIFontPickerViewController(configuration: configuration)
+ if let selectedName, let font = UIFont(name: selectedName, size: 16) {
+ picker.selectedFontDescriptor = font.fontDescriptor
+ }
+ picker.delegate = self
+ self.picker = picker
+ presenter.present(picker, animated: true)
+ picker.presentationController?.delegate = self
+ }
+
+ func fontPickerViewControllerDidPickFont(_ viewController: UIFontPickerViewController) {
+ guard let descriptor = viewController.selectedFontDescriptor else { dismiss(); return }
+ let result = T3InstalledFonts.describe(UIFont(descriptor: descriptor, size: 16))
+ viewController.dismiss(animated: true) { self.finish(result) }
+ }
+
+ func fontPickerViewControllerDidCancel(_ viewController: UIFontPickerViewController) {
+ dismiss()
+ }
+
+ func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
+ finish(nil)
+ }
+
+ func dismiss() {
+ guard let picker else { finish(nil); return }
+ picker.dismiss(animated: true) { self.finish(nil) }
+ }
+
+ private func finish(_ result: [String: Any]?) {
+ guard !finished else { return }
+ finished = true
+ picker?.delegate = nil
+ picker = nil
+ completion(result)
+ }
+}
diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift
index 5b3d84aedcee3..50fb0fcde2ce3 100644
--- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift
+++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift
@@ -10,6 +10,7 @@ public final class T3NativeControlsModule: Module {
private let presentationSources = T3PresentationSources()
private var videoPresentation: T3NativeVideoPresentation?
private var filePresentation: T3NativeFilePresentation?
+ private var fontPresentation: T3FontPickerPresentation?
/// The last file preview that closed. Written on main, read synchronously from JS.
private let filePreviewActivity = NSLock()
private var finishedFilePreview: String?
@@ -66,6 +67,29 @@ public final class T3NativeControlsModule: Module {
try T3DpopKeys.sign(privateKey: privateKey, signingInput: signingInput)
}
+ AsyncFunction("pickInstalledFont") { (selectedName: String?, promise: Promise) in
+ MainActor.assumeIsolated {
+ guard self.fontPresentation == nil,
+ let presenter = self.appContext?.utilities?.currentViewController()
+ else {
+ promise.reject("ERR_FONT_PICKER", "Cannot present the font picker right now.")
+ return
+ }
+ let presentation = T3FontPickerPresentation { [weak self] result in
+ self?.fontPresentation = nil
+ promise.resolve(result)
+ }
+ self.fontPresentation = presentation
+ presentation.present(from: presenter, selectedName: selectedName)
+ }
+ }.runOnQueue(.main)
+
+ AsyncFunction("resolveInstalledFonts") { (names: [String], promise: Promise) in
+ MainActor.assumeIsolated {
+ T3InstalledFonts.resolve(names) { promise.resolve($0) }
+ }
+ }.runOnQueue(.main)
+
AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in
try self.presentVideo(
url: url,
@@ -111,9 +135,11 @@ public final class T3NativeControlsModule: Module {
OnDestroy {
let presentation = self.videoPresentation
let file = self.filePresentation
+ let font = self.fontPresentation
DispatchQueue.main.async {
presentation?.dismiss()
file?.dismiss()
+ font?.dismiss()
}
}
diff --git a/apps/mobile/src/components/ComposerEditor.ios.test.tsx b/apps/mobile/src/components/ComposerEditor.ios.test.tsx
index 029f454b902f7..6901aec6894b7 100644
--- a/apps/mobile/src/components/ComposerEditor.ios.test.tsx
+++ b/apps/mobile/src/components/ComposerEditor.ios.test.tsx
@@ -130,6 +130,12 @@ vi.mock("../lib/contextChipIconSource", () => ({ contextChipIconUri: () => "" })
vi.mock("../features/settings/appearance/useScaledTextRole", () => ({
useComposerTextRole: () => ({ fontSize: 16, lineHeight: 22 }),
}));
+vi.mock("../features/settings/appearance/AppearancePreferencesProvider", () => ({
+ useAppearancePreferences: () => ({
+ fonts: { composer: { regular: "System" } },
+ composerFontSize: 16,
+ }),
+}));
vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid", randomHex: () => "0000" }));
vi.mock("../state/assets", () => ({ assetEnvironment: {} }));
vi.mock("../state/attachments", () => ({ attachmentEnvironment: {} }));
diff --git a/apps/mobile/src/features/files/FileMarkdownPreview.tsx b/apps/mobile/src/features/files/FileMarkdownPreview.tsx
index 2d0de616bc698..273b45e84fec3 100644
--- a/apps/mobile/src/features/files/FileMarkdownPreview.tsx
+++ b/apps/mobile/src/features/files/FileMarkdownPreview.tsx
@@ -9,7 +9,6 @@ import { ScrollView, View } from "react-native";
import { RefreshControl } from "../../components/ui/RefreshControl";
import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
-import { FONT_FAMILIES } from "../../lib/fontFamilies";
import { resolveNativeMarkdownTypography } from "../../lib/appearancePreferences";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
import {
@@ -40,8 +39,8 @@ interface MarkdownPreviewStyles {
}
export function useMarkdownPreviewStyles(): MarkdownPreviewStyles {
- const { appearance } = useAppearancePreferences();
- const nativeMarkdownTypography = resolveNativeMarkdownTypography(appearance.baseFontSize);
+ const { fonts, replyFontSize, replyEmphasisFontFamily } = useAppearancePreferences();
+ const nativeMarkdownTypography = resolveNativeMarkdownTypography(replyFontSize);
const theme = useUniwindTheme();
const body = theme["--color-md-body"];
const strong = theme["--color-md-strong"];
@@ -50,8 +49,8 @@ export function useMarkdownPreviewStyles(): MarkdownPreviewStyles {
const codeBackground = theme["--color-md-code-bg"];
const codeText = theme["--color-md-code-text"];
const horizontalRule = theme["--color-md-hr"];
- const regularFontFamily = FONT_FAMILIES.regular;
- const boldFontFamily = FONT_FAMILIES.bold;
+ const regularFontFamily = fonts.reply.regular;
+ const boldFontFamily = fonts.reply.bold;
return {
nativeTextStyle: {
@@ -69,9 +68,10 @@ export function useMarkdownPreviewStyles(): MarkdownPreviewStyles {
fontSize: nativeMarkdownTypography.fontSize,
lineHeight: nativeMarkdownTypography.lineHeight,
headingFontSizes: nativeMarkdownTypography.headingFontSizes,
+ codeFontFamily: fonts.code.regular,
fontFamily: regularFontFamily,
headingFontFamily: boldFontFamily,
- boldFontFamily,
+ boldFontFamily: replyEmphasisFontFamily,
},
};
}
diff --git a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx
index 717e71362a593..610e53786c037 100644
--- a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx
@@ -6,6 +6,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { SettingsScreen } from "./components/SettingsScreen";
import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSection";
import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection";
+import { FontAppearanceSection } from "./appearance/sections/FontAppearanceSection";
import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection";
import { ThemeAppearanceSection } from "./appearance/sections/ThemeAppearanceSection";
import { ToolCallsAppearanceSection } from "./appearance/sections/ToolCallsAppearanceSection";
@@ -27,6 +28,7 @@ export function SettingsAppearanceRouteScreen() {
{Platform.OS === "android" ? : null}
+
diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx
index 26e17f45afb13..74d650181f46b 100644
--- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx
+++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx
@@ -1,4 +1,13 @@
-import { createContext, startTransition, use, useMemo, type ReactNode } from "react";
+import {
+ createContext,
+ startTransition,
+ use,
+ useCallback,
+ useMemo,
+ useState,
+ useEffect,
+ type ReactNode,
+} from "react";
import { AppState, Appearance, Platform, useColorScheme } from "react-native";
import { manufacturer } from "expo-device";
@@ -19,6 +28,19 @@ import {
} from "../../../lib/appearancePreferences";
import { appAtomRegistry } from "../../../state/atom-registry";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences";
+import {
+ normalizeFontSelections,
+ reconcileInstalledFonts,
+ normalizeReplyEmphasisWeights,
+ resolveReplyEmphasis,
+ type ReplyEmphasisWeight,
+ resolveMobileFonts,
+ type MobileFontRole,
+ type MobileFontSelections,
+ type InstalledMobileFont,
+} from "../../../lib/mobileFonts";
+import { resolveInstalledFonts } from "../../../native/installed-fonts";
+import { normalizeBaseFontSize } from "../../../lib/appearancePreferences";
import type { Preferences } from "../../../persistence/mobile-preferences";
import {
isSystemColorsAvailable,
@@ -45,6 +67,20 @@ import {
} from "../../../lib/mobileThemeRuntime";
interface AppearancePreferencesContextValue {
+ readonly fonts: ReturnType;
+ readonly fontSelections: MobileFontSelections;
+ readonly installedFonts: ReadonlyMap;
+ readonly unavailableFonts: readonly string[];
+ readonly replyFontSize: number;
+ readonly replyEmphasisWeight: ReplyEmphasisWeight | undefined;
+ readonly replyEmphasisFontFamily: string;
+ readonly setReplyEmphasisWeight: (weight: ReplyEmphasisWeight | undefined) => void;
+ readonly composerFontSize: number;
+ readonly customReplyFontSize: boolean;
+ readonly customComposerFontSize: boolean;
+ readonly setFontSelection: (role: MobileFontRole, selection: string) => void;
+ readonly setReplyFontSize: (size: number | null) => void;
+ readonly setComposerFontSize: (size: number | null) => void;
readonly appearance: ResolvedAppearance;
readonly themeId: MobileThemeId;
readonly themeIds: MobileThemeIds;
@@ -222,6 +258,84 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
() => resolveAppearance({ baseFontSize, codeFontSize, codeWordBreak, terminalFontSize }),
[baseFontSize, codeFontSize, codeWordBreak, terminalFontSize],
);
+ const fontSelections = useMemo(
+ () => normalizeFontSelections(storedPreferences?.fontSelections),
+ [storedPreferences?.fontSelections],
+ );
+ const selectedFontNames = useMemo(
+ () => [
+ ...new Set(
+ Object.values(fontSelections)
+ .filter((selection) => selection.startsWith("installed:"))
+ .map((selection) => selection.slice(10)),
+ ),
+ ],
+ [fontSelections],
+ );
+ const [installedFonts, setInstalledFonts] = useState>(
+ new Map(),
+ );
+ const [unavailableFonts, setUnavailableFonts] = useState([]);
+ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reloads native installed font faces when selections change or the app returns to the foreground
+ useEffect(() => {
+ let cancelled = false;
+ let generation = 0;
+ const updateUnavailableFonts = (missing: readonly string[]) => {
+ setUnavailableFonts((previous) =>
+ previous.length === missing.length &&
+ previous.every((name, index) => name === missing[index])
+ ? previous
+ : missing,
+ );
+ };
+ const refresh = () => {
+ generation = generation + 1;
+ const request = generation;
+ void resolveInstalledFonts(selectedFontNames)
+ .then((resolved) => {
+ if (cancelled || request !== generation) return;
+ const available = new Map(resolved.map((font) => [font.name, font]));
+ setInstalledFonts((previous) => reconcileInstalledFonts(previous, available));
+ updateUnavailableFonts(selectedFontNames.filter((name) => !available.has(name)));
+ })
+ .catch(() => {
+ if (cancelled || request !== generation) return;
+ setInstalledFonts((previous) => (previous.size ? new Map() : previous));
+ updateUnavailableFonts(selectedFontNames);
+ });
+ };
+ refresh();
+ const subscription = AppState.addEventListener("change", (state) => {
+ if (state === "active") refresh();
+ });
+ return () => {
+ cancelled = true;
+ subscription.remove();
+ };
+ }, [selectedFontNames]);
+ const fonts = useMemo(
+ () => resolveMobileFonts(fontSelections, installedFonts, Platform.OS),
+ [fontSelections, installedFonts],
+ );
+ const replyEmphasisWeight = normalizeReplyEmphasisWeights(
+ storedPreferences?.replyEmphasisWeights,
+ )[fonts.reply.regular];
+ const replyEmphasisFontFamily = resolveReplyEmphasis(fonts.reply, replyEmphasisWeight);
+ const fontVariables = useMemo(
+ () => ({
+ ...themeVariables,
+ "--font-sans": fonts.interface.regular,
+ "--font-medium": fonts.interface.medium,
+ "--font-bold": fonts.interface.bold,
+ }),
+ [themeVariables, fonts.interface],
+ );
+ const replyFontSize = normalizeBaseFontSize(storedPreferences?.replyFontSize ?? baseFontSize);
+ const composerFontSize = normalizeBaseFontSize(
+ storedPreferences?.composerFontSize ?? baseFontSize,
+ );
+ const customReplyFontSize = storedPreferences?.replyFontSize != null;
+ const customComposerFontSize = storedPreferences?.composerFontSize != null;
const isReady = AsyncResult.isSuccess(preferencesResult);
const updatePreferences = (patch: Partial) => {
startTransition(() => savePreferences(patch));
@@ -290,12 +404,61 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
};
const mathRenderingEnabled = storedPreferences?.mathRenderingEnabled === true;
- const setMathRenderingEnabled = (value: boolean) =>
- updatePreferences({ mathRenderingEnabled: value });
+ const setMathRenderingEnabled = useCallback(
+ (value: boolean) => updatePreferences({ mathRenderingEnabled: value }),
+ [updatePreferences],
+ );
+ const setFontSelection = useCallback(
+ (role: MobileFontRole, selection: string) => {
+ savePreferences({
+ transform: (current) => ({
+ fontSelections: { ...normalizeFontSelections(current.fontSelections), [role]: selection },
+ }),
+ });
+ },
+ [savePreferences],
+ );
+ const setReplyEmphasisWeight = useCallback(
+ (weight: ReplyEmphasisWeight | undefined) => {
+ savePreferences({
+ transform: (current) => {
+ const next = { ...normalizeReplyEmphasisWeights(current.replyEmphasisWeights) };
+ if (weight === undefined) delete next[fonts.reply.regular];
+ else next[fonts.reply.regular] = weight;
+ return { replyEmphasisWeights: next };
+ },
+ });
+ },
+ [fonts.reply.regular, savePreferences],
+ );
+ const setReplyFontSize = useCallback(
+ (size: number | null) =>
+ updatePreferences({ replyFontSize: size === null ? null : normalizeBaseFontSize(size) }),
+ [updatePreferences],
+ );
+ const setComposerFontSize = useCallback(
+ (size: number | null) =>
+ updatePreferences({ composerFontSize: size === null ? null : normalizeBaseFontSize(size) }),
+ [updatePreferences],
+ );
const value = useMemo(
(): AppearancePreferencesContextValue => ({
appearance,
+ fonts,
+ fontSelections,
+ installedFonts,
+ unavailableFonts,
+ replyFontSize,
+ replyEmphasisWeight,
+ replyEmphasisFontFamily,
+ setReplyEmphasisWeight,
+ composerFontSize,
+ customReplyFontSize,
+ customComposerFontSize,
+ setFontSelection,
+ setReplyFontSize,
+ setComposerFontSize,
themeId,
themeIds,
themeMode,
@@ -320,6 +483,20 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
}),
[
appearance,
+ fonts,
+ fontSelections,
+ installedFonts,
+ unavailableFonts,
+ replyFontSize,
+ replyEmphasisWeight,
+ replyEmphasisFontFamily,
+ setReplyEmphasisWeight,
+ composerFontSize,
+ customReplyFontSize,
+ customComposerFontSize,
+ setFontSelection,
+ setReplyFontSize,
+ setComposerFontSize,
themeId,
themeIds,
themeMode,
@@ -346,7 +523,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
return (
- {props.children}
+ {props.children}
);
diff --git a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx
index afcf4ba30d9a1..96d396e7c5b57 100644
--- a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx
+++ b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx
@@ -20,12 +20,17 @@ export function AppearancePreviewSeparator() {
export function TextAppearancePreview(props: { readonly fontSize: number }) {
const sizes = resolveMarkdownFontSizes(props.fontSize);
+ const { fonts } = useAppearancePreferences();
return (
The quick brown fox jumps over the lazy dog.
diff --git a/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx
new file mode 100644
index 0000000000000..f8b42bf860ec0
--- /dev/null
+++ b/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx
@@ -0,0 +1,236 @@
+import { useState } from "react";
+import { Alert, View } from "react-native";
+import { useAtomSet } from "@effect/atom-react";
+import { AppText as Text } from "../../../../components/ui/AppText";
+import { ListRowLayout } from "../../../../components/ui/ListRowLayout";
+import { Menu } from "../../../../components/ui/Menu";
+import { SettingsSection } from "../../components/SettingsSection";
+import { ListActionRow as SettingsActionRow } from "../../../../components/ui/ListActionRow";
+import { SettingsSwitchRow } from "../../components/SettingsSwitchRow";
+import { FontSizeSliderRow } from "../components/FontSizeSliderRow";
+import { useAppearancePreferences } from "../AppearancePreferencesProvider";
+import { updateMobilePreferencesAtom } from "../../../../state/preferences";
+import { canPickInstalledFont, pickInstalledFont } from "../../../../native/installed-fonts";
+import {
+ BUILT_IN_FONT_OPTIONS,
+ REPLY_EMPHASIS_OPTIONS,
+ DEFAULT_FONT_SELECTIONS,
+ type MobileFontRole,
+} from "../../../../lib/mobileFonts";
+import { MIN_BASE_FONT_SIZE, MAX_BASE_FONT_SIZE } from "../../../../lib/appearancePreferences";
+
+const ROLES: readonly { role: MobileFontRole; label: string }[] = [
+ { role: "interface", label: "Interface font" },
+ { role: "reply", label: "Reply font" },
+ { role: "composer", label: "Composer font" },
+ { role: "code", label: "Chat code font" },
+];
+
+export function FontAppearanceSection() {
+ const {
+ isReady,
+ fontSelections,
+ installedFonts,
+ unavailableFonts,
+ fonts,
+ replyFontSize,
+ replyEmphasisWeight,
+ replyEmphasisFontFamily,
+ setReplyEmphasisWeight,
+ composerFontSize,
+ customReplyFontSize,
+ customComposerFontSize,
+ setFontSelection,
+ setReplyFontSize,
+ setComposerFontSize,
+ } = useAppearancePreferences();
+ const savePreferences = useAtomSet(updateMobilePreferencesAtom, { mode: "promise" });
+ const [busy, setBusy] = useState(false);
+ const options = [
+ ...BUILT_IN_FONT_OPTIONS,
+ ...[...installedFonts.values()].map((font) => ({
+ id: `installed:${font.name}`,
+ label: font.label,
+ })),
+ ];
+ const report = (error: unknown) =>
+ Alert.alert(
+ "Custom fonts",
+ error instanceof Error ? error.message : "Could not update fonts. Please try again.",
+ );
+
+ const chooseInstalledFont = (role: MobileFontRole) => {
+ setBusy(true);
+ const selection = fontSelections[role];
+ void pickInstalledFont(selection.startsWith("installed:") ? selection.slice(10) : null)
+ .then((font) => {
+ if (font) setFontSelection(role, `installed:${font.name}`);
+ })
+ .catch(report)
+ .finally(() => setBusy(false));
+ };
+
+ return (
+
+
+
+ A reply in your selected font.{" "}
+
+ Emphasized text.
+
+
+
+ Defaults follow the interface. These choices stay on this device.
+
+
+ {ROLES.map(({ role, label }) => (
+
+ ))}
+
+
+
+ Emphasis is remembered for this reply font. Unavailable weights are disabled.
+
+
+ setReplyFontSize(enabled ? replyFontSize : null)}
+ />
+ {customReplyFontSize ? (
+
+ ) : null}
+ setComposerFontSize(enabled ? composerFontSize : null)}
+ />
+ {customComposerFontSize ? (
+
+ ) : null}
+ {unavailableFonts.length ? (
+
+
+ Unavailable fonts: {unavailableFonts.join(", ")}. Using defaults. Choose an installed
+ font again or select Default.
+
+
+ ) : null}
+
+ void savePreferences({
+ fontSelections: DEFAULT_FONT_SELECTIONS,
+ replyEmphasisWeights: {},
+ replyFontSize: null,
+ composerFontSize: null,
+ }).catch(report)
+ }
+ />
+
+ );
+}
diff --git a/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts b/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts
index c71298a89822a..ba87cd2d268f3 100644
--- a/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts
+++ b/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts
@@ -16,7 +16,8 @@ export function useScaledTextRole(role: keyof typeof MOBILE_TYPOGRAPHY): ScaledT
}
export function useComposerTextRole(): ScaledTextRole {
- const body = useScaledTextRole("body");
+ const { composerFontSize } = useAppearancePreferences();
+ const body = resolveScaledTextRole("body", composerFontSize);
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 fdd6b414a46e5..f7ba19fa557ff 100644
--- a/apps/mobile/src/features/threads/ThreadComposer.tsx
+++ b/apps/mobile/src/features/threads/ThreadComposer.tsx
@@ -431,7 +431,7 @@ export const THREAD_COMPOSER_BOTTOM_GUTTER = 6;
export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps) {
const project = useProject(scopeProjectRef(props.environmentId, props.selectedThread.projectId));
- const { themeVariables: materialTheme } = useAppearancePreferences();
+ const { themeVariables: materialTheme, fonts } = useAppearancePreferences();
const composerPanel = materialTheme["--color-composer-panel"];
const navigation = useNavigation();
const foregroundColor = useUniwindTheme()["--color-foreground"];
@@ -1109,6 +1109,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
{...compactEditor.editorProps}
textStyle={{
...bodyText,
+ fontFamily: fonts.composer.regular,
color: foregroundColor,
}}
/>
diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx
index 6419c7dc7778a..045512d296212 100644
--- a/apps/mobile/src/features/threads/ThreadFeed.tsx
+++ b/apps/mobile/src/features/threads/ThreadFeed.tsx
@@ -146,7 +146,6 @@ import { PresentationSource } from "../../components/ui/NativePresentation";
import { NativeScrollViewMarker } from "../../native/NativeScrollViewMarker";
import Animated, { FadeIn, FadeInDown, type SharedValue } from "react-native-reanimated";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
-import { FONT_FAMILIES } from "../../lib/fontFamilies";
import { scopedThreadKey } from "../../lib/scopedEntities";
import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
import { downloadAndShareAttachment } from "../../lib/attachmentDownload";
@@ -1297,8 +1296,15 @@ function MarkdownDetailsFold(props: {
}
function useMarkdownStyles(): MarkdownStyleSets {
- const { appearance } = useAppearancePreferences();
- const nativeMarkdownTypography = resolveNativeMarkdownTypography(appearance.baseFontSize);
+ const { appearance, fonts, replyFontSize, replyEmphasisFontFamily } = useAppearancePreferences();
+ const replyTypography = useMemo(
+ () => resolveNativeMarkdownTypography(replyFontSize),
+ [replyFontSize],
+ );
+ const nativeMarkdownTypography = useMemo(
+ () => resolveNativeMarkdownTypography(appearance.baseFontSize),
+ [appearance.baseFontSize],
+ );
const theme = useUniwindTheme();
const markdownBodyColor = theme["--color-md-body"];
const markdownStrongColor = theme["--color-md-strong"];
@@ -1318,55 +1324,81 @@ function useMarkdownStyles(): MarkdownStyleSets {
const markdownUserCodeText = theme["--color-md-user-code-text"];
const markdownUserInlineCodeText = theme["--color-user-bubble-foreground-muted"];
const markdownUserFenceBg = theme["--color-md-user-fence-bg"];
- const regularFontFamily = FONT_FAMILIES.regular;
- const boldFontFamily = FONT_FAMILIES.bold;
-
- return {
- user: {
- nativeTextStyle: {
- color: markdownUserBodyColor,
- strongColor: markdownUserBodyColor,
- mutedColor: markdownUserBodyColor,
- linkColor: markdownUserBodyColor,
- inlineCodeColor: markdownUserInlineCodeText,
- codeColor: markdownUserCodeText,
- codeBackgroundColor: markdownUserCodeBg,
- codeBlockBackgroundColor: markdownUserFenceBg,
- fileTextColor: markdownUserBodyColor,
- quoteMarkerColor: markdownUserBodyColor,
- dividerColor: markdownUserBodyColor,
- contextChipBorderColor,
- fontSize: nativeMarkdownTypography.fontSize,
- lineHeight: nativeMarkdownTypography.lineHeight,
- headingFontSizes: nativeMarkdownTypography.headingFontSizes,
- fontFamily: regularFontFamily,
- headingFontFamily: boldFontFamily,
- boldFontFamily,
+ const regularFontFamily = fonts.interface.regular;
+ const boldFontFamily = fonts.interface.bold;
+
+ return useMemo(() => {
+ return {
+ user: {
+ nativeTextStyle: {
+ color: markdownUserBodyColor,
+ strongColor: markdownUserBodyColor,
+ mutedColor: markdownUserBodyColor,
+ linkColor: markdownUserBodyColor,
+ inlineCodeColor: markdownUserInlineCodeText,
+ codeColor: markdownUserCodeText,
+ codeBackgroundColor: markdownUserCodeBg,
+ codeBlockBackgroundColor: markdownUserFenceBg,
+ fileTextColor: markdownUserBodyColor,
+ quoteMarkerColor: markdownUserBodyColor,
+ dividerColor: markdownUserBodyColor,
+ contextChipBorderColor,
+ fontSize: nativeMarkdownTypography.fontSize,
+ lineHeight: nativeMarkdownTypography.lineHeight,
+ headingFontSizes: nativeMarkdownTypography.headingFontSizes,
+ codeFontFamily: fonts.code.regular,
+ fontFamily: regularFontFamily,
+ headingFontFamily: boldFontFamily,
+ boldFontFamily,
+ },
},
- },
- assistant: {
- nativeTextStyle: {
- color: markdownBodyColor,
- strongColor: markdownStrongColor,
- mutedColor: markdownBodyColor,
- linkColor: markdownLinkColor,
- inlineCodeColor: markdownInlineCodeText,
- codeColor: markdownCodeText,
- codeBackgroundColor: markdownCodeBg,
- codeBlockBackgroundColor: markdownCodeBg,
- fileTextColor: markdownCodeText,
- quoteMarkerColor: markdownBlockquoteBorder,
- dividerColor: markdownHrColor,
- contextChipBorderColor,
- fontSize: nativeMarkdownTypography.fontSize,
- lineHeight: nativeMarkdownTypography.lineHeight,
- headingFontSizes: nativeMarkdownTypography.headingFontSizes,
- fontFamily: regularFontFamily,
- headingFontFamily: boldFontFamily,
- boldFontFamily,
+ assistant: {
+ nativeTextStyle: {
+ color: markdownBodyColor,
+ strongColor: markdownStrongColor,
+ mutedColor: markdownBodyColor,
+ linkColor: markdownLinkColor,
+ inlineCodeColor: markdownInlineCodeText,
+ codeColor: markdownCodeText,
+ codeBackgroundColor: markdownCodeBg,
+ codeBlockBackgroundColor: markdownCodeBg,
+ fileTextColor: markdownCodeText,
+ quoteMarkerColor: markdownBlockquoteBorder,
+ dividerColor: markdownHrColor,
+ contextChipBorderColor,
+ fontSize: replyTypography.fontSize,
+ lineHeight: replyTypography.lineHeight,
+ headingFontSizes: replyTypography.headingFontSizes,
+ codeFontFamily: fonts.code.regular,
+ fontFamily: fonts.reply.regular,
+ headingFontFamily: fonts.reply.bold,
+ boldFontFamily: replyEmphasisFontFamily,
+ },
},
- },
- };
+ };
+ }, [
+ fonts.reply,
+ replyEmphasisFontFamily,
+ fonts.code.regular,
+ replyTypography,
+ boldFontFamily,
+ contextChipBorderColor,
+ markdownBlockquoteBorder,
+ markdownBodyColor,
+ markdownCodeBg,
+ markdownCodeText,
+ markdownHrColor,
+ markdownInlineCodeText,
+ markdownLinkColor,
+ markdownStrongColor,
+ markdownUserBodyColor,
+ markdownUserCodeBg,
+ markdownUserCodeText,
+ markdownUserFenceBg,
+ markdownUserInlineCodeText,
+ nativeMarkdownTypography,
+ regularFontFamily,
+ ]);
}
/** Messages from other agents fold to one line so the user's own messages stay easy to find. */
@@ -2765,11 +2797,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
const copyFeedbackTimeoutRef = useRef | null>(null);
const headerMaterialVisibleRef = useRef(false);
const { width: windowWidth, fontScale } = useWindowDimensions();
- const { appearance } = useAppearancePreferences();
+ const { appearance, fonts, replyFontSize, replyEmphasisFontFamily } = useAppearancePreferences();
const workRowSizing = deriveThreadWorkLogSizing({
baseFontSize: appearance.baseFontSize,
fontScale,
});
+ const textLayoutKey = `${workRowSizing.textSizeKey}:${replyFontSize}:${fonts.interface.regular}:${fonts.interface.bold}:${fonts.reply.regular}:${fonts.reply.bold}:${replyEmphasisFontFamily}:${fonts.code.regular}`;
const settledPaneWidth = useWorkspaceSettledContentWidth();
const splitLayout = (props.layoutVariant ?? "compact") !== "compact";
const [measuredViewportWidth, setViewportWidth] = useState(() => (splitLayout ? 0 : windowWidth));
@@ -3703,7 +3736,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
// reads getFixedItemSize only when it renders, so its size cache is cleared
// after the commit that hands it rows at the new geometry; clearing earlier
// re-caches old sizes.
- const { textSizeKey } = workRowSizing;
+ const textSizeKey = textLayoutKey;
const hasHtmlRenders = presentedFeed.some((entry) => entry.type === "html-render");
const previousRowGeometry = useRef({ textSizeKey, contentWidth });
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears LegendList size caches after the commit that brings new row geometry
diff --git a/apps/mobile/src/lib/filePreview.test.ts b/apps/mobile/src/lib/filePreview.test.ts
index 8363b0ba5c958..629bd8e56c9af 100644
--- a/apps/mobile/src/lib/filePreview.test.ts
+++ b/apps/mobile/src/lib/filePreview.test.ts
@@ -19,6 +19,7 @@ import { acquireAudioSession, activateAudioSession, releaseAudioSession } from "
import { ThreadFeed } from "../features/threads/ThreadFeed";
import type { ThreadFeedEntry } from "./threadActivity";
import type { MarkdownImageRenderer } from "../native/SelectableMarkdownText";
+import { DEFAULT_FONT_SELECTIONS, resolveMobileFonts } from "./mobileFonts";
const audio = vi.hoisted(() => ({
status: {
@@ -268,7 +269,13 @@ vi.mock("../features/layout/workspace-content-width", () => ({
useWorkspaceSettledContentWidth: () => null,
}));
vi.mock("../features/settings/appearance/AppearancePreferencesProvider", () => ({
- useAppearancePreferences: () => ({ appearance: { baseFontSize: 14 }, themeAppearance: "dark" }),
+ useAppearancePreferences: () => ({
+ appearance: { baseFontSize: 14 },
+ themeAppearance: "dark",
+ fonts: resolveMobileFonts(DEFAULT_FONT_SELECTIONS, new Map(), "ios"),
+ replyFontSize: 14,
+ replyEmphasisFontFamily: "DMSans-Bold",
+ }),
}));
vi.mock("./useUniwindTheme", () => ({
useUniwindTheme: () => new Proxy({}, { get: () => "#000000" }),
diff --git a/apps/mobile/src/lib/fontFamilies.ts b/apps/mobile/src/lib/fontFamilies.ts
index 38602ec7c9892..4c7a237df3767 100644
--- a/apps/mobile/src/lib/fontFamilies.ts
+++ b/apps/mobile/src/lib/fontFamilies.ts
@@ -2,4 +2,5 @@ export const FONT_FAMILIES = {
regular: "DMSans-Regular",
medium: "DMSans-Medium",
bold: "DMSans-Bold",
+ emphasisFaces: { "500": "DMSans-Medium", "700": "DMSans-Bold" },
} as const;
diff --git a/apps/mobile/src/lib/mobileFonts.test.ts b/apps/mobile/src/lib/mobileFonts.test.ts
new file mode 100644
index 0000000000000..c29c3f3fef1d7
--- /dev/null
+++ b/apps/mobile/src/lib/mobileFonts.test.ts
@@ -0,0 +1,180 @@
+import { describe, expect, it } from "vite-plus/test";
+import {
+ DEFAULT_FONT_SELECTIONS,
+ normalizeFontSelections,
+ reconcileInstalledFonts,
+ normalizeReplyEmphasisWeights,
+ resolveReplyEmphasis,
+ resolveMobileFonts,
+} from "./mobileFonts";
+
+const serif = {
+ name: "ExampleSerif-Regular",
+ label: "Example Serif",
+ regular: "ExampleSerif-Regular",
+ medium: "ExampleSerif-Medium",
+ bold: "ExampleSerif-Bold",
+ emphasisFaces: {
+ "500": "ExampleSerif-Medium",
+ "600": "ExampleSerif-Semibold",
+ "700": "ExampleSerif-Bold",
+ },
+};
+
+describe("installed mobile fonts", () => {
+ it("preserves installed font names and resets removed import choices and invalid preferences", () => {
+ expect(
+ normalizeFontSelections({
+ interface: "serif",
+ reply: "installed:ExampleSerif-Regular",
+ composer: "imported:Old Family",
+ code: "installed:",
+ }),
+ ).toEqual({
+ interface: "serif",
+ reply: "installed:ExampleSerif-Regular",
+ composer: "",
+ code: "",
+ });
+ expect(
+ normalizeFontSelections({ reply: 42, interface: "installed:" + "x".repeat(251) }),
+ ).toEqual(DEFAULT_FONT_SELECTIONS);
+ });
+
+ it("uses the selected family's native weight faces independently for each role", () => {
+ const fonts = resolveMobileFonts(
+ {
+ ...DEFAULT_FONT_SELECTIONS,
+ interface: "serif",
+ reply: `installed:${serif.name}`,
+ code: "monospace",
+ },
+ new Map([[serif.name, serif]]),
+ "ios",
+ );
+ expect(fonts.interface.regular).toBe("Georgia");
+ expect(fonts.composer).toEqual(fonts.interface);
+ expect(fonts.reply).toMatchObject({
+ regular: serif.regular,
+ medium: serif.medium,
+ bold: serif.bold,
+ });
+ expect(fonts.code.regular).toBe("Menlo-Regular");
+ });
+
+ it("falls back while resolving or after an installed font is removed", () => {
+ const fonts = resolveMobileFonts(
+ { ...DEFAULT_FONT_SELECTIONS, reply: `installed:${serif.name}` },
+ new Map(),
+ "ios",
+ );
+ expect(fonts.reply).toEqual(fonts.interface);
+ });
+
+ it("keeps Android's system family choices without a file import dependency", () => {
+ const fonts = resolveMobileFonts(
+ { interface: "system", reply: "serif", composer: "monospace", code: "" },
+ new Map(),
+ "android",
+ );
+ expect(fonts.interface.regular).toBe("sans-serif");
+ expect(fonts.reply.regular).toBe("serif");
+ expect(fonts.composer.regular).toBe("monospace");
+ expect(fonts.code.regular).toBe("monospace");
+ });
+});
+
+describe("reply emphasis", () => {
+ it("preserves the default bold face and only changes emphasis for a selected weight", () => {
+ const standard = resolveMobileFonts(DEFAULT_FONT_SELECTIONS, new Map(), "ios");
+ expect(resolveReplyEmphasis(standard.reply)).toBe("DMSans-Bold");
+ expect(resolveReplyEmphasis(standard.reply, "500")).toBe("DMSans-Medium");
+ });
+
+ it("selects a real installed semibold face and falls back when that weight is unavailable", () => {
+ expect(resolveReplyEmphasis(serif, "600")).toBe("ExampleSerif-Semibold");
+ expect(
+ resolveReplyEmphasis(
+ {
+ regular: "Example-Regular",
+ medium: "Example-Regular",
+ bold: "Example-Bold",
+ emphasisFaces: { "700": "Example-Bold" },
+ },
+ "600",
+ ),
+ ).toBe("Example-Bold");
+ });
+
+ it("remembers different weights per font and drops invalid saved values", () => {
+ const saved = normalizeReplyEmphasisWeights({
+ "FirstSerif-Regular": "700",
+ "SecondSerif-Regular": "600",
+ invalid: 600,
+ bad: "900",
+ "": "500",
+ });
+ expect(saved).toEqual({ "FirstSerif-Regular": "700", "SecondSerif-Regular": "600" });
+ expect(normalizeReplyEmphasisWeights(null)).toEqual({});
+ expect(resolveReplyEmphasis(serif, saved["SecondSerif-Regular"])).toBe("ExampleSerif-Semibold");
+ });
+});
+
+describe("font refresh identity", () => {
+ it("keeps the map and font references when native descriptors are unchanged or reordered", () => {
+ const second = { ...serif, name: "Other-Regular", regular: "Other-Regular" };
+ const current = new Map([
+ [serif.name, serif],
+ [second.name, second],
+ ]);
+ const refreshed = new Map([
+ [second.name, { ...second }],
+ [serif.name, { ...serif, emphasisFaces: { ...serif.emphasisFaces } }],
+ ]);
+ expect(reconcileInstalledFonts(current, refreshed)).toBe(current);
+ });
+
+ it("applies changed faces and removals while keeping unrelated font references", () => {
+ const second = { ...serif, name: "Other-Regular", regular: "Other-Regular" };
+ const current = new Map([
+ [serif.name, serif],
+ [second.name, second],
+ ]);
+ const changed = {
+ ...serif,
+ emphasisFaces: { ...serif.emphasisFaces, "600": "Replacement-Semibold" },
+ };
+ const next = reconcileInstalledFonts(
+ current,
+ new Map([
+ [serif.name, changed],
+ [second.name, { ...second }],
+ ]),
+ );
+ expect(next).not.toBe(current);
+ expect(next.get(serif.name)?.emphasisFaces?.["600"]).toBe("Replacement-Semibold");
+ expect(next.get(second.name)).toBe(second);
+ expect(current.get(serif.name)?.emphasisFaces?.["600"]).toBe("ExampleSerif-Semibold");
+ const removed = reconcileInstalledFonts(next, new Map([[second.name, second]]));
+ expect(removed.has(serif.name)).toBe(false);
+ const empty = reconcileInstalledFonts(removed, new Map());
+ expect(empty.size).toBe(0);
+ expect(reconcileInstalledFonts(empty, new Map())).toBe(empty);
+ });
+
+ it.each(["ios", "android"])(
+ "reuses built-in family objects across unrelated %s font refreshes",
+ (platform) => {
+ const selections = {
+ interface: "system",
+ reply: "serif",
+ composer: "monospace",
+ code: "monospace",
+ };
+ const before = resolveMobileFonts(selections, new Map(), platform);
+ const after = resolveMobileFonts(selections, new Map([[serif.name, serif]]), platform);
+ for (const role of ["interface", "reply", "composer", "code"] as const)
+ expect(after[role]).toBe(before[role]);
+ },
+ );
+});
diff --git a/apps/mobile/src/lib/mobileFonts.ts b/apps/mobile/src/lib/mobileFonts.ts
new file mode 100644
index 0000000000000..0d15d6a4475f7
--- /dev/null
+++ b/apps/mobile/src/lib/mobileFonts.ts
@@ -0,0 +1,138 @@
+import { FONT_FAMILIES } from "./fontFamilies";
+
+export const MOBILE_FONT_ROLES = ["interface", "reply", "composer", "code"] as const;
+export type MobileFontRole = (typeof MOBILE_FONT_ROLES)[number];
+export type MobileFontSelections = Readonly>;
+export interface MobileFontFamily {
+ readonly regular: string;
+ readonly medium: string;
+ readonly bold: string;
+ readonly emphasisFaces?: Readonly>>;
+}
+export interface InstalledMobileFont extends MobileFontFamily {
+ readonly name: string;
+ readonly label: string;
+}
+export const DEFAULT_FONT_SELECTIONS: MobileFontSelections = {
+ interface: "",
+ reply: "",
+ composer: "",
+ code: "",
+};
+export const BUILT_IN_FONT_OPTIONS = [
+ { id: "", label: "Default" },
+ { id: "system", label: "System sans" },
+ { id: "serif", label: "Serif" },
+ { id: "monospace", label: "Monospace" },
+] as const;
+
+const BUILT_IN_FAMILIES = {
+ ios: {
+ system: { regular: "System", medium: "System", bold: "System" },
+ serif: {
+ regular: "Georgia",
+ medium: "Georgia",
+ bold: "Georgia-Bold",
+ emphasisFaces: { "700": "Georgia-Bold" },
+ },
+ mono: {
+ regular: "Menlo-Regular",
+ medium: "Menlo-Regular",
+ bold: "Menlo-Bold",
+ emphasisFaces: { "700": "Menlo-Bold" },
+ },
+ },
+ android: {
+ system: { regular: "sans-serif", medium: "sans-serif-medium", bold: "sans-serif" },
+ serif: { regular: "serif", medium: "serif", bold: "serif" },
+ mono: { regular: "monospace", medium: "monospace", bold: "monospace" },
+ },
+} as const;
+
+export function reconcileInstalledFonts(
+ previous: ReadonlyMap,
+ available: ReadonlyMap,
+): ReadonlyMap {
+ let changed = previous.size !== available.size;
+ const next = new Map();
+ for (const [name, font] of available) {
+ const prior = previous.get(name);
+ const same =
+ prior &&
+ (["name", "label", "regular", "medium", "bold"] as const).every(
+ (key) => prior[key] === font[key],
+ ) &&
+ REPLY_EMPHASIS_OPTIONS.every(
+ ({ id }) => prior.emphasisFaces?.[id] === font.emphasisFaces?.[id],
+ );
+ if (!same) changed = true;
+ next.set(name, same ? prior : font);
+ }
+ return changed ? next : previous;
+}
+
+export function normalizeFontSelections(value: unknown): MobileFontSelections {
+ const record =
+ typeof value === "object" && value !== null ? (value as Record) : {};
+ return Object.fromEntries(
+ MOBILE_FONT_ROLES.map((role) => {
+ const candidate = record[role];
+ return [
+ role,
+ typeof candidate === "string" &&
+ candidate.length <= 250 &&
+ (BUILT_IN_FONT_OPTIONS.some((option) => option.id === candidate) ||
+ (candidate.startsWith("installed:") && candidate.slice(10).trim().length > 0))
+ ? candidate
+ : "",
+ ];
+ }),
+ ) as unknown as MobileFontSelections;
+}
+
+export function resolveMobileFonts(
+ selections: MobileFontSelections,
+ installed: ReadonlyMap,
+ platform: string,
+): Readonly> {
+ const defaults = FONT_FAMILIES;
+ const { system, serif, mono } = BUILT_IN_FAMILIES[platform === "ios" ? "ios" : "android"];
+ const resolve = (selection: string, fallback: MobileFontFamily): MobileFontFamily => {
+ if (selection === "system") return system;
+ if (selection === "serif") return serif;
+ if (selection === "monospace") return mono;
+ if (!selection.startsWith("installed:")) return fallback;
+ return installed.get(selection.slice(10)) ?? fallback;
+ };
+ const interfaceFont = resolve(selections.interface, defaults);
+ return {
+ interface: interfaceFont,
+ reply: resolve(selections.reply, interfaceFont),
+ composer: resolve(selections.composer, interfaceFont),
+ code: resolve(selections.code, mono),
+ };
+}
+
+export const REPLY_EMPHASIS_OPTIONS = [
+ { id: "500", label: "Medium" },
+ { id: "600", label: "Semibold" },
+ { id: "700", label: "Bold" },
+] as const;
+export type ReplyEmphasisWeight = (typeof REPLY_EMPHASIS_OPTIONS)[number]["id"];
+export type ReplyEmphasisWeights = Readonly>;
+
+export function normalizeReplyEmphasisWeights(value: unknown): ReplyEmphasisWeights {
+ if (typeof value !== "object" || value === null) return {};
+ return Object.fromEntries(
+ Object.entries(value).filter(
+ (entry): entry is [string, ReplyEmphasisWeight] =>
+ entry[0].length > 0 &&
+ entry[0].length <= 250 &&
+ REPLY_EMPHASIS_OPTIONS.some((option) => option.id === entry[1]),
+ ),
+ );
+}
+
+export function resolveReplyEmphasis(font: MobileFontFamily, weight?: ReplyEmphasisWeight) {
+ return (weight && font.emphasisFaces?.[weight]) || font.bold;
+}
diff --git a/apps/mobile/src/lib/useMobileNavigationTheme.ts b/apps/mobile/src/lib/useMobileNavigationTheme.ts
index 54412e14cb2a8..580b1dfa6184d 100644
--- a/apps/mobile/src/lib/useMobileNavigationTheme.ts
+++ b/apps/mobile/src/lib/useMobileNavigationTheme.ts
@@ -5,12 +5,20 @@ import { useAppearancePreferences } from "../features/settings/appearance/Appear
import { useUniwindTheme } from "./useUniwindTheme";
export function useMobileNavigationTheme(surface: "screen" | "sidebar" = "screen"): Theme {
- const { themeAppearance: appearance } = useAppearancePreferences();
+ const { themeAppearance: appearance, fonts, fontSelections } = useAppearancePreferences();
const variables = useUniwindTheme();
return useMemo(() => {
const base = appearance === "dark" ? DarkTheme : DefaultTheme;
return {
...base,
+ fonts: fontSelections.interface
+ ? {
+ regular: { fontFamily: fonts.interface.regular, fontWeight: "400" },
+ medium: { fontFamily: fonts.interface.medium, fontWeight: "500" },
+ bold: { fontFamily: fonts.interface.bold, fontWeight: "700" },
+ heavy: { fontFamily: fonts.interface.bold, fontWeight: "800" },
+ }
+ : base.fonts,
colors: {
...base.colors,
primary: variables["--color-primary-text"],
@@ -24,5 +32,5 @@ export function useMobileNavigationTheme(surface: "screen" | "sidebar" = "screen
notification: variables["--color-danger-foreground"],
},
};
- }, [appearance, surface, variables]);
+ }, [appearance, fonts.interface, fontSelections.interface, surface, variables]);
}
diff --git a/apps/mobile/src/native/EnrichedMarkdownText.tsx b/apps/mobile/src/native/EnrichedMarkdownText.tsx
index 86bf8021bdd0f..9e2c1c926c621 100644
--- a/apps/mobile/src/native/EnrichedMarkdownText.tsx
+++ b/apps/mobile/src/native/EnrichedMarkdownText.tsx
@@ -45,7 +45,7 @@ function enrichedStyle(
dark: boolean,
fontScale: number,
): MarkdownStyle {
- const monospace = Platform.OS === "ios" ? "Menlo" : "monospace";
+ const monospace = style.codeFontFamily ?? (Platform.OS === "ios" ? "Menlo" : "monospace");
// Keep the radius modest: iOS clips square table-cell borders without stroking the corner arcs.
const block = {
marginTop: 4,
@@ -90,7 +90,11 @@ function enrichedStyle(
h4: heading(4),
h5: heading(5),
h6: heading(6),
- strong: { color: style.strongColor, fontFamily: style.boldFontFamily, fontWeight: "normal" },
+ strong: {
+ color: style.strongColor,
+ fontFamily: style.boldFontFamily,
+ fontWeight: style.boldFontFamily === style.fontFamily ? "bold" : "normal",
+ },
link: { color: style.linkColor, underline: false },
code: {
fontFamily: monospace,
diff --git a/apps/mobile/src/native/SelectableMarkdownText.types.ts b/apps/mobile/src/native/SelectableMarkdownText.types.ts
index 63f8429c386fd..5f5371d4eac0e 100644
--- a/apps/mobile/src/native/SelectableMarkdownText.types.ts
+++ b/apps/mobile/src/native/SelectableMarkdownText.types.ts
@@ -15,6 +15,7 @@ export interface NativeMarkdownTextStyle {
readonly contextChipBorderColor?: string;
readonly fontSize: number;
readonly lineHeight: number;
+ readonly codeFontFamily?: string;
readonly fontFamily: string;
readonly headingFontFamily: string;
readonly boldFontFamily: string;
diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx
index 56f80be2e7be8..cae9c11e96b5a 100644
--- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx
+++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx
@@ -1,3 +1,4 @@
+import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
import { PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES } from "@t3tools/client-runtime/text-paste";
import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts";
import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
@@ -26,7 +27,6 @@ import {
import { contextChipIconUri, modelChipIcon, modelChipIconUri } from "../lib/contextChipIconSource";
import { useUniwindTheme } from "../lib/useUniwindTheme";
import { createNativeComposerTheme } from "../lib/nativeComposerTheme";
-import { FONT_FAMILIES } from "../lib/fontFamilies";
import { useComposerTextRole } from "../features/settings/appearance/useScaledTextRole";
import {
acknowledgeComposerNativeEvent,
@@ -178,7 +178,8 @@ export function ComposerEditor({
}
const bodyText = useComposerTextRole();
const theme = useUniwindTheme();
- const fontFamily = FONT_FAMILIES.regular;
+ const { fonts } = useAppearancePreferences();
+ const fontFamily = fonts.composer.regular;
const skillLabels = useMemo(() => composerSkillLabels(skills), [skills]);
const { modelsBySource, modelTokens } = useComposerModelTokens(props.value, models);
diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx
index 5aabd8e0d6071..112e740dde9a8 100644
--- a/apps/mobile/src/native/T3ComposerEditor.native.tsx
+++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx
@@ -26,9 +26,7 @@ import {
contextRecordFileName,
} from "../lib/composerChipPresentation";
import { contextChipIconUri, modelChipIcon, modelChipIconUri } from "../lib/contextChipIconSource";
-import { MOBILE_TYPOGRAPHY } from "../lib/typography";
import { useNativePaste } from "../lib/useNativePaste";
-import { FONT_FAMILIES } from "../lib/fontFamilies";
import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
import { useUniwindTheme } from "../lib/useUniwindTheme";
import { createNativeComposerTheme } from "../lib/nativeComposerTheme";
@@ -306,13 +304,13 @@ export function ComposerEditor({
},
[nativeEventSnapshotsRef],
);
- const { systemColorsActive } = useAppearancePreferences();
+ const { systemColorsActive, fonts, composerFontSize } = useAppearancePreferences();
const themeJson = JSON.stringify({
...createNativeComposerTheme(theme),
selection: Platform.OS === "android" || systemColorsActive ? theme["--color-focus"] : null,
});
const resolvedTextStyle = StyleSheet.flatten(textStyle) ?? {};
- const regularFontFamily = FONT_FAMILIES.regular;
+ const regularFontFamily = fonts.composer.regular;
return (
(null);
const bodyText = useScaledTextRole("body");
- const fontFamily = FONT_FAMILIES.regular;
+ const { fonts, composerFontSize } = useAppearancePreferences();
+ const fontFamily = fonts.composer.regular;
const handlePaste = useNativePaste((uris) => onPasteImages?.(uris));
useImperativeHandle(
@@ -58,6 +59,8 @@ export function ComposerEditor({
minHeight: 0,
fontFamily,
...bodyText,
+ fontSize: composerFontSize,
+ lineHeight: Math.round((composerFontSize * 23) / 16),
paddingVertical: contentInsetVertical,
},
textStyle,
diff --git a/apps/mobile/src/native/installed-fonts.test.ts b/apps/mobile/src/native/installed-fonts.test.ts
new file mode 100644
index 0000000000000..6970024aca5e6
--- /dev/null
+++ b/apps/mobile/src/native/installed-fonts.test.ts
@@ -0,0 +1,61 @@
+import { expect, it, vi } from "vite-plus/test";
+import type { InstalledMobileFont } from "../lib/mobileFonts";
+
+const native = vi.hoisted(() => ({
+ resolveInstalledFonts: vi.fn<(names: readonly string[]) => Promise>(),
+ pickInstalledFont: vi.fn<() => Promise>(),
+}));
+
+vi.mock("expo", () => ({ requireOptionalNativeModule: () => native }));
+vi.mock("react-native", () => ({ Platform: { OS: "ios" } }));
+
+async function setup() {
+ vi.resetModules();
+ native.resolveInstalledFonts.mockReset();
+ native.pickInstalledFont.mockReset();
+ return import("./installed-fonts");
+}
+
+it("does not request a missing font again until the user selects it in the picker", async () => {
+ const fonts = await setup();
+ const font = {
+ name: "Removed-Regular",
+ label: "Removed",
+ regular: "Removed-Regular",
+ medium: "Removed-Regular",
+ bold: "Removed-Regular",
+ };
+ native.resolveInstalledFonts.mockResolvedValue([]);
+ expect(await fonts.resolveInstalledFonts([font.name])).toEqual([]);
+ expect(await fonts.resolveInstalledFonts([font.name])).toEqual([]);
+ expect(native.resolveInstalledFonts).toHaveBeenCalledTimes(1);
+
+ native.pickInstalledFont.mockResolvedValue(null);
+ await fonts.pickInstalledFont(font.name);
+ expect(await fonts.resolveInstalledFonts([font.name])).toEqual([]);
+ expect(native.resolveInstalledFonts).toHaveBeenCalledTimes(1);
+
+ native.pickInstalledFont.mockResolvedValue(font);
+ await fonts.pickInstalledFont(font.name);
+ native.resolveInstalledFonts.mockResolvedValue([font]);
+ expect(await fonts.resolveInstalledFonts([font.name])).toEqual([font]);
+ native.resolveInstalledFonts.mockResolvedValue([]);
+ expect(await fonts.resolveInstalledFonts([font.name])).toEqual([]);
+ expect(await fonts.resolveInstalledFonts([font.name])).toEqual([]);
+ expect(native.resolveInstalledFonts).toHaveBeenCalledTimes(3);
+});
+
+it("shares an in-flight request and permits another attempt after a native failure", async () => {
+ const fonts = await setup();
+ const pending = Promise.withResolvers();
+ native.resolveInstalledFonts.mockReturnValue(pending.promise);
+ const first = fonts.resolveInstalledFonts(["Removed-Regular"]);
+ const second = fonts.resolveInstalledFonts(["Removed-Regular"]);
+ const settled = Promise.allSettled([first, second]);
+ pending.reject(new Error("Native request failed"));
+ expect((await settled).map((result) => result.status)).toEqual(["rejected", "rejected"]);
+ expect(native.resolveInstalledFonts).toHaveBeenCalledTimes(1);
+ native.resolveInstalledFonts.mockResolvedValue([]);
+ expect(await fonts.resolveInstalledFonts(["Removed-Regular"])).toEqual([]);
+ expect(native.resolveInstalledFonts).toHaveBeenCalledTimes(2);
+});
diff --git a/apps/mobile/src/native/installed-fonts.ts b/apps/mobile/src/native/installed-fonts.ts
new file mode 100644
index 0000000000000..41b9a6579722c
--- /dev/null
+++ b/apps/mobile/src/native/installed-fonts.ts
@@ -0,0 +1,52 @@
+import { requireOptionalNativeModule } from "expo";
+import { Platform } from "react-native";
+import type { InstalledMobileFont } from "../lib/mobileFonts";
+
+const nativeControls = requireOptionalNativeModule<{
+ pickInstalledFont?: (selectedName: string | null) => Promise;
+ resolveInstalledFonts?: (names: readonly string[]) => Promise;
+}>("T3NativeControls");
+
+export const canPickInstalledFont =
+ Platform.OS === "ios" && typeof nativeControls?.pickInstalledFont === "function";
+
+// CoreText can show a system dialog for missing fonts, including on foreground refresh.
+const missingFontNames = new Set();
+const pendingResolutions = new Map>();
+
+export async function pickInstalledFont(selectedName: string | null) {
+ if (!canPickInstalledFont || !nativeControls?.pickInstalledFont)
+ throw new Error("Installed font selection requires an updated iOS app.");
+ const font = await nativeControls.pickInstalledFont(selectedName);
+ if (font) {
+ missingFontNames.delete(font.name);
+ pendingResolutions.delete(font.name);
+ }
+ return font;
+}
+
+export async function resolveInstalledFonts(names: readonly string[]) {
+ if (!names.length || !nativeControls?.resolveInstalledFonts) return [];
+ const requestedNames = [...new Set(names)].filter(
+ (name) => !missingFontNames.has(name) && !pendingResolutions.has(name),
+ );
+ if (requestedNames.length) {
+ const request = nativeControls.resolveInstalledFonts(requestedNames);
+ for (const name of requestedNames) {
+ const resolution = request
+ .then((fonts) => {
+ const font = fonts.find((candidate) => candidate.name === name);
+ if (!font && pendingResolutions.get(name) === resolution) missingFontNames.add(name);
+ return font;
+ })
+ .finally(() => {
+ if (pendingResolutions.get(name) === resolution) pendingResolutions.delete(name);
+ });
+ pendingResolutions.set(name, resolution);
+ }
+ }
+ const resolved = await Promise.all(
+ [...new Set(names)].map((name) => pendingResolutions.get(name)),
+ );
+ return resolved.filter((font) => font !== undefined);
+}
diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts
index f29fbc8c119b4..b1c2b99d8b43f 100644
--- a/apps/mobile/src/persistence/mobile-preferences.ts
+++ b/apps/mobile/src/persistence/mobile-preferences.ts
@@ -24,6 +24,13 @@ import * as MobileDatabase from "./mobile-database";
import * as MobileSecureStorage from "./mobile-secure-storage";
import { MobileStorageDecodeError, MobileStorageEncodeError } from "./mobile-storage";
+import {
+ normalizeFontSelections,
+ normalizeReplyEmphasisWeights,
+ type MobileFontSelections,
+ type ReplyEmphasisWeights,
+} from "../lib/mobileFonts";
+
const PREFERENCES_KEY = "t3code.preferences";
const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback";
const encodeJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
@@ -99,6 +106,10 @@ export interface Preferences {
readonly darkThemeId?: MobileThemeId;
readonly themeMode?: MobileThemeMode;
readonly androidNavigationStyle?: AndroidNavigationStyle;
+ readonly fontSelections?: MobileFontSelections;
+ readonly replyFontSize?: number | null;
+ readonly replyEmphasisWeights?: ReplyEmphasisWeights;
+ readonly composerFontSize?: number | null;
readonly baseFontSize?: number;
readonly terminalFontSize?: number | null;
readonly markdownFontSize?: number;
@@ -188,6 +199,10 @@ function sanitizePreferences(parsed: Preferences): Preferences {
darkThemeId?: MobileThemeId;
themeMode?: MobileThemeMode;
androidNavigationStyle?: AndroidNavigationStyle;
+ fontSelections?: MobileFontSelections;
+ replyFontSize?: number | null;
+ replyEmphasisWeights?: ReplyEmphasisWeights;
+ composerFontSize?: number | null;
baseFontSize?: number;
terminalFontSize?: number | null;
markdownFontSize?: number;
@@ -268,6 +283,14 @@ function sanitizePreferences(parsed: Preferences): Preferences {
if (parsed.androidNavigationStyle === "one-ui" || parsed.androidNavigationStyle === "material") {
preferences.androidNavigationStyle = parsed.androidNavigationStyle;
}
+ if (parsed.fontSelections !== undefined)
+ preferences.fontSelections = normalizeFontSelections(parsed.fontSelections);
+ if (parsed.replyEmphasisWeights !== undefined)
+ preferences.replyEmphasisWeights = normalizeReplyEmphasisWeights(parsed.replyEmphasisWeights);
+ if (typeof parsed.replyFontSize === "number" || parsed.replyFontSize === null)
+ preferences.replyFontSize = parsed.replyFontSize;
+ if (typeof parsed.composerFontSize === "number" || parsed.composerFontSize === null)
+ preferences.composerFontSize = parsed.composerFontSize;
if (typeof parsed.baseFontSize === "number") preferences.baseFontSize = parsed.baseFontSize;
if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) {
preferences.terminalFontSize = parsed.terminalFontSize;
diff --git a/docs/user/appearance.md b/docs/user/appearance.md
index c37855fdf3c87..29447e9370048 100644
--- a/docs/user/appearance.md
+++ b/docs/user/appearance.md
@@ -14,6 +14,20 @@ those modes. Customize these shortcuts under **Settings → Keybindings**.
On mobile, open **Settings → Appearance**. Mobile has its own themes and text,
code, and terminal preferences. It does not follow environment themes or defaults.
+Under mobile **Appearance → Fonts**, choose fonts independently for the interface, replies,
+composer, and code in messages or Markdown previews. Reply and composer sizes can follow Text size
+or use their own size. Diffs and the terminal keep their existing fonts.
+
+On iOS, choose **Choose installed font…** from a font menu to open the system font picker.
+iOS manages each family's matching styles. Installed fonts must be available to apps through iOS;
+if a selected font becomes unavailable, the app uses its default. Android offers the built-in
+system sans, serif, and monospace choices.
+
+Use **Reply emphasis weight** to choose an available Medium, Semibold, or Bold face for emphasized
+reply text. Default keeps the font's existing emphasis. The choice is remembered for each reply
+font, so changing fonts restores that font's choice. Unavailable weights are disabled. Reset font
+choices and message sizes to clear these selections. Fonts and preferences stay on this device.
+
On Android 12 or newer, choose the **Material You** theme in Appearance to use colors from
your wallpaper. Selecting another theme replaces those colors. Like other themes, Material You
can be selected separately for light and dark appearances.