/** * Font loading utility for the grain design system — the brand faces. * * Self-hosted OFL variable fonts (Archivo, Source Serif 4), embedded as * base64 woff2 with `font-display: block`. No ``, no Google Fonts * network request, no `display=swap` FOUT-by-design. * * `document.fonts.check()` ALONE IS NOT PROOF a face is actually loaded * and painting. The CSS font-matching algorithm it consults can be * satisfied by any registered face, including one that has merely been * *declared*, not successfully downloaded and parsed — so it can report * `true` for a face that never actually renders. loadFonts() therefore * runs a proof per face and reports it, rather than asserting it * silently: * * 1. FontFace status — `await face.load()`, then `face.status === 'loaded'`. * 2. `document.fonts.check()` for the face — required, but never * trusted alone (see above). * 3. Laid-out width divergence — identical text measured once with the * face in the stack and once forced onto the bare fallback generic. * Identical widths mean the fallback painted and steps 1/2 lied. * * A face is `proved` only when all three agree. See * foundry-business-hearth, proposal 20260802T2107, foundation/design-system.md §3.1. */ import { ARCHIVO_WOFF2_BASE64 } from './data/archivo.b64'; import { SOURCE_SERIF_4_WOFF2_BASE64 } from './data/source-serif-4.b64'; interface FontFaceSpec { family: string; base64: string; /** CSS font-weight range for the variable font. */ weightRange: string; /** Bare generic fallback used for the width-divergence probe. */ fallbackGeneric: string; } const FONT_FACES: FontFaceSpec[] = [ { family: 'Archivo', base64: ARCHIVO_WOFF2_BASE64, weightRange: '400 700', fallbackGeneric: 'sans-serif', }, { family: 'Source Serif 4', base64: SOURCE_SERIF_4_WOFF2_BASE64, weightRange: '400 700', fallbackGeneric: 'serif', }, ]; // Long, varied text at a large size so a real metric divergence is many // pixels — never swallowed by subpixel rounding — while a fallback-only // render lands within the epsilon below. const PROOF_TEXT = 'The quick brown fox jumps over the lazy dog 0123456789'; const PROOF_SIZE_PX = 48; const WIDTH_DIVERGENCE_EPSILON_PX = 1; export interface FontLoadFaceReport { family: string; /** Step 1: the FontFace object itself reports 'loaded'. */ faceLoaded: boolean; /** Step 2: document.fonts.check() for this family. Required, but see module doc — not trusted alone. */ checkPasses: boolean; /** Step 3: a laid-out width measured with this face in the stack differs from the * same text laid out with the bare fallback generic. */ widthDivergent: boolean; /** faceLoaded && checkPasses && widthDivergent — all three, none alone. */ proved: boolean; error?: string; } export interface FontLoadReport { faces: FontLoadFaceReport[]; ok: boolean; } function measureWidth(fontFamilyCss: string): number { const span = document.createElement('span'); span.textContent = PROOF_TEXT; span.setAttribute('aria-hidden', 'true'); span.style.cssText = [ 'position: absolute', 'visibility: hidden', 'white-space: nowrap', 'left: -99999px', 'top: -99999px', `font-family: ${fontFamilyCss}`, `font-size: ${PROOF_SIZE_PX}px`, ].join(';'); document.body.appendChild(span); const width = span.getBoundingClientRect().width; document.body.removeChild(span); return width; } /** * `display: 'block'` per `migration.md` §7's contract — mirrors * `scripts/build-grain-css.mjs`'s `fontFace()` CSS output (`font-display: block`) * so the JS runtime path `loadFonts()` actually calls, and the static * `dist/grain.css` `@font-face` rule, agree. The FontFace API defaults * `display` to `'auto'`, not `'block'`, when the descriptor omits it — * exported standalone so it is unit-testable without a real `FontFace` * implementation (vitest's node environment has neither, see load.test.ts). */ export function faceDescriptor(weightRange: string): FontFaceDescriptors { return { weight: weightRange, style: 'normal', display: 'block' }; } async function proveFace(spec: FontFaceSpec): Promise { const { family, base64, weightRange, fallbackGeneric } = spec; const face = new FontFace( family, `url(data:font/woff2;base64,${base64})`, faceDescriptor(weightRange), ); let faceLoaded = false; let error: string | undefined; try { await face.load(); faceLoaded = face.status === 'loaded'; document.fonts.add(face); } catch (err) { faceLoaded = false; error = err instanceof Error ? err.message : String(err); } const checkPasses = document.fonts.check(`${PROOF_SIZE_PX}px "${family}"`); const loadedWidth = measureWidth(`"${family}", ${fallbackGeneric}`); const fallbackWidth = measureWidth(fallbackGeneric); const widthDivergent = Math.abs(loadedWidth - fallbackWidth) > WIDTH_DIVERGENCE_EPSILON_PX; return { family, faceLoaded, checkPasses, widthDivergent, proved: faceLoaded && checkPasses && widthDivergent, ...(error ? { error } : {}), }; } /** * Loads the self-hosted brand faces and returns a structured proof * report. Never throws — a failed face is reported, not thrown, so a * font regression degrades to the CSS fallback stack rather than * crashing app startup. Failures are logged loudly via console.error; * callers that need to gate on the result should inspect `report.ok`. */ async function loadFonts(): Promise { if (typeof document === 'undefined' || typeof FontFace === 'undefined') { return { faces: [], ok: false }; } const faces = await Promise.all(FONT_FACES.map(proveFace)); const ok = faces.every((f) => f.proved); if (!ok) { for (const f of faces.filter((f) => !f.proved)) { // eslint-disable-next-line no-console console.error( `[grain/fonts] "${f.family}" failed the load proof — ` + `faceLoaded=${f.faceLoaded} checkPasses=${f.checkPasses} widthDivergent=${f.widthDivergent}` + (f.error ? ` error=${f.error}` : ''), ); } } return { faces, ok }; } export { loadFonts, FONT_FACES, PROOF_TEXT, PROOF_SIZE_PX, WIDTH_DIVERGENCE_EPSILON_PX };