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
15const 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
20export interface AppearancePreferences {
21 readonly baseFontSize: number;
22 readonly terminalFontSize: number | null;
23 readonly codeFontSize: number | null;
24 readonly codeWordBreak: boolean;
25}
26
27export interface ResolvedAppearance {
28 readonly baseFontSize: number;
29 readonly terminalFontSize: number;
30 readonly codeFontSize: number;
31 readonly codeWordBreak: boolean;
32 readonly isTerminalFontSizeCustom: boolean;
33 readonly isCodeFontSizeCustom: boolean;
34}
35
36export interface ResolvedMobileCodeSurface {
37 readonly fontSize: number;
38 readonly lineNumberFontSize: number;
39 readonly rowHeight: number;
40 readonly gutterWidth: number;
41 readonly codePadding: number;
42 readonly textVerticalInset: number;
43}
44
45export interface ResolvedMarkdownFontSizes {
46 readonly s: number;
47 readonly m: number;
48 readonly h1: number;
49 readonly h2: number;
50 readonly h3: number;
51 readonly h4: number;
52 readonly h5: number;
53 readonly h6: number;
54 readonly bodyLineHeight: number;
55 readonly codeBlockFontSize: number;
56 readonly codeBlockLineHeight: number;
57}
58
59export interface NativeMarkdownTypography {
60 readonly fontSize: number;
61 readonly lineHeight: number;
62 readonly headingFontSizes: readonly [number, number, number, number, number, number];
63}
64
65export function normalizeBaseFontSize(value: number | null | undefined): number {
66 if (typeof value !== "number" || !Number.isFinite(value)) {
67 return DEFAULT_BASE_FONT_SIZE;
68 }
69
70 return Math.min(MAX_BASE_FONT_SIZE, Math.max(MIN_BASE_FONT_SIZE, Math.round(value)));
71}
72
73function normalizeCodeFontSize(value: number | null | undefined): number {
74 if (typeof value !== "number" || !Number.isFinite(value)) {
75 return DEFAULT_CODE_FONT_SIZE;
76 }
77
78 return Math.min(MAX_CODE_FONT_SIZE, Math.max(MIN_CODE_FONT_SIZE, Math.round(value)));
79}
80
81function normalizeCodeWordBreak(value: boolean | null | undefined): boolean {
82 return value === true;
83}
84
85function deriveTerminalFontSize(baseFontSize: number): number {
86 const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE;
87 return normalizeTerminalFontSize(Math.round(DEFAULT_TERMINAL_FONT_SIZE * scale * 2) / 2);
88}
89
90function deriveCodeFontSize(baseFontSize: number): number {
91 const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE;
92 return normalizeCodeFontSize(Math.round(DEFAULT_CODE_FONT_SIZE * scale));
93}
94
95interface StoredAppearancePreferences {
96 readonly baseFontSize?: number | null | undefined;
97 readonly markdownFontSize?: number | null | undefined;
98 readonly terminalFontSize?: number | null | undefined;
99 readonly codeFontSize?: number | null | undefined;
100 readonly codeWordBreak?: boolean | null | undefined;
101}
102
103export function resolveAppearancePreferences(
104 stored: StoredAppearancePreferences | null | undefined,
105): AppearancePreferences {
106 return {
107 baseFontSize: normalizeBaseFontSize(stored?.baseFontSize ?? stored?.markdownFontSize),
108 terminalFontSize:
109 typeof stored?.terminalFontSize === "number" && Number.isFinite(stored.terminalFontSize)
110 ? normalizeTerminalFontSize(stored.terminalFontSize)
111 : null,
112 codeFontSize:
113 typeof stored?.codeFontSize === "number" && Number.isFinite(stored.codeFontSize)
114 ? normalizeCodeFontSize(stored.codeFontSize)
115 : null,
116 codeWordBreak: normalizeCodeWordBreak(stored?.codeWordBreak),
117 };
118}
119
120export function resolveAppearance(preferences: AppearancePreferences): ResolvedAppearance {
121 return {
122 baseFontSize: preferences.baseFontSize,
123 terminalFontSize:
124 preferences.terminalFontSize ?? deriveTerminalFontSize(preferences.baseFontSize),
125 codeFontSize: preferences.codeFontSize ?? deriveCodeFontSize(preferences.baseFontSize),
126 codeWordBreak: preferences.codeWordBreak,
127 isTerminalFontSizeCustom: preferences.terminalFontSize !== null,
128 isCodeFontSizeCustom: preferences.codeFontSize !== null,
129 };
130}
131
132export function resolveMobileCodeSurface(codeFontSize: number): ResolvedMobileCodeSurface {
133 const fontSize = normalizeCodeFontSize(codeFontSize);
134 const scale = fontSize / DEFAULT_CODE_FONT_SIZE;
135
136 return {
137 fontSize,
138 lineNumberFontSize: Math.max(8, Math.round(MOBILE_CODE_SURFACE.lineNumberFontSize * scale)),
139 rowHeight: Math.max(14, Math.round(MOBILE_CODE_SURFACE.rowHeight * scale)),
140 gutterWidth: MOBILE_CODE_SURFACE.gutterWidth,
141 codePadding: MOBILE_CODE_SURFACE.codePadding,
142 textVerticalInset: MOBILE_CODE_SURFACE.textVerticalInset,
143 };
144}
145
146export function resolveMarkdownFontSizes(baseFontSize: number): ResolvedMarkdownFontSizes {
147 const m = normalizeBaseFontSize(baseFontSize);
148 const scale = m / DEFAULT_BASE_FONT_SIZE;
149 const codeBlockFontSize = Math.max(10, Math.round(13 * scale));
150
151 return {
152 s: Math.max(10, Math.round(14 * scale)),
153 m,
154 h1: Math.max(16, Math.round(21 * scale)),
155 h2: Math.max(14, Math.round(19 * scale)),
156 h3: Math.max(13, Math.round(17 * scale)),
157 h4: Math.max(12, Math.round(15 * scale)),
158 h5: Math.max(12, Math.round(15 * scale)),
159 h6: Math.max(12, Math.round(15 * scale)),
160 bodyLineHeight: Math.max(18, Math.round(MOBILE_TYPOGRAPHY.body.lineHeight * scale)),
161 codeBlockFontSize,
162 codeBlockLineHeight: codeBlockFontSize + 6,
163 };
164}
165
166/** Keep text-variable roles in sync with global.css and MOBILE_TYPOGRAPHY. */
167const TEXT_SCALE_VARIABLE_ROLES = {
168 "--text-3xs": MOBILE_TYPOGRAPHY.micro,
169 "--text-2xs": MOBILE_TYPOGRAPHY.caption,
170 "--text-xs": MOBILE_TYPOGRAPHY.label,
171 "--text-sm": MOBILE_TYPOGRAPHY.footnote,
172 "--text-base": MOBILE_TYPOGRAPHY.body,
173 "--text-lg": MOBILE_TYPOGRAPHY.headline,
174 "--text-xl": MOBILE_TYPOGRAPHY.title,
175 "--text-2xl": MOBILE_TYPOGRAPHY.largeTitle,
176 "--text-3xl": MOBILE_TYPOGRAPHY.display,
177} as const;
178
179export function resolveTextScaleVariables(baseFontSize: number): Record<string, number> {
180 const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE;
181 const variables: Record<string, number> = {};
182
183 for (const [name, role] of Object.entries(TEXT_SCALE_VARIABLE_ROLES)) {
184 variables[name] = Math.max(8, Math.round(role.fontSize * scale));
185 variables[`${name}--line-height`] = scaledTypographyLineHeight(role, baseFontSize);
186 }
187
188 return variables;
189}
190
191export function scaledTypographyLineHeight(
192 role: { readonly lineHeight: number },
193 baseFontSize: number,
194): number {
195 const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE;
196 return Math.max(10, Math.round(role.lineHeight * scale));
197}
198
199export function resolveScaledTextRole(role: keyof typeof MOBILE_TYPOGRAPHY, baseFontSize: number) {
200 const typography = MOBILE_TYPOGRAPHY[role];
201 const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE;
202 return {
203 fontSize: Math.max(8, Math.round(typography.fontSize * scale)),
204 lineHeight: scaledTypographyLineHeight(typography, baseFontSize),
205 };
206}
207
208export function resolveNativeMarkdownTypography(baseFontSize: number): NativeMarkdownTypography {
209 const fontSizes = resolveMarkdownFontSizes(baseFontSize);
210 return {
211 fontSize: fontSizes.m,
212 lineHeight: fontSizes.bodyLineHeight,
213 headingFontSizes: [
214 fontSizes.h1,
215 fontSizes.h2,
216 fontSizes.h3,
217 fontSizes.h4,
218 fontSizes.h5,
219 fontSizes.h6,
220 ],
221 };
222}
223
224export function stepTerminalFontSize(current: number, direction: -1 | 1): number {
225 const next =
226 direction === -1 ? current - TERMINAL_FONT_SIZE_STEP : current + TERMINAL_FONT_SIZE_STEP;
227 return normalizeTerminalFontSize(next);
228}
229
230export { MAX_TERMINAL_FONT_SIZE, MIN_TERMINAL_FONT_SIZE, TERMINAL_FONT_SIZE_STEP };