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
8import {
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
20export 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.
25export const DEFAULT_CODE_FONT_STACK =
26 '"AT Name Mono", "SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace';
27
28export 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 */
35export 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
44export 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
53function 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 */
66export 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. */
75export function appearanceFontStack(custom: string, defaultStack: string): string {
76 const families = cssFontFamilies(custom);
77 return families === null ? defaultStack : `${families}, ${defaultStack}`;
78}
79
80export 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 */
99export 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
137function 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
142export 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
151export function clampPromptFontSize(value: number): number {
152 return clampFontSize(value, MIN_PROMPT_FONT_SIZE, MAX_PROMPT_FONT_SIZE, DEFAULT_PROMPT_FONT_SIZE);
153}
154
155export function clampCodeFontSize(value: number): number {
156 return clampFontSize(value, MIN_CODE_FONT_SIZE, MAX_CODE_FONT_SIZE, DEFAULT_CODE_FONT_SIZE);
157}
158
159const FONT_PROBE_TEXT = "mmmmmmmmMMWli1O0@# fjord";
160let fontProbeContext: CanvasRenderingContext2D | null | undefined;
161
162function 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 */
177export 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
194const MONOSPACE_PROBE_VARIANTS = ["normal 400", "normal 700", "italic 400", "italic 700"] as const;
195const MONOSPACE_PROBE_GLYPHS = ["i", "M", "W", "0", "@", "#", ".", " "] as const;
196const MONOSPACE_ADVANCE_TOLERANCE = 0.01;
197
198export 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 */
219export 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.
245const 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;
256const 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
269const 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 */
277function 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
295function 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 */
304function 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 */
334export 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
352export 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
362let 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 */
370export 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}