import { DEFAULT_CHAT_FONT_SIZE, DEFAULT_CODE_FONT_SIZE, DEFAULT_INTERFACE_FONT_SIZE, DEFAULT_PROMPT_FONT_SIZE, MAX_CHAT_FONT_SIZE, MAX_CODE_FONT_SIZE, MAX_INTERFACE_FONT_SIZE, MAX_PROMPT_FONT_SIZE, MIN_CHAT_FONT_SIZE, MIN_CODE_FONT_SIZE, MIN_INTERFACE_FONT_SIZE, MIN_PROMPT_FONT_SIZE, } from "@t3tools/contracts"; export const DEFAULT_SANS_FONT_STACK = '-apple-system, BlinkMacSystemFont, "Inter Variable", "Segoe UI", system-ui, sans-serif'; // Some engines map `ui-monospace` to a proportional font, so prefer concrete names. export const DEFAULT_CODE_FONT_STACK = '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace'; export const TYPOGRAPHY_ADVANCED_STORAGE_KEY = "t3code:typography-advanced"; export function resolveTerminalFontPreference(input: { readonly advanced: boolean; readonly code: string; readonly terminal: string; }): string { if (input.advanced) return input.terminal; return input.code; } export function resolveTerminalFontSizePreference(input: { readonly advanced: boolean; readonly code: number; readonly terminal: number; }): number { if (input.advanced) return input.terminal; return input.code; } function quoteFontFamilyName(name: string): string { const bare = name.trim(); if (bare.length === 0) return ""; if (/^(['"]).*\1$/.test(bare)) return bare; if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(bare)) return bare; return `"${bare.replaceAll('"', "")}"`; } function cssFontFamilies(input: string): string | null { const families = input .split(",") .map(quoteFontFamilyName) .filter((name) => name.length > 0); return families.length > 0 ? families.join(", ") : null; } export function appearanceFontStack(custom: string, defaultStack: string): string { const families = cssFontFamilies(custom); return families === null ? defaultStack : `${families}, ${defaultStack}`; } export interface AppearanceFontPreferences { readonly sans: string; readonly code: string; readonly composer: string; readonly chat: string; readonly sizeInterface: number; readonly sizePrompt: number; readonly sizeChat: number; readonly sizeCode: number; readonly smoothing: boolean; } /** Always writes sizes: interface size sets the root; prompt and code use pixels to avoid double scaling. */ export function applyAppearanceFontVariables( root: HTMLElement, preferences: AppearanceFontPreferences, ): void { const families: ReadonlyArray = [ ["--font-sans", preferences.sans, DEFAULT_SANS_FONT_STACK], ["--font-mono", preferences.code, DEFAULT_CODE_FONT_STACK], ["--font-composer", preferences.composer, "var(--font-sans)"], ["--font-chat", preferences.chat, "var(--font-sans)"], ]; for (const [variable, custom, fallback] of families) { const list = cssFontFamilies(custom); if (list === null) { root.style.removeProperty(variable); } else { root.style.setProperty(variable, `${list}, ${fallback}`); } } root.style.fontSize = `${clampInterfaceFontSize(preferences.sizeInterface)}px`; root.style.setProperty("--font-size-prompt", `${clampPromptFontSize(preferences.sizePrompt)}px`); const chat = clampChatFontSize(preferences.sizeChat); // The default leaves messages on their rem size, so they keep following the interface size. if (chat === DEFAULT_CHAT_FONT_SIZE) { root.style.removeProperty("--font-size-chat"); } else { root.style.setProperty("--font-size-chat", `${chat}px`); } const code = clampCodeFontSize(preferences.sizeCode); root.style.setProperty("--font-size-code", `${code}px`); root.style.setProperty("--diffs-font-size", `${code}px`); if (preferences.smoothing) { root.style.setProperty("-webkit-font-smoothing", "antialiased"); } else { root.style.removeProperty("-webkit-font-smoothing"); } } function clampFontSize(value: number, minimum: number, maximum: number, fallback: number): number { if (!Number.isFinite(value)) return fallback; return Math.min(maximum, Math.max(minimum, Math.round(value))); } function clampInterfaceFontSize(value: number): number { return clampFontSize( value, MIN_INTERFACE_FONT_SIZE, MAX_INTERFACE_FONT_SIZE, DEFAULT_INTERFACE_FONT_SIZE, ); } function clampPromptFontSize(value: number): number { return clampFontSize(value, MIN_PROMPT_FONT_SIZE, MAX_PROMPT_FONT_SIZE, DEFAULT_PROMPT_FONT_SIZE); } function clampChatFontSize(value: number): number { return clampFontSize(value, MIN_CHAT_FONT_SIZE, MAX_CHAT_FONT_SIZE, DEFAULT_CHAT_FONT_SIZE); } function clampCodeFontSize(value: number): number { return clampFontSize(value, MIN_CODE_FONT_SIZE, MAX_CODE_FONT_SIZE, DEFAULT_CODE_FONT_SIZE); } const FONT_PROBE_TEXT = "mmmmmmmmMMWli1O0@# fjord"; let fontProbeContext: CanvasRenderingContext2D | null | undefined; function probeWidth(fontList: string): number | null { if (fontProbeContext === undefined) { fontProbeContext = document.createElement("canvas").getContext("2d"); } if (fontProbeContext === null) return null; fontProbeContext.font = `16px ${fontList}`; return fontProbeContext.measureText(FONT_PROBE_TEXT).width; } /** Probes canvas metrics because document.fonts.check() accepts uninstalled families. */ export function isFontFamilyAvailable(family: string): boolean { const families = cssFontFamilies(family); if (families === null) return false; if (/^(system-ui|sans-serif|serif|monospace|ui-monospace)$/i.test(families)) return true; try { for (const generic of ["monospace", "serif", "sans-serif"]) { const baseline = probeWidth(generic); const candidate = probeWidth(`${families}, ${generic}`); if (baseline === null || candidate === null) return false; if (candidate !== baseline) return true; } return false; } catch { return false; } } const MONOSPACE_PROBE_VARIANTS = ["normal 400", "normal 700", "italic 400", "italic 700"] as const; const MONOSPACE_PROBE_GLYPHS = ["i", "M", "W", "0", "@", "#", ".", " "] as const; const MONOSPACE_ADVANCE_TOLERANCE = 0.01; function areFontAdvancesMonospace(advances: readonly number[]): boolean { const reference = advances[0]; if ( reference === undefined || reference <= 0 || advances.some((advance) => !Number.isFinite(advance) || advance <= 0) ) { return true; } return advances.every((advance) => Math.abs(advance - reference) < MONOSPACE_ADVANCE_TOLERANCE); } export function isMonospaceFamily(family: string): boolean { const families = cssFontFamilies(family); if (families === null) return true; try { if (fontProbeContext === undefined) { fontProbeContext = document.createElement("canvas").getContext("2d"); } if (fontProbeContext === null) return true; const context = fontProbeContext; for (const variant of MONOSPACE_PROBE_VARIANTS) { context.font = `${variant} 32px ${families}, monospace`; const advances = MONOSPACE_PROBE_GLYPHS.map((glyph) => context.measureText(glyph).width); if (!areFontAdvancesMonospace(advances)) return false; } return true; } catch { return true; } } const SANS_GENERIC_CANDIDATES = [ "Segoe UI", "Roboto", "Noto Sans", "Ubuntu", "Cantarell", "DejaVu Sans", "Liberation Sans", "Helvetica Neue", "Arial", ] as const; const MONO_GENERIC_CANDIDATES = [ "Menlo", "Consolas", "Cascadia Mono", "DejaVu Sans Mono", "Ubuntu Mono", "Liberation Mono", "Noto Sans Mono", "Roboto Mono", "Monaco", "Courier New", ] as const; const GENERIC_PROBE_TEXT = "RagIl10O@ fjord quiz"; // Canvas generic-family mappings can differ from CSS, so measure the DOM. function measureDomProbeWidth(fontFamily: string): number | null { try { const body = document.body; if (!body) return null; const span = document.createElement("span"); span.style.cssText = "position:absolute;left:-9999px;top:0;visibility:hidden;white-space:pre;font-size:100px;"; span.style.fontFamily = fontFamily; span.textContent = GENERIC_PROBE_TEXT; body.appendChild(span); const width = span.getBoundingClientRect().width; span.remove(); return width > 0 ? width : null; } catch { return null; } } function widthsMatch(left: number, right: number): boolean { return Math.abs(left - right) < 0.01; } function resolveGenericFamilyLabel(generic: string): string | null { const lower = generic.toLowerCase(); if (lower === "serif") return null; const monoLike = lower === "ui-monospace" || lower === "monospace"; const genericWidth = measureDomProbeWidth(generic); if (genericWidth === null) return null; for (const candidate of monoLike ? MONO_GENERIC_CANDIDATES : SANS_GENERIC_CANDIDATES) { if (!isFontFamilyAvailable(candidate)) continue; const candidateWidth = measureDomProbeWidth(`"${candidate}"`); if (candidateWidth !== null && widthsMatch(genericWidth, candidateWidth)) { return candidate; } } // Unnameable Apple faces need -apple-system metrics to distinguish the UI face from SF Mono. if (/mac|iphone|ipad|ipod/i.test(navigator.platform)) { const systemWidth = measureDomProbeWidth("-apple-system"); if (systemWidth !== null && widthsMatch(genericWidth, systemWidth)) return "SF Pro"; return monoLike ? "SF Mono" : "SF Pro"; } return null; } export function resolveDefaultFamilyLabel(stack: string): string | null { for (const raw of stack.split(",")) { const family = raw.trim().replace(/^(['"])(.*)\1$/, "$2"); if (family.length === 0) continue; if ( /^(system-ui|sans-serif|serif|monospace|ui-monospace|-apple-system|BlinkMacSystemFont)$/i.test( family, ) ) { const resolved = resolveGenericFamilyLabel(family); if (resolved !== null) return resolved; continue; } if (isFontFamilyAvailable(family)) return family; } return null; } export interface InstalledFontFamiliesResult { readonly families: readonly string[]; readonly status: "granted" | "denied" | "unsupported"; } let installedFamiliesCache: InstalledFontFamiliesResult | null = null; /** Call from a user gesture; the first call prompts for local-fonts permission. */ export async function queryInstalledFontFamilies(): Promise { if (installedFamiliesCache !== null) return installedFamiliesCache; const query = ( window as Window & { queryLocalFonts?: () => Promise>; } ).queryLocalFonts; if (typeof query !== "function") { installedFamiliesCache = { families: [], status: "unsupported" }; return installedFamiliesCache; } try { const fonts = await query.call(window); const families = [...new Set(fonts.map((font) => font.family))] // Dot-prefixed macOS UI faces often refuse to render in web content. .filter((family) => !family.startsWith(".")) .sort((left, right) => left.localeCompare(right)); // Denied access can return an empty list instead of throwing. if (families.length === 0) return { families: [], status: "denied" }; installedFamiliesCache = { families, status: "granted" }; return installedFamiliesCache; } catch { return { families: [], status: "denied" }; } }