diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts
index e6d388b9a9856..1390004c57596 100644
--- a/apps/mobile/app.config.ts
+++ b/apps/mobile/app.config.ts
@@ -254,6 +254,7 @@ const config: ExpoConfig = {
`webcredentials:${variant.relyingParty}`,
],
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 730b9e1a97f34..0b18ff9c0217a 100644
--- a/apps/mobile/global.css
+++ b/apps/mobile/global.css
@@ -42,10 +42,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 efa54e2ec374a..1065e78f0acad 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
@@ -28,6 +28,7 @@ import android.view.inputmethod.InputConnection
import android.view.inputmethod.InputConnectionWrapper
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
+import com.facebook.react.common.assets.ReactFontManager
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
@@ -270,11 +271,12 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
}
fun setFontFamily(fontFamily: String) {
- editor.typeface = if (fontFamily.contains("Mono", ignoreCase = true)) {
- Typeface.MONOSPACE
- } else {
- Typeface.DEFAULT
- }
+ 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 f54f75caf5f50..c9cca4ae7c759 100644
--- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift
+++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift
@@ -1060,9 +1060,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
@@ -1074,8 +1086,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 a5b8f7a44e3a6..5df7bc0ffa04b 100644
--- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift
+++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift
@@ -8,6 +8,7 @@ public final class T3NativeControlsModule: Module {
private let presentationSources = T3PresentationSources()
private var videoPresentation: T3NativeVideoPresentation?
private var filePresentation: T3NativeFilePresentation?
+ private var fontPresentation: T3FontPickerPresentation?
public func definition() -> ModuleDefinition {
Events("videoPlaybackChange")
@@ -51,6 +52,29 @@ public final class T3NativeControlsModule: Module {
ViewName("ScrollEdgeBleed")
}
+ 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,
@@ -84,9 +108,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/features/files/FileMarkdownPreview.tsx b/apps/mobile/src/features/files/FileMarkdownPreview.tsx
index f9f81459bbf08..4a6466ffeb699 100644
--- a/apps/mobile/src/features/files/FileMarkdownPreview.tsx
+++ b/apps/mobile/src/features/files/FileMarkdownPreview.tsx
@@ -8,7 +8,6 @@ import { StackActions, useNavigation } from "@react-navigation/native";
import { RefreshControl, ScrollView, View } from "react-native";
import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
-import { FONT_FAMILIES } from "../../lib/fontFamilies";
import { resolveNativeMarkdownTypography } from "../../lib/appearancePreferences";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
import {
@@ -39,8 +38,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"];
@@ -49,8 +48,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: {
@@ -68,9 +67,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 8045fb3dd3d4e..4f76a801235c8 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";
@@ -26,6 +27,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..d98e70423dae1
--- /dev/null
+++ b/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx
@@ -0,0 +1,229 @@
+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/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx
index 52ef705e93b29..41e8e0a6f325b 100644
--- a/apps/mobile/src/features/threads/ThreadComposer.tsx
+++ b/apps/mobile/src/features/threads/ThreadComposer.tsx
@@ -410,11 +410,16 @@ 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, composerFontSize } = useAppearancePreferences();
const composerPanel = materialTheme["--color-composer-panel"];
const navigation = useNavigation();
const foregroundColor = useUniwindTheme()["--color-foreground"];
- const bodyText = useScaledTextRole("body");
+ const scaledBodyText = useScaledTextRole("body");
+ const bodyText = {
+ ...scaledBodyText,
+ fontSize: composerFontSize,
+ lineHeight: Math.round((composerFontSize * 23) / 16),
+ };
const fallbackInputRef = useRef(null);
const inputRef = props.editorRef ?? fallbackInputRef;
const [isFocused, setIsFocused] = useState(false);
@@ -1047,6 +1052,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 fb4fd3fe1123e..03a3b05e96e2b 100644
--- a/apps/mobile/src/features/threads/ThreadFeed.tsx
+++ b/apps/mobile/src/features/threads/ThreadFeed.tsx
@@ -144,7 +144,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
-import { FONT_FAMILIES } from "../../lib/fontFamilies";
import { scopedThreadKey } from "../../lib/scopedEntities";
import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
import { downloadAndShareAttachment } from "../../lib/attachmentDownload";
@@ -1298,8 +1297,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"];
@@ -1319,55 +1325,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. */
@@ -2623,11 +2655,12 @@ export const ThreadFeed = memo(function ThreadFeed({
const userScrollSettleTimerRef = 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 [measuredViewportWidth, setViewportWidth] = useState(() =>
props.layoutVariant === "split" ? 0 : windowWidth,
@@ -3563,7 +3596,7 @@ export const ThreadFeed = memo(function ThreadFeed({
// 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 and starts a reflow 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 97a5051577026..31203be8858b3 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: {
@@ -257,7 +258,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 6fa8c229a9539..1916de8389b26 100644
--- a/apps/mobile/src/native/EnrichedMarkdownText.tsx
+++ b/apps/mobile/src/native/EnrichedMarkdownText.tsx
@@ -37,7 +37,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,
@@ -82,7 +82,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 686007015268b..1ae7afbb3e421 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 c649b12292be6..22002c3d8d0a5 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";
@@ -25,8 +26,6 @@ import {
import { contextChipIconUri } from "../lib/contextChipIconSource";
import { useUniwindTheme } from "../lib/useUniwindTheme";
import { createNativeComposerTheme } from "../lib/nativeComposerTheme";
-import { FONT_FAMILIES } from "../lib/fontFamilies";
-import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
import {
acknowledgeComposerNativeEvent,
assumeComposerControlledState,
@@ -159,9 +158,9 @@ export function ComposerEditor({
});
setConfirmedTokens({ value: props.value, tokens });
}
- const bodyText = useScaledTextRole("body");
const theme = useUniwindTheme();
- const fontFamily = FONT_FAMILIES.regular;
+ const { fonts, composerFontSize } = useAppearancePreferences();
+ const fontFamily = fonts.composer.regular;
useImperativeHandle(
ref,
@@ -297,12 +296,12 @@ export function ComposerEditor({
fontSize={
typeof resolvedTextStyle.fontSize === "number"
? resolvedTextStyle.fontSize
- : bodyText.fontSize
+ : composerFontSize
}
lineHeight={
typeof resolvedTextStyle.lineHeight === "number"
? resolvedTextStyle.lineHeight
- : bodyText.lineHeight
+ : Math.round((composerFontSize * 23) / 16)
}
contentInsetVertical={contentInsetVertical}
editable={props.editable ?? true}
diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx
index e1ed9f7425668..0eae8dac3131a 100644
--- a/apps/mobile/src/native/T3ComposerEditor.native.tsx
+++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx
@@ -24,9 +24,7 @@ import {
contextRecordFileName,
} from "../lib/composerChipPresentation";
import { contextChipIconUri } 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";
@@ -279,13 +277,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(
@@ -56,6 +57,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.ts b/apps/mobile/src/native/installed-fonts.ts
new file mode 100644
index 0000000000000..6af819090fa0f
--- /dev/null
+++ b/apps/mobile/src/native/installed-fonts.ts
@@ -0,0 +1,22 @@
+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";
+
+export async function pickInstalledFont(selectedName: string | null) {
+ if (!canPickInstalledFont || !nativeControls?.pickInstalledFont)
+ throw new Error("Installed font selection requires an updated iOS app.");
+ return nativeControls.pickInstalledFont(selectedName);
+}
+
+export async function resolveInstalledFonts(names: readonly string[]) {
+ if (!names.length || !nativeControls?.resolveInstalledFonts) return [];
+ return nativeControls.resolveInstalledFonts(names);
+}
diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts
index 43ac75e185411..05b93d5187eec 100644
--- a/apps/mobile/src/persistence/mobile-preferences.ts
+++ b/apps/mobile/src/persistence/mobile-preferences.ts
@@ -23,6 +23,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";
@@ -97,6 +104,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;
@@ -179,6 +190,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;
@@ -262,6 +277,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 49d8e02d79a82..97aca2f1747ea 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.