| 1 | /** |
| 2 | * Font preferences from Settings → Appearance, applied as CSS custom |
| 3 | * properties. The default stacks mirror the `--font-sans` / `--font-mono` |
| 4 | * definitions in `index.css`; a custom family is always prepended to the |
| 5 | * matching default stack so glyph coverage never regresses. |
| 6 | */ |
| 7 | |
| 8 | import { |
| 9 | DEFAULT_CODE_FONT_SIZE, |
| 10 | DEFAULT_INTERFACE_FONT_SIZE, |
| 11 | DEFAULT_PROMPT_FONT_SIZE, |
| 12 | MAX_CODE_FONT_SIZE, |
| 13 | MAX_INTERFACE_FONT_SIZE, |
| 14 | MAX_PROMPT_FONT_SIZE, |
| 15 | MIN_CODE_FONT_SIZE, |
| 16 | MIN_INTERFACE_FONT_SIZE, |
| 17 | MIN_PROMPT_FONT_SIZE, |
| 18 | } from "@t3tools/contracts"; |
| 19 | |
| 20 | export const DEFAULT_SANS_FONT_STACK = |
| 21 | '"AT Name Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif'; |
| 22 | |
| 23 | // Concrete names first: some engines alias `ui-monospace` to the |
| 24 | // proportional system UI font, which would break every code surface. |
| 25 | export const DEFAULT_CODE_FONT_STACK = |
| 26 | '"AT Name Mono", "SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace'; |
| 27 | |
| 28 | export const TYPOGRAPHY_ADVANCED_STORAGE_KEY = "t3code:typography-advanced"; |
| 29 | |
| 30 | /** |
| 31 | * Simple typography treats the terminal as another monospace surface. In |
| 32 | * Advanced mode an empty terminal preference means the terminal default, |
| 33 | * keeping later code-font changes isolated to code surfaces. |
| 34 | */ |
| 35 | export function resolveTerminalFontPreference(input: { |
| 36 | readonly advanced: boolean; |
| 37 | readonly code: string; |
| 38 | readonly terminal: string; |
| 39 | }): string { |
| 40 | if (input.advanced) return input.terminal; |
| 41 | return input.code; |
| 42 | } |
| 43 | |
| 44 | export function resolveTerminalFontSizePreference(input: { |
| 45 | readonly advanced: boolean; |
| 46 | readonly code: number; |
| 47 | readonly terminal: number; |
| 48 | }): number { |
| 49 | if (input.advanced) return input.terminal; |
| 50 | return input.code; |
| 51 | } |
| 52 | |
| 53 | function quoteFontFamilyName(name: string): string { |
| 54 | const bare = name.trim(); |
| 55 | if (bare.length === 0) return ""; |
| 56 | // Already quoted, or a single ident that needs no quoting. |
| 57 | if (/^(['"]).*\1$/.test(bare)) return bare; |
| 58 | if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(bare)) return bare; |
| 59 | return `"${bare.replaceAll('"', "")}"`; |
| 60 | } |
| 61 | |
| 62 | /** |
| 63 | * Normalize a user-entered family (single name or comma-separated list) into a |
| 64 | * safe CSS font-family list, or null when the input is effectively empty. |
| 65 | */ |
| 66 | export function cssFontFamilies(input: string): string | null { |
| 67 | const families = input |
| 68 | .split(",") |
| 69 | .map(quoteFontFamilyName) |
| 70 | .filter((name) => name.length > 0); |
| 71 | return families.length > 0 ? families.join(", ") : null; |
| 72 | } |
| 73 | |
| 74 | /** The full stack a preference resolves to: custom families before the default. */ |
| 75 | export function appearanceFontStack(custom: string, defaultStack: string): string { |
| 76 | const families = cssFontFamilies(custom); |
| 77 | return families === null ? defaultStack : `${families}, ${defaultStack}`; |
| 78 | } |
| 79 | |
| 80 | export interface AppearanceFontPreferences { |
| 81 | readonly sans: string; |
| 82 | readonly code: string; |
| 83 | readonly composer: string; |
| 84 | readonly sizeInterface: number; |
| 85 | readonly sizePrompt: number; |
| 86 | readonly sizeCode: number; |
| 87 | /** Grayscale `antialiased` rendering; false keeps the heavier platform default. */ |
| 88 | readonly smoothing: boolean; |
| 89 | } |
| 90 | |
| 91 | /** |
| 92 | * Apply the preferences to the root element. Unset families remove the |
| 93 | * override so the stylesheet defaults (and theme changes) stay in charge. |
| 94 | * |
| 95 | * Sizes are always written: the interface size drives the root font size (and |
| 96 | * with it every rem-based dimension), while the prompt and code sizes stay in |
| 97 | * absolute pixels so they do not scale twice. |
| 98 | */ |
| 99 | export function applyAppearanceFontVariables( |
| 100 | root: HTMLElement, |
| 101 | preferences: AppearanceFontPreferences, |
| 102 | ): void { |
| 103 | const families: ReadonlyArray<readonly [variable: string, custom: string, fallback: string]> = [ |
| 104 | ["--font-sans", preferences.sans, DEFAULT_SANS_FONT_STACK], |
| 105 | ["--font-mono", preferences.code, DEFAULT_CODE_FONT_STACK], |
| 106 | ["--chat-code-font", preferences.code, "var(--font-mono)"], |
| 107 | // The composer falls back to whatever the sans preference resolves to. |
| 108 | ["--font-composer", preferences.composer, "var(--font-sans)"], |
| 109 | ]; |
| 110 | for (const [variable, custom, fallback] of families) { |
| 111 | const list = cssFontFamilies(custom); |
| 112 | if (list === null) { |
| 113 | root.style.removeProperty(variable); |
| 114 | } else { |
| 115 | root.style.setProperty(variable, `${list}, ${fallback}`); |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | root.style.fontSize = `${clampInterfaceFontSize(preferences.sizeInterface)}px`; |
| 120 | root.style.setProperty("--font-size-prompt", `${clampPromptFontSize(preferences.sizePrompt)}px`); |
| 121 | const code = clampCodeFontSize(preferences.sizeCode); |
| 122 | root.style.setProperty("--font-size-code", `${code}px`); |
| 123 | // The @pierre/diffs surfaces read their own hook for code text. |
| 124 | root.style.setProperty("--diffs-font-size", `${code}px`); |
| 125 | |
| 126 | // Inherited from the root; only macOS engines honor the property, so no |
| 127 | // platform gate is needed here. Smoothing on means grayscale `antialiased` |
| 128 | // (thinner strokes); off restores the platform default, which macOS renders |
| 129 | // with heavier stem darkening. |
| 130 | if (preferences.smoothing) { |
| 131 | root.style.setProperty("-webkit-font-smoothing", "antialiased"); |
| 132 | } else { |
| 133 | root.style.removeProperty("-webkit-font-smoothing"); |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | function clampFontSize(value: number, minimum: number, maximum: number, fallback: number): number { |
| 138 | if (!Number.isFinite(value)) return fallback; |
| 139 | return Math.min(maximum, Math.max(minimum, Math.round(value))); |
| 140 | } |
| 141 | |
| 142 | export function clampInterfaceFontSize(value: number): number { |
| 143 | return clampFontSize( |
| 144 | value, |
| 145 | MIN_INTERFACE_FONT_SIZE, |
| 146 | MAX_INTERFACE_FONT_SIZE, |
| 147 | DEFAULT_INTERFACE_FONT_SIZE, |
| 148 | ); |
| 149 | } |
| 150 | |
| 151 | export function clampPromptFontSize(value: number): number { |
| 152 | return clampFontSize(value, MIN_PROMPT_FONT_SIZE, MAX_PROMPT_FONT_SIZE, DEFAULT_PROMPT_FONT_SIZE); |
| 153 | } |
| 154 | |
| 155 | export function clampCodeFontSize(value: number): number { |
| 156 | return clampFontSize(value, MIN_CODE_FONT_SIZE, MAX_CODE_FONT_SIZE, DEFAULT_CODE_FONT_SIZE); |
| 157 | } |
| 158 | |
| 159 | const FONT_PROBE_TEXT = "mmmmmmmmMMWli1O0@# fjord"; |
| 160 | let fontProbeContext: CanvasRenderingContext2D | null | undefined; |
| 161 | |
| 162 | function probeWidth(fontList: string): number | null { |
| 163 | if (fontProbeContext === undefined) { |
| 164 | fontProbeContext = document.createElement("canvas").getContext("2d"); |
| 165 | } |
| 166 | if (fontProbeContext === null) return null; |
| 167 | fontProbeContext.font = `16px ${fontList}`; |
| 168 | return fontProbeContext.measureText(FONT_PROBE_TEXT).width; |
| 169 | } |
| 170 | |
| 171 | /** |
| 172 | * Canvas metric probing instead of document.fonts.check(): check() reports |
| 173 | * true for families that are not installed at all (nothing needs loading), so |
| 174 | * it cannot filter the dropdown. A family exists when falling back to at |
| 175 | * least one generic changes the measured advance. |
| 176 | */ |
| 177 | export function isFontFamilyAvailable(family: string): boolean { |
| 178 | const families = cssFontFamilies(family); |
| 179 | if (families === null) return false; |
| 180 | if (/^(system-ui|sans-serif|serif|monospace|ui-monospace)$/i.test(families)) return true; |
| 181 | try { |
| 182 | for (const generic of ["monospace", "serif", "sans-serif"]) { |
| 183 | const baseline = probeWidth(generic); |
| 184 | const candidate = probeWidth(`${families}, ${generic}`); |
| 185 | if (baseline === null || candidate === null) return false; |
| 186 | if (candidate !== baseline) return true; |
| 187 | } |
| 188 | return false; |
| 189 | } catch { |
| 190 | return false; |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | const MONOSPACE_PROBE_VARIANTS = ["normal 400", "normal 700", "italic 400", "italic 700"] as const; |
| 195 | const MONOSPACE_PROBE_GLYPHS = ["i", "M", "W", "0", "@", "#", ".", " "] as const; |
| 196 | const MONOSPACE_ADVANCE_TOLERANCE = 0.01; |
| 197 | |
| 198 | export function areFontAdvancesMonospace(advances: readonly number[]): boolean { |
| 199 | const reference = advances[0]; |
| 200 | if ( |
| 201 | reference === undefined || |
| 202 | reference <= 0 || |
| 203 | advances.some((advance) => !Number.isFinite(advance) || advance <= 0) |
| 204 | ) { |
| 205 | return true; |
| 206 | } |
| 207 | return advances.every((advance) => Math.abs(advance - reference) < MONOSPACE_ADVANCE_TOLERANCE); |
| 208 | } |
| 209 | |
| 210 | /** |
| 211 | * Whether a family renders every character on the same advance. Cell-grid |
| 212 | * surfaces (the terminal) require this: a proportional face draws its text |
| 213 | * narrower than the lattice the cursor and selection are placed on, which |
| 214 | * reads as ragged gaps and a cursor stranded to the right of the text. |
| 215 | * |
| 216 | * Unmeasurable environments answer true, so a missing canvas never blocks a |
| 217 | * legitimate font. |
| 218 | */ |
| 219 | export function isMonospaceFamily(family: string): boolean { |
| 220 | const families = cssFontFamilies(family); |
| 221 | if (families === null) return true; |
| 222 | try { |
| 223 | if (fontProbeContext === undefined) { |
| 224 | fontProbeContext = document.createElement("canvas").getContext("2d"); |
| 225 | } |
| 226 | if (fontProbeContext === null) return true; |
| 227 | const context = fontProbeContext; |
| 228 | // Fall back to a generic mono so an absent face measures as monospace and |
| 229 | // is left for the normal fallback chain to resolve. |
| 230 | for (const variant of MONOSPACE_PROBE_VARIANTS) { |
| 231 | context.font = `${variant} 32px ${families}, monospace`; |
| 232 | const advances = MONOSPACE_PROBE_GLYPHS.map((glyph) => context.measureText(glyph).width); |
| 233 | if (!areFontAdvancesMonospace(advances)) return false; |
| 234 | } |
| 235 | return true; |
| 236 | } catch { |
| 237 | return true; |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | // Nameable faces the platform generics commonly map to, likeliest first. |
| 242 | // Pixel-comparing a generic against these names the actual face; Apple's own |
| 243 | // UI fonts are deliberately not CSS-nameable, so a miss on an Apple platform |
| 244 | // identifies San Francisco itself. |
| 245 | const SANS_GENERIC_CANDIDATES = [ |
| 246 | "Segoe UI", |
| 247 | "Roboto", |
| 248 | "Noto Sans", |
| 249 | "Ubuntu", |
| 250 | "Cantarell", |
| 251 | "DejaVu Sans", |
| 252 | "Liberation Sans", |
| 253 | "Helvetica Neue", |
| 254 | "Arial", |
| 255 | ] as const; |
| 256 | const MONO_GENERIC_CANDIDATES = [ |
| 257 | "Menlo", |
| 258 | "Consolas", |
| 259 | "Cascadia Mono", |
| 260 | "DejaVu Sans Mono", |
| 261 | "Ubuntu Mono", |
| 262 | "Liberation Mono", |
| 263 | "Noto Sans Mono", |
| 264 | "Roboto Mono", |
| 265 | "Monaco", |
| 266 | "Courier New", |
| 267 | ] as const; |
| 268 | |
| 269 | const GENERIC_PROBE_TEXT = "RagIl10O@ fjord quiz"; |
| 270 | |
| 271 | /** |
| 272 | * Advance width of the probe text laid out by the DOM - not canvas, whose |
| 273 | * generic-family mapping diverges from real rendering (this engine draws |
| 274 | * `ui-monospace` as the proportional UI font on canvas but not in CSS). |
| 275 | * Identical widths at this size mean the same face for practical purposes. |
| 276 | */ |
| 277 | function measureDomProbeWidth(fontFamily: string): number | null { |
| 278 | try { |
| 279 | const body = document.body; |
| 280 | if (!body) return null; |
| 281 | const span = document.createElement("span"); |
| 282 | span.style.cssText = |
| 283 | "position:absolute;left:-9999px;top:0;visibility:hidden;white-space:pre;font-size:100px;"; |
| 284 | span.style.fontFamily = fontFamily; |
| 285 | span.textContent = GENERIC_PROBE_TEXT; |
| 286 | body.appendChild(span); |
| 287 | const width = span.getBoundingClientRect().width; |
| 288 | span.remove(); |
| 289 | return width > 0 ? width : null; |
| 290 | } catch { |
| 291 | return null; |
| 292 | } |
| 293 | } |
| 294 | |
| 295 | function widthsMatch(left: number, right: number): boolean { |
| 296 | return Math.abs(left - right) < 0.01; |
| 297 | } |
| 298 | |
| 299 | /** |
| 300 | * Name the concrete face a generic keyword renders as, by measuring the |
| 301 | * generic against nameable candidates. Null when the face cannot be |
| 302 | * identified (and the platform gives no definitional answer). |
| 303 | */ |
| 304 | function resolveGenericFamilyLabel(generic: string): string | null { |
| 305 | const lower = generic.toLowerCase(); |
| 306 | if (lower === "serif") return null; |
| 307 | const monoLike = lower === "ui-monospace" || lower === "monospace"; |
| 308 | const genericWidth = measureDomProbeWidth(generic); |
| 309 | if (genericWidth === null) return null; |
| 310 | for (const candidate of monoLike ? MONO_GENERIC_CANDIDATES : SANS_GENERIC_CANDIDATES) { |
| 311 | if (!isFontFamilyAvailable(candidate)) continue; |
| 312 | const candidateWidth = measureDomProbeWidth(`"${candidate}"`); |
| 313 | if (candidateWidth !== null && widthsMatch(genericWidth, candidateWidth)) { |
| 314 | return candidate; |
| 315 | } |
| 316 | } |
| 317 | // No nameable face matched; on Apple platforms that means one of the San |
| 318 | // Francisco faces, which CSS cannot name. Comparing against -apple-system |
| 319 | // tells the UI face apart from SF Mono. |
| 320 | if (/mac|iphone|ipad|ipod/i.test(navigator.platform)) { |
| 321 | const systemWidth = measureDomProbeWidth("-apple-system"); |
| 322 | if (systemWidth !== null && widthsMatch(genericWidth, systemWidth)) return "SF Pro"; |
| 323 | return monoLike ? "SF Mono" : "SF Pro"; |
| 324 | } |
| 325 | return null; |
| 326 | } |
| 327 | |
| 328 | /** |
| 329 | * The first family of a default stack that will actually render - what the |
| 330 | * "Default" choice means on this machine. Concrete names are probed for |
| 331 | * availability; generic keywords are resolved to the face they draw with |
| 332 | * where identifiable. Null when nothing can be named. |
| 333 | */ |
| 334 | export function resolveDefaultFamilyLabel(stack: string): string | null { |
| 335 | for (const raw of stack.split(",")) { |
| 336 | const family = raw.trim().replace(/^(['"])(.*)\1$/, "$2"); |
| 337 | if (family.length === 0) continue; |
| 338 | if ( |
| 339 | /^(system-ui|sans-serif|serif|monospace|ui-monospace|-apple-system|BlinkMacSystemFont)$/i.test( |
| 340 | family, |
| 341 | ) |
| 342 | ) { |
| 343 | const resolved = resolveGenericFamilyLabel(family); |
| 344 | if (resolved !== null) return resolved; |
| 345 | continue; |
| 346 | } |
| 347 | if (isFontFamilyAvailable(family)) return family; |
| 348 | } |
| 349 | return null; |
| 350 | } |
| 351 | |
| 352 | export interface InstalledFontFamiliesResult { |
| 353 | readonly families: readonly string[]; |
| 354 | /** |
| 355 | * "unsupported" - the engine has no Local Font Access API (Safari, |
| 356 | * Firefox); "denied" - the API exists but the user declined the permission |
| 357 | * prompt. Both fall back to the curated catalog. |
| 358 | */ |
| 359 | readonly status: "granted" | "denied" | "unsupported"; |
| 360 | } |
| 361 | |
| 362 | let installedFamiliesCache: InstalledFontFamiliesResult | null = null; |
| 363 | |
| 364 | /** |
| 365 | * Every installed family via the Local Font Access API (Chromium and |
| 366 | * Electron). Call from a user gesture: the first call raises the browser's |
| 367 | * local-fonts permission prompt. A denial is not cached, so reopening the |
| 368 | * picker can ask again after the user changes the site setting. |
| 369 | */ |
| 370 | export async function queryInstalledFontFamilies(): Promise<InstalledFontFamiliesResult> { |
| 371 | if (installedFamiliesCache !== null) return installedFamiliesCache; |
| 372 | const query = ( |
| 373 | window as Window & { |
| 374 | queryLocalFonts?: () => Promise<ReadonlyArray<{ readonly family: string }>>; |
| 375 | } |
| 376 | ).queryLocalFonts; |
| 377 | if (typeof query !== "function") { |
| 378 | installedFamiliesCache = { families: [], status: "unsupported" }; |
| 379 | return installedFamiliesCache; |
| 380 | } |
| 381 | try { |
| 382 | const fonts = await query.call(window); |
| 383 | const families = [...new Set(fonts.map((font) => font.family))] |
| 384 | // Dot-prefixed families are macOS-internal UI faces; selecting one is |
| 385 | // never intended and most refuse to render for web content anyway. |
| 386 | .filter((family) => !family.startsWith(".")) |
| 387 | .sort((left, right) => left.localeCompare(right)); |
| 388 | // A denied permission check resolves with an empty list instead of |
| 389 | // throwing; no machine has zero fonts, so treat empty as denied. |
| 390 | if (families.length === 0) return { families: [], status: "denied" }; |
| 391 | installedFamiliesCache = { families, status: "granted" }; |
| 392 | return installedFamiliesCache; |
| 393 | } catch { |
| 394 | return { families: [], status: "denied" }; |
| 395 | } |
| 396 | } |