/**
* 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 };