1import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "./typography";
2import {
3 DEFAULT_TERMINAL_FONT_SIZE,
4 MAX_TERMINAL_FONT_SIZE,
5 MIN_TERMINAL_FONT_SIZE,
6 TERMINAL_FONT_SIZE_STEP,
7 normalizeTerminalFontSize,
8} from "../features/terminal/terminalPreferences";
9
10export const DEFAULT_BASE_FONT_SIZE = MOBILE_TYPOGRAPHY.body.fontSize;
11export const MIN_BASE_FONT_SIZE = 11;
12export const MAX_BASE_FONT_SIZE = 22;
13export const BASE_FONT_SIZE_STEP = 1;
14
15export const DEFAULT_CODE_FONT_SIZE = MOBILE_CODE_SURFACE.fontSize;
16export const MIN_CODE_FONT_SIZE = 8;
17export const MAX_CODE_FONT_SIZE = 18;
18export const CODE_FONT_SIZE_STEP = 1;
19
20/**
21 * User-configurable appearance preferences as stored. `null` overrides mean
22 * "automatic": the value is derived from the base font size.
23 */
24export interface AppearancePreferences {
25 readonly baseFontSize: number;
26 readonly terminalFontSize: number | null;
27 readonly codeFontSize: number | null;
28 readonly codeWordBreak: boolean;
29}
30
31/** Effective appearance values after applying base-size derivation. */
32export interface ResolvedAppearance {
33 readonly baseFontSize: number;
34 readonly terminalFontSize: number;
35 readonly codeFontSize: number;
36 readonly codeWordBreak: boolean;
37 readonly isTerminalFontSizeCustom: boolean;
38 readonly isCodeFontSizeCustom: boolean;
39}
40
41export interface ResolvedMobileCodeSurface {
42 readonly fontSize: number;
43 readonly lineNumberFontSize: number;
44 readonly rowHeight: number;
45 readonly gutterWidth: number;
46 readonly codePadding: number;
47 readonly textVerticalInset: number;
48}
49
50export interface ResolvedMarkdownFontSizes {
51 readonly s: number;
52 readonly m: number;
53 readonly h1: number;
54 readonly h2: number;
55 readonly h3: number;
56 readonly h4: number;
57 readonly h5: number;
58 readonly h6: number;
59 readonly bodyLineHeight: number;
60 readonly codeBlockFontSize: number;
61 readonly codeBlockLineHeight: number;
62}
63
64export interface NativeMarkdownTypography {
65 readonly fontSize: number;
66 readonly lineHeight: number;
67 readonly headingFontSizes: readonly [number, number, number, number, number, number];
68}
69
70export function normalizeBaseFontSize(value: number | null | undefined): number {
71 if (typeof value !== "number" || !Number.isFinite(value)) {
72 return DEFAULT_BASE_FONT_SIZE;
73 }
74
75 return Math.min(MAX_BASE_FONT_SIZE, Math.max(MIN_BASE_FONT_SIZE, Math.round(value)));
76}
77
78function normalizeCodeFontSize(value: number | null | undefined): number {
79 if (typeof value !== "number" || !Number.isFinite(value)) {
80 return DEFAULT_CODE_FONT_SIZE;
81 }
82
83 return Math.min(MAX_CODE_FONT_SIZE, Math.max(MIN_CODE_FONT_SIZE, Math.round(value)));
84}
85
86function normalizeCodeWordBreak(value: boolean | null | undefined): boolean {
87 return value === true;
88}
89
90/** Terminal size derived from base: 10.5pt at base 16, snapped to 0.5pt steps. */
91function deriveTerminalFontSize(baseFontSize: number): number {
92 const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE;
93 return normalizeTerminalFontSize(Math.round(DEFAULT_TERMINAL_FONT_SIZE * scale * 2) / 2);
94}
95
96/** Code/diff size derived from base: 12pt at base 16. */
97function deriveCodeFontSize(baseFontSize: number): number {
98 const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE;
99 return normalizeCodeFontSize(Math.round(DEFAULT_CODE_FONT_SIZE * scale));
100}
101
102interface StoredAppearancePreferences {
103 readonly baseFontSize?: number | null | undefined;
104 /** Legacy key from before base font size existed; migrated to baseFontSize. */
105 readonly markdownFontSize?: number | null | undefined;
106 readonly terminalFontSize?: number | null | undefined;
107 readonly codeFontSize?: number | null | undefined;
108 readonly codeWordBreak?: boolean | null | undefined;
109}
110
111export function resolveAppearancePreferences(
112 stored: StoredAppearancePreferences | null | undefined,
113): AppearancePreferences {
114 return {
115 baseFontSize: normalizeBaseFontSize(stored?.baseFontSize ?? stored?.markdownFontSize),
116 terminalFontSize:
117 typeof stored?.terminalFontSize === "number" && Number.isFinite(stored.terminalFontSize)
118 ? normalizeTerminalFontSize(stored.terminalFontSize)
119 : null,
120 codeFontSize:
121 typeof stored?.codeFontSize === "number" && Number.isFinite(stored.codeFontSize)
122 ? normalizeCodeFontSize(stored.codeFontSize)
123 : null,
124 codeWordBreak: normalizeCodeWordBreak(stored?.codeWordBreak),
125 };
126}
127
128export function resolveAppearance(preferences: AppearancePreferences): ResolvedAppearance {
129 return {
130 baseFontSize: preferences.baseFontSize,
131 terminalFontSize:
132 preferences.terminalFontSize ?? deriveTerminalFontSize(preferences.baseFontSize),
133 codeFontSize: preferences.codeFontSize ?? deriveCodeFontSize(preferences.baseFontSize),
134 codeWordBreak: preferences.codeWordBreak,
135 isTerminalFontSizeCustom: preferences.terminalFontSize !== null,
136 isCodeFontSizeCustom: preferences.codeFontSize !== null,
137 };
138}
139
140export function resolveMobileCodeSurface(codeFontSize: number): ResolvedMobileCodeSurface {
141 const fontSize = normalizeCodeFontSize(codeFontSize);
142 const scale = fontSize / DEFAULT_CODE_FONT_SIZE;
143
144 return {
145 fontSize,
146 lineNumberFontSize: Math.max(8, Math.round(MOBILE_CODE_SURFACE.lineNumberFontSize * scale)),
147 rowHeight: Math.max(14, Math.round(MOBILE_CODE_SURFACE.rowHeight * scale)),
148 gutterWidth: MOBILE_CODE_SURFACE.gutterWidth,
149 codePadding: MOBILE_CODE_SURFACE.codePadding,
150 textVerticalInset: MOBILE_CODE_SURFACE.textVerticalInset,
151 };
152}
153
154export function resolveMarkdownFontSizes(baseFontSize: number): ResolvedMarkdownFontSizes {
155 const m = normalizeBaseFontSize(baseFontSize);
156 const scale = m / DEFAULT_BASE_FONT_SIZE;
157 const codeBlockFontSize = Math.max(10, Math.round(13 * scale));
158
159 return {
160 s: Math.max(10, Math.round(14 * scale)),
161 m,
162 h1: Math.max(16, Math.round(21 * scale)),
163 h2: Math.max(14, Math.round(19 * scale)),
164 h3: Math.max(13, Math.round(17 * scale)),
165 h4: Math.max(12, Math.round(15 * scale)),
166 h5: Math.max(12, Math.round(15 * scale)),
167 h6: Math.max(12, Math.round(15 * scale)),
168 bodyLineHeight: Math.max(18, Math.round(MOBILE_TYPOGRAPHY.body.lineHeight * scale)),
169 codeBlockFontSize,
170 codeBlockLineHeight: codeBlockFontSize + 6,
171 };
172}
173
174/**
175 * Maps the Uniwind `--text-*` theme variables (see global.css) to the
176 * MOBILE_TYPOGRAPHY roles they were authored from. Keep in sync with both.
177 */
178const TEXT_SCALE_VARIABLE_ROLES = {
179 "--text-3xs": MOBILE_TYPOGRAPHY.micro,
180 "--text-2xs": MOBILE_TYPOGRAPHY.caption,
181 "--text-xs": MOBILE_TYPOGRAPHY.label,
182 "--text-sm": MOBILE_TYPOGRAPHY.footnote,
183 "--text-base": MOBILE_TYPOGRAPHY.body,
184 "--text-lg": MOBILE_TYPOGRAPHY.headline,
185 "--text-xl": MOBILE_TYPOGRAPHY.title,
186 "--text-2xl": MOBILE_TYPOGRAPHY.largeTitle,
187 "--text-3xl": MOBILE_TYPOGRAPHY.display,
188} as const;
189
190/**
191 * Scaled values for every `--text-*` size and line-height variable, ready to
192 * pass to `Uniwind.updateCSSVariables`. All className-based text (`text-sm`,
193 * `text-base`, ...) re-resolves live when these are injected.
194 */
195export function resolveTextScaleVariables(baseFontSize: number): Record<string, number> {
196 const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE;
197 const variables: Record<string, number> = {};
198
199 for (const [name, role] of Object.entries(TEXT_SCALE_VARIABLE_ROLES)) {
200 variables[name] = Math.max(8, Math.round(role.fontSize * scale));
201 variables[`${name}--line-height`] = scaledTypographyLineHeight(role, baseFontSize);
202 }
203
204 return variables;
205}
206
207/**
208 * The line height a MOBILE_TYPOGRAPHY role renders at under the given base
209 * font size — the same value resolveTextScaleVariables injects for the role's
210 * `--text-*--line-height` variable. For layout code that must predict
211 * text-driven heights (e.g. the thread feed's fixed item sizes) instead of
212 * measuring them.
213 */
214export function scaledTypographyLineHeight(
215 role: { readonly lineHeight: number },
216 baseFontSize: number,
217): number {
218 const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE;
219 return Math.max(10, Math.round(role.lineHeight * scale));
220}
221
222/** Text dimensions shared by React Native and Compose consumers of an appearance role. */
223export function resolveScaledTextRole(role: keyof typeof MOBILE_TYPOGRAPHY, baseFontSize: number) {
224 const typography = MOBILE_TYPOGRAPHY[role];
225 const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE;
226 return {
227 fontSize: Math.max(8, Math.round(typography.fontSize * scale)),
228 lineHeight: scaledTypographyLineHeight(typography, baseFontSize),
229 };
230}
231
232export function resolveNativeMarkdownTypography(baseFontSize: number): NativeMarkdownTypography {
233 const fontSizes = resolveMarkdownFontSizes(baseFontSize);
234 return {
235 fontSize: fontSizes.m,
236 lineHeight: fontSizes.bodyLineHeight,
237 headingFontSizes: [
238 fontSizes.h1,
239 fontSizes.h2,
240 fontSizes.h3,
241 fontSizes.h4,
242 fontSizes.h5,
243 fontSizes.h6,
244 ],
245 };
246}
247
248export function stepTerminalFontSize(current: number, direction: -1 | 1): number {
249 const next =
250 direction === -1 ? current - TERMINAL_FONT_SIZE_STEP : current + TERMINAL_FONT_SIZE_STEP;
251 return normalizeTerminalFontSize(next);
252}
253
254export {
255 DEFAULT_TERMINAL_FONT_SIZE,
256 MAX_TERMINAL_FONT_SIZE,
257 MIN_TERMINAL_FONT_SIZE,
258 TERMINAL_FONT_SIZE_STEP,
259 normalizeTerminalFontSize,
260};