| 1 | import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "./typography"; |
| 2 | import { |
| 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 | |
| 10 | export const DEFAULT_BASE_FONT_SIZE = MOBILE_TYPOGRAPHY.body.fontSize; |
| 11 | export const MIN_BASE_FONT_SIZE = 11; |
| 12 | export const MAX_BASE_FONT_SIZE = 22; |
| 13 | export const BASE_FONT_SIZE_STEP = 1; |
| 14 | |
| 15 | export const DEFAULT_CODE_FONT_SIZE = MOBILE_CODE_SURFACE.fontSize; |
| 16 | export const MIN_CODE_FONT_SIZE = 8; |
| 17 | export const MAX_CODE_FONT_SIZE = 18; |
| 18 | export 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 | */ |
| 24 | export 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. */ |
| 32 | export 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 | |
| 41 | export 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 | |
| 50 | export 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 | |
| 64 | export interface NativeMarkdownTypography { |
| 65 | readonly fontSize: number; |
| 66 | readonly lineHeight: number; |
| 67 | readonly headingFontSizes: readonly [number, number, number, number, number, number]; |
| 68 | } |
| 69 | |
| 70 | export 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 | |
| 78 | function 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 | |
| 86 | function 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. */ |
| 91 | function 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. */ |
| 97 | function 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 | |
| 102 | interface 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 | |
| 111 | export 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 | |
| 128 | export 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 | |
| 140 | export 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 | |
| 154 | export 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 | */ |
| 178 | const 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 | */ |
| 195 | export 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 | */ |
| 214 | export 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. */ |
| 223 | export 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 | |
| 232 | export 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 | |
| 248 | export 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 | |
| 254 | export { |
| 255 | DEFAULT_TERMINAL_FONT_SIZE, |
| 256 | MAX_TERMINAL_FONT_SIZE, |
| 257 | MIN_TERMINAL_FONT_SIZE, |
| 258 | TERMINAL_FONT_SIZE_STEP, |
| 259 | normalizeTerminalFontSize, |
| 260 | }; |