Skip to content
Source
Sign inSign up

appearanceFonts.ts

329 lines11.0 KB

1import {
2 DEFAULT_CODE_FONT_SIZE,
3 DEFAULT_INTERFACE_FONT_SIZE,
4 DEFAULT_PROMPT_FONT_SIZE,
5 MAX_CODE_FONT_SIZE,
6 MAX_INTERFACE_FONT_SIZE,
7 MAX_PROMPT_FONT_SIZE,
8 MIN_CODE_FONT_SIZE,
9 MIN_INTERFACE_FONT_SIZE,
10 MIN_PROMPT_FONT_SIZE,
11} from "@t3tools/contracts";
12​
13export const DEFAULT_SANS_FONT_STACK =
14 '-apple-system, BlinkMacSystemFont, "Inter Variable", "Segoe UI", system-ui, sans-serif';
15​
16// Some engines map `ui-monospace` to a proportional font, so prefer concrete names.
17export const DEFAULT_CODE_FONT_STACK =
18 '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace';
19​
20export const TYPOGRAPHY_ADVANCED_STORAGE_KEY = "t3code:typography-advanced";
21​
22export function resolveTerminalFontPreference(input: {
23 readonly advanced: boolean;
24 readonly code: string;
25 readonly terminal: string;
26}): string {
27 if (input.advanced) return input.terminal;
28 return input.code;
30​
31export function resolveTerminalFontSizePreference(input: {
32 readonly advanced: boolean;
33 readonly code: number;
34 readonly terminal: number;
35}): number {
36 if (input.advanced) return input.terminal;
37 return input.code;
39​
40function quoteFontFamilyName(name: string): string {
41 const bare = name.trim();
42 if (bare.length === 0) return "";
43 if (/^(['"]).*\1$/.test(bare)) return bare;
44 if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(bare)) return bare;
45 return `"${bare.replaceAll('"', "")}"`;
47​
48function cssFontFamilies(input: string): string | null {
49 const families = input
50 .split(",")
51 .map(quoteFontFamilyName)
52 .filter((name) => name.length > 0);
53 return families.length > 0 ? families.join(", ") : null;
55​
56export function appearanceFontStack(custom: string, defaultStack: string): string {
57 const families = cssFontFamilies(custom);
58 return families === null ? defaultStack : `${families}, ${defaultStack}`;
60​
61export interface AppearanceFontPreferences {
62 readonly sans: string;
63 readonly code: string;
64 readonly composer: string;
65 readonly sizeInterface: number;
66 readonly sizePrompt: number;
67 readonly sizeCode: number;
68 readonly smoothing: boolean;
69 readonly separate: boolean;
71​
72/** Interface size sets the root; prompt and code use pixels to avoid double scaling. */
73export function applyAppearanceFontVariables(
74 root: HTMLElement,
75 preferences: AppearanceFontPreferences,
76): void {
77 const families: ReadonlyArray<readonly [variable: string, custom: string, fallback: string]> = [
78 ["--font-sans", preferences.sans, DEFAULT_SANS_FONT_STACK],
79 ["--font-mono", preferences.code, DEFAULT_CODE_FONT_STACK],
80 ["--font-composer", preferences.separate ? preferences.composer : "", "var(--font-sans)"],
81 ];
82 for (const [variable, custom, fallback] of families) {
83 const list = cssFontFamilies(custom);
84 if (list === null) {
85 root.style.removeProperty(variable);
86 } else {
87 root.style.setProperty(variable, `${list}, ${fallback}`);
88 }
89 }
90​
91 const markerWidth = probeWidth(
92 appearanceFontStack(preferences.sans, DEFAULT_SANS_FONT_STACK),
93 LIST_MARKER_PROBE_TEXT,
94 );
95 if (markerWidth === null) {
96 root.style.removeProperty("--list-marker-sans");
97 } else {
98 root.style.setProperty("--list-marker-sans", `${(markerWidth / PROBE_FONT_SIZE).toFixed(3)}em`);
99 }
100​
101 root.style.fontSize = `${clampInterfaceFontSize(preferences.sizeInterface)}px`;
102 root.style.setProperty(
103 "--font-size-prompt",
104 preferences.separate ? `${clampPromptFontSize(preferences.sizePrompt)}px` : "var(--text-sm)",
105 );
106 const code = clampCodeFontSize(preferences.sizeCode);
107 root.style.setProperty("--font-size-code", `${code}px`);
108 root.style.setProperty("--diffs-font-size", `${code}px`);
109​
110 if (preferences.smoothing) {
111 root.style.setProperty("-webkit-font-smoothing", "antialiased");
112 } else {
113 root.style.removeProperty("-webkit-font-smoothing");
114 }
116​
117function clampFontSize(value: number, minimum: number, maximum: number, fallback: number): number {
118 if (!Number.isFinite(value)) return fallback;
119 return Math.min(maximum, Math.max(minimum, Math.round(value)));
121​
122function clampInterfaceFontSize(value: number): number {
123 return clampFontSize(
124 value,
125 MIN_INTERFACE_FONT_SIZE,
126 MAX_INTERFACE_FONT_SIZE,
127 DEFAULT_INTERFACE_FONT_SIZE,
128 );
130​
131function clampPromptFontSize(value: number): number {
132 return clampFontSize(value, MIN_PROMPT_FONT_SIZE, MAX_PROMPT_FONT_SIZE, DEFAULT_PROMPT_FONT_SIZE);
134​
135function clampCodeFontSize(value: number): number {
136 return clampFontSize(value, MIN_CODE_FONT_SIZE, MAX_CODE_FONT_SIZE, DEFAULT_CODE_FONT_SIZE);
138​
139const FONT_PROBE_TEXT = "mmmmmmmmMMWli1O0@# fjord";
140// The widest single-digit `ol` marker; CSS can't measure it to size the list gutter.
141const LIST_MARKER_PROBE_TEXT = "0. ";
142const PROBE_FONT_SIZE = 16;
143let fontProbeContext: CanvasRenderingContext2D | null | undefined;
144​
145function probeWidth(fontList: string, text = FONT_PROBE_TEXT): number | null {
146 if (fontProbeContext === undefined) {
147 fontProbeContext = document.createElement("canvas").getContext("2d");
148 }
149 if (fontProbeContext === null) return null;
150 fontProbeContext.font = `${PROBE_FONT_SIZE}px ${fontList}`;
151 return fontProbeContext.measureText(text).width;
153​
154/** Probes canvas metrics because document.fonts.check() accepts uninstalled families. */
155export function isFontFamilyAvailable(family: string): boolean {
156 const families = cssFontFamilies(family);
157 if (families === null) return false;
158 if (/^(system-ui|sans-serif|serif|monospace|ui-monospace)$/i.test(families)) return true;
159 try {
160 for (const generic of ["monospace", "serif", "sans-serif"]) {
161 const baseline = probeWidth(generic);
162 const candidate = probeWidth(`${families}, ${generic}`);
163 if (baseline === null || candidate === null) return false;
164 if (candidate !== baseline) return true;
165 }
166 return false;
167 } catch {
168 return false;
169 }
171​
172const MONOSPACE_PROBE_VARIANTS = ["normal 400", "normal 700", "italic 400", "italic 700"] as const;
173const MONOSPACE_PROBE_GLYPHS = ["i", "M", "W", "0", "@", "#", ".", " "] as const;
174const MONOSPACE_ADVANCE_TOLERANCE = 0.01;
175​
176function areFontAdvancesMonospace(advances: readonly number[]): boolean {
177 const reference = advances[0];
178 if (
179 reference === undefined ||
180 reference <= 0 ||
181 advances.some((advance) => !Number.isFinite(advance) || advance <= 0)
182 ) {
183 return true;
184 }
185 return advances.every((advance) => Math.abs(advance - reference) < MONOSPACE_ADVANCE_TOLERANCE);
187​
188export function isMonospaceFamily(family: string): boolean {
189 const families = cssFontFamilies(family);
190 if (families === null) return true;
191 try {
192 if (fontProbeContext === undefined) {
193 fontProbeContext = document.createElement("canvas").getContext("2d");
194 }
195 if (fontProbeContext === null) return true;
196 const context = fontProbeContext;
197 for (const variant of MONOSPACE_PROBE_VARIANTS) {
198 context.font = `${variant} 32px ${families}, monospace`;
199 const advances = MONOSPACE_PROBE_GLYPHS.map((glyph) => context.measureText(glyph).width);
200 if (!areFontAdvancesMonospace(advances)) return false;
201 }
202 return true;
203 } catch {
204 return true;
205 }
207​
208const SANS_GENERIC_CANDIDATES = [
209 "Segoe UI",
210 "Roboto",
211 "Noto Sans",
212 "Ubuntu",
213 "Cantarell",
214 "DejaVu Sans",
215 "Liberation Sans",
216 "Helvetica Neue",
217 "Arial",
218] as const;
219const MONO_GENERIC_CANDIDATES = [
220 "Menlo",
221 "Consolas",
222 "Cascadia Mono",
223 "DejaVu Sans Mono",
224 "Ubuntu Mono",
225 "Liberation Mono",
226 "Noto Sans Mono",
227 "Roboto Mono",
228 "Monaco",
229 "Courier New",
230] as const;
231​
232const GENERIC_PROBE_TEXT = "RagIl10O@ fjord quiz";
233​
234// Canvas generic-family mappings can differ from CSS, so measure the DOM.
235function measureDomProbeWidth(fontFamily: string): number | null {
236 try {
237 const body = document.body;
238 if (!body) return null;
239 const span = document.createElement("span");
240 span.style.cssText =
241 "position:absolute;left:-9999px;top:0;visibility:hidden;white-space:pre;font-size:100px;";
242 span.style.fontFamily = fontFamily;
243 span.textContent = GENERIC_PROBE_TEXT;
244 body.appendChild(span);
245 const width = span.getBoundingClientRect().width;
246 span.remove();
247 return width > 0 ? width : null;
248 } catch {
249 return null;
250 }
252​
253function widthsMatch(left: number, right: number): boolean {
254 return Math.abs(left - right) < 0.01;
256​
257function resolveGenericFamilyLabel(generic: string): string | null {
258 const lower = generic.toLowerCase();
259 if (lower === "serif") return null;
260 const monoLike = lower === "ui-monospace" || lower === "monospace";
261 const genericWidth = measureDomProbeWidth(generic);
262 if (genericWidth === null) return null;
263 for (const candidate of monoLike ? MONO_GENERIC_CANDIDATES : SANS_GENERIC_CANDIDATES) {
264 if (!isFontFamilyAvailable(candidate)) continue;
265 const candidateWidth = measureDomProbeWidth(`"${candidate}"`);
266 if (candidateWidth !== null && widthsMatch(genericWidth, candidateWidth)) {
267 return candidate;
268 }
269 }
270 // Unnameable Apple faces need -apple-system metrics to distinguish the UI face from SF Mono.
271 if (/mac|iphone|ipad|ipod/i.test(navigator.platform)) {
272 const systemWidth = measureDomProbeWidth("-apple-system");
273 if (systemWidth !== null && widthsMatch(genericWidth, systemWidth)) return "SF Pro";
274 return monoLike ? "SF Mono" : "SF Pro";
275 }
276 return null;
278​
279export function resolveDefaultFamilyLabel(stack: string): string | null {
280 for (const raw of stack.split(",")) {
281 const family = raw.trim().replace(/^(['"])(.*)\1$/, "$2");
282 if (family.length === 0) continue;
283 if (
284 /^(system-ui|sans-serif|serif|monospace|ui-monospace|-apple-system|BlinkMacSystemFont)$/i.test(
285 family,
286 )
287 ) {
288 const resolved = resolveGenericFamilyLabel(family);
289 if (resolved !== null) return resolved;
290 continue;
291 }
292 if (isFontFamilyAvailable(family)) return family;
293 }
294 return null;
296​
297export interface InstalledFontFamiliesResult {
298 readonly families: readonly string[];
299 readonly status: "granted" | "denied" | "unsupported";
301​
302let installedFamiliesCache: InstalledFontFamiliesResult | null = null;
303​
304/** Call from a user gesture; the first call prompts for local-fonts permission. */
305export async function queryInstalledFontFamilies(): Promise<InstalledFontFamiliesResult> {
306 if (installedFamiliesCache !== null) return installedFamiliesCache;
307 const query = (
308 window as Window & {
309 queryLocalFonts?: () => Promise<ReadonlyArray<{ readonly family: string }>>;
310 }
311 ).queryLocalFonts;
312 if (typeof query !== "function") {
313 installedFamiliesCache = { families: [], status: "unsupported" };
314 return installedFamiliesCache;
315 }
316 try {
317 const fonts = await query.call(window);
318 const families = [...new Set(fonts.map((font) => font.family))]
319 // Dot-prefixed macOS UI faces often refuse to render in web content.
320 .filter((family) => !family.startsWith("."))
321 .sort((left, right) => left.localeCompare(right));
322 // Denied access can return an empty list instead of throwing.
323 if (families.length === 0) return { families: [], status: "denied" };
324 installedFamiliesCache = { families, status: "granted" };
325 return installedFamiliesCache;
326 } catch {
327 return { families: [], status: "denied" };
328 }