From 998d7de2df7e1ac31bd581d9f1992a386ec58bf5 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sun, 7 Jun 2026 08:26:05 -0300 Subject: [PATCH] feat(fallbacks): add generic metadata --- packages/fallbacks/README.md | 9 ++++--- packages/fallbacks/fallbacks.test.ts | 39 +++++++++++++++++++++++++++- packages/fallbacks/records.json | 23 ++++++++++++++++ packages/fallbacks/src/data.ts | 23 ++++++++++++++++ packages/fallbacks/src/fallbacks.ts | 11 +++++--- packages/fallbacks/src/index.ts | 1 + packages/fallbacks/src/types.ts | 23 +++++++++++++--- 7 files changed, 116 insertions(+), 13 deletions(-) diff --git a/packages/fallbacks/README.md b/packages/fallbacks/README.md index a55d56e..3572fba 100644 --- a/packages/fallbacks/README.md +++ b/packages/fallbacks/README.md @@ -25,7 +25,7 @@ const fallback = getRenderableFallback("Helvetica", { canRenderFamily: (family) => bundledFamilies.has(family), }); -// { substituteFamily: "Liberation Sans", policyAction: "substitute", verdict: "metric_safe", lineBreakSafe: true, evidenceId: "helvetica" } +// { substituteFamily: "Liberation Sans", policyAction: "substitute", verdict: "metric_safe", lineBreakSafe: true, evidenceId: "helvetica", generic: "sans-serif" } ``` The result is `null` when there is nothing renderable from your available assets. Use `getFallbackDecision` when you need to know why. @@ -38,10 +38,10 @@ Use `getFallbackDecision` for UI, diagnostics, and reporting. It distinguishes k import { getFallbackDecision } from "@docfonts/fallbacks"; getFallbackDecision("Aptos"); -// { kind: "customer_supplied", evidenceId: "aptos" } +// { kind: "customer_supplied", evidenceId: "aptos", generic: "sans-serif" } getFallbackDecision("Tahoma"); -// { kind: "no_recommended_fallback", evidenceId: "tahoma" } +// { kind: "no_recommended_fallback", evidenceId: "tahoma", generic: "sans-serif" } getFallbackDecision("Made Up Font"); // { kind: "unknown" } @@ -49,7 +49,7 @@ getFallbackDecision("Made Up Font"); getFallbackDecision("Georgia", { canRenderFamily: (family) => bundledFamilies.has(family), }); -// { kind: "asset_missing", substituteFamily: "Gelasio", verdict: "near_metric", evidenceId: "georgia" } +// { kind: "asset_missing", substituteFamily: "Gelasio", verdict: "near_metric", evidenceId: "georgia", generic: "serif" } ``` Decision kinds: @@ -86,6 +86,7 @@ Keys are normalized. Use `normalizeFamilyName` for lookups. Rows whose substitut - `lineBreakSafe` - true when advances preserve line breaks: `metric_safe`, `near_metric`, or monospace `cell_width_only`. - `faces` - reviewed face coverage for this evidence row. If any face is `true`, respect it as face-scoped coverage (a row can be Regular-only). If all faces are `false`, the row is **not** face-scoped (e.g. a category fallback whose physical font does have faces) and the face-aware helpers treat it as renderable for any face. - `evidenceId` - the stable id for the reviewed evidence row; look the full row up in `SUBSTITUTION_EVIDENCE`. +- `generic` - the logical font's broad CSS category (`serif`, `sans-serif`, or `monospace`), for a last-resort generic `font-family` keyword when no named substitute renders. Also present on the known (non-`unknown`) decision kinds. - `glyphExceptions` - named glyph-level divergences that qualify this fallback (e.g. one codepoint reflows), or omitted when none. A family lookup carries all of the row's; a face lookup (`getRenderableFallbackForFace`) carries only that face's, so Cambria Regular shows none while Bold Italic shows its grave-accent exception. `cell_width_only` keeps monospace advances stable, but glyph shapes can still differ. A `substitute` can still have a lower-fidelity `verdict` when one face or glyph is qualified. The verdict is the fidelity signal. diff --git a/packages/fallbacks/fallbacks.test.ts b/packages/fallbacks/fallbacks.test.ts index 90c40c7..0a6f5c9 100644 --- a/packages/fallbacks/fallbacks.test.ts +++ b/packages/fallbacks/fallbacks.test.ts @@ -16,7 +16,7 @@ import { SUBSTITUTION_EVIDENCE, } from "./src/index"; -// A consumer that ships exactly the five families the reference renderer bundles. +// A consumer that ships exactly these five open fallback families. const BUNDLED = new Set([ "Carlito", "Caladea", @@ -37,6 +37,7 @@ describe("getFallbackDecision", () => { lineBreakSafe: true, faces: { regular: true, bold: true, italic: true, boldItalic: true }, evidenceId: "helvetica", + generic: "sans-serif", }, }); }); @@ -48,6 +49,7 @@ describe("getFallbackDecision", () => { substituteFamily: "Gelasio", verdict: "near_metric", evidenceId: "georgia", + generic: "serif", }); }); @@ -56,14 +58,17 @@ describe("getFallbackDecision", () => { expect(getFallbackDecision("Aptos")).toEqual({ kind: "customer_supplied", evidenceId: "aptos", + generic: "sans-serif", }); expect(getFallbackDecision("Tahoma")).toEqual({ kind: "no_recommended_fallback", evidenceId: "tahoma", + generic: "sans-serif", }); expect(getFallbackDecision("Cambria Math")).toEqual({ kind: "preserve_only", evidenceId: "cambria-math", + generic: "serif", }); expect(getFallbackDecision("Foo Unknown Font")).toEqual({ kind: "unknown", @@ -124,6 +129,10 @@ describe("createFallbackMap", () => { "times new roman", ]); expect(map.helvetica.substituteFamily).toBe("Liberation Sans"); + // Each entry carries the logical font's generic, so a face-aware resolver can emit a keyword. + expect(map.helvetica.generic).toBe("sans-serif"); + expect(map["times new roman"].generic).toBe("serif"); + expect(map["courier new"].generic).toBe("monospace"); // Georgia/Arial Narrow/Baskerville point at un-bundled families, so they are absent. expect(map.georgia).toBeUndefined(); }); @@ -186,6 +195,7 @@ describe("face-aware lookups (Regular-only safety)", () => { kind: "face_missing", substituteFamily: "Bacasime Antique", evidenceId: "baskerville-old-face", + generic: "serif", }); }); @@ -290,6 +300,30 @@ describe("glyphExceptions projection", () => { }); }); +describe("generic CSS family metadata", () => { + const renderAll = { canRenderFamily: () => true }; + + test("every evidence row carries one of the broad generic categories", () => { + const GENERICS = new Set(["serif", "sans-serif", "monospace"]); + for (const row of SUBSTITUTION_EVIDENCE) { + expect( + GENERICS.has(row.generic), + `${row.evidenceId} (${row.generic})`, + ).toBe(true); + } + }); + + test("a resolved fallback projects the logical font's generic", () => { + expect(getRenderableFallback("Cambria", renderAll)?.generic).toBe("serif"); + expect(getRenderableFallback("Calibri", renderAll)?.generic).toBe( + "sans-serif", + ); + expect(getRenderableFallback("Consolas", renderAll)?.generic).toBe( + "monospace", + ); + }); +}); + describe("Cooper Black -> Caprasimo (Regular-only, metric_safe)", () => { const renderAll = { canRenderFamily: () => true }; const onlyCaprasimo = { canRenderFamily: (f: string) => f === "Caprasimo" }; @@ -304,6 +338,7 @@ describe("Cooper Black -> Caprasimo (Regular-only, metric_safe)", () => { lineBreakSafe: true, faces: { regular: true, bold: false, italic: false, boldItalic: false }, evidenceId: "cooper-black", + generic: "serif", }); }); @@ -324,6 +359,7 @@ describe("Cooper Black -> Caprasimo (Regular-only, metric_safe)", () => { kind: "face_missing", substituteFamily: "Caprasimo", evidenceId: "cooper-black", + generic: "serif", }); } }); @@ -337,6 +373,7 @@ describe("Cooper Black -> Caprasimo (Regular-only, metric_safe)", () => { substituteFamily: "Caprasimo", verdict: "metric_safe", evidenceId: "cooper-black", + generic: "serif", }); expect( getRenderableFallbackForFace("Cooper Black", "regular", onlyCaprasimo) diff --git a/packages/fallbacks/records.json b/packages/fallbacks/records.json index 7614b79..bf3204e 100644 --- a/packages/fallbacks/records.json +++ b/packages/fallbacks/records.json @@ -1,6 +1,7 @@ [ { "evidenceId": "calibri", + "generic": "sans-serif", "logicalFamily": "Calibri", "physicalFamily": "Carlito", "verdict": "metric_safe", @@ -30,6 +31,7 @@ }, { "evidenceId": "cambria", + "generic": "serif", "logicalFamily": "Cambria", "physicalFamily": "Caladea", "verdict": "visual_only", @@ -75,6 +77,7 @@ }, { "evidenceId": "arial", + "generic": "sans-serif", "logicalFamily": "Arial", "physicalFamily": "Liberation Sans", "verdict": "metric_safe", @@ -101,6 +104,7 @@ }, { "evidenceId": "times-new-roman", + "generic": "serif", "logicalFamily": "Times New Roman", "physicalFamily": "Liberation Serif", "verdict": "metric_safe", @@ -129,6 +133,7 @@ }, { "evidenceId": "courier-new", + "generic": "monospace", "logicalFamily": "Courier New", "physicalFamily": "Liberation Mono", "verdict": "metric_safe", @@ -157,6 +162,7 @@ }, { "evidenceId": "georgia", + "generic": "serif", "logicalFamily": "Georgia", "physicalFamily": "Gelasio", "verdict": "near_metric", @@ -212,6 +218,7 @@ }, { "evidenceId": "arial-narrow", + "generic": "sans-serif", "logicalFamily": "Arial Narrow", "physicalFamily": "Liberation Sans Narrow", "verdict": "visual_only", @@ -257,6 +264,7 @@ }, { "evidenceId": "aptos", + "generic": "sans-serif", "logicalFamily": "Aptos", "physicalFamily": null, "verdict": "no_substitute", @@ -279,6 +287,7 @@ }, { "evidenceId": "consolas", + "generic": "monospace", "logicalFamily": "Consolas", "physicalFamily": "Inconsolata SemiExpanded", "verdict": "cell_width_only", @@ -307,6 +316,7 @@ }, { "evidenceId": "verdana", + "generic": "sans-serif", "logicalFamily": "Verdana", "physicalFamily": null, "verdict": "visual_only", @@ -329,6 +339,7 @@ }, { "evidenceId": "tahoma", + "generic": "sans-serif", "logicalFamily": "Tahoma", "physicalFamily": null, "verdict": "visual_only", @@ -351,6 +362,7 @@ }, { "evidenceId": "trebuchet-ms", + "generic": "sans-serif", "logicalFamily": "Trebuchet MS", "physicalFamily": null, "verdict": "visual_only", @@ -373,6 +385,7 @@ }, { "evidenceId": "comic-sans-ms", + "generic": "sans-serif", "logicalFamily": "Comic Sans MS", "physicalFamily": "Comic Neue", "verdict": "visual_only", @@ -401,6 +414,7 @@ }, { "evidenceId": "candara", + "generic": "sans-serif", "logicalFamily": "Candara", "physicalFamily": null, "verdict": "visual_only", @@ -423,6 +437,7 @@ }, { "evidenceId": "constantia", + "generic": "serif", "logicalFamily": "Constantia", "physicalFamily": null, "verdict": "visual_only", @@ -445,6 +460,7 @@ }, { "evidenceId": "corbel", + "generic": "sans-serif", "logicalFamily": "Corbel", "physicalFamily": null, "verdict": "visual_only", @@ -467,6 +483,7 @@ }, { "evidenceId": "lucida-console", + "generic": "monospace", "logicalFamily": "Lucida Console", "physicalFamily": "Cousine", "verdict": "cell_width_only", @@ -493,6 +510,7 @@ }, { "evidenceId": "aptos-display", + "generic": "sans-serif", "logicalFamily": "Aptos Display", "physicalFamily": null, "verdict": "customer_supplied", @@ -514,6 +532,7 @@ }, { "evidenceId": "cambria-math", + "generic": "serif", "logicalFamily": "Cambria Math", "physicalFamily": null, "verdict": "preserve_only", @@ -535,6 +554,7 @@ }, { "evidenceId": "helvetica", + "generic": "sans-serif", "logicalFamily": "Helvetica", "physicalFamily": "Liberation Sans", "verdict": "metric_safe", @@ -563,6 +583,7 @@ }, { "evidenceId": "calibri-light", + "generic": "sans-serif", "logicalFamily": "Calibri Light", "physicalFamily": "Carlito", "verdict": "visual_only", @@ -589,6 +610,7 @@ }, { "evidenceId": "baskerville-old-face", + "generic": "serif", "logicalFamily": "Baskerville Old Face", "physicalFamily": "Bacasime Antique", "verdict": "visual_only", @@ -628,6 +650,7 @@ }, { "evidenceId": "cooper-black", + "generic": "serif", "logicalFamily": "Cooper Black", "physicalFamily": "Caprasimo", "verdict": "metric_safe", diff --git a/packages/fallbacks/src/data.ts b/packages/fallbacks/src/data.ts index 4ccc309..c8e35e2 100644 --- a/packages/fallbacks/src/data.ts +++ b/packages/fallbacks/src/data.ts @@ -4,6 +4,7 @@ import type { SubstitutionEvidence } from "./types.js"; export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [ { "evidenceId": "calibri", + "generic": "sans-serif", "logicalFamily": "Calibri", "physicalFamily": "Carlito", "verdict": "metric_safe", @@ -33,6 +34,7 @@ export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [ }, { "evidenceId": "cambria", + "generic": "serif", "logicalFamily": "Cambria", "physicalFamily": "Caladea", "verdict": "visual_only", @@ -78,6 +80,7 @@ export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [ }, { "evidenceId": "arial", + "generic": "sans-serif", "logicalFamily": "Arial", "physicalFamily": "Liberation Sans", "verdict": "metric_safe", @@ -106,6 +109,7 @@ export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [ }, { "evidenceId": "times-new-roman", + "generic": "serif", "logicalFamily": "Times New Roman", "physicalFamily": "Liberation Serif", "verdict": "metric_safe", @@ -134,6 +138,7 @@ export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [ }, { "evidenceId": "courier-new", + "generic": "monospace", "logicalFamily": "Courier New", "physicalFamily": "Liberation Mono", "verdict": "metric_safe", @@ -162,6 +167,7 @@ export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [ }, { "evidenceId": "georgia", + "generic": "serif", "logicalFamily": "Georgia", "physicalFamily": "Gelasio", "verdict": "near_metric", @@ -217,6 +223,7 @@ export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [ }, { "evidenceId": "arial-narrow", + "generic": "sans-serif", "logicalFamily": "Arial Narrow", "physicalFamily": "Liberation Sans Narrow", "verdict": "visual_only", @@ -262,6 +269,7 @@ export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [ }, { "evidenceId": "aptos", + "generic": "sans-serif", "logicalFamily": "Aptos", "physicalFamily": null, "verdict": "no_substitute", @@ -286,6 +294,7 @@ export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [ }, { "evidenceId": "consolas", + "generic": "monospace", "logicalFamily": "Consolas", "physicalFamily": "Inconsolata SemiExpanded", "verdict": "cell_width_only", @@ -314,6 +323,7 @@ export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [ }, { "evidenceId": "verdana", + "generic": "sans-serif", "logicalFamily": "Verdana", "physicalFamily": null, "verdict": "visual_only", @@ -338,6 +348,7 @@ export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [ }, { "evidenceId": "tahoma", + "generic": "sans-serif", "logicalFamily": "Tahoma", "physicalFamily": null, "verdict": "visual_only", @@ -362,6 +373,7 @@ export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [ }, { "evidenceId": "trebuchet-ms", + "generic": "sans-serif", "logicalFamily": "Trebuchet MS", "physicalFamily": null, "verdict": "visual_only", @@ -386,6 +398,7 @@ export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [ }, { "evidenceId": "comic-sans-ms", + "generic": "sans-serif", "logicalFamily": "Comic Sans MS", "physicalFamily": "Comic Neue", "verdict": "visual_only", @@ -414,6 +427,7 @@ export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [ }, { "evidenceId": "candara", + "generic": "sans-serif", "logicalFamily": "Candara", "physicalFamily": null, "verdict": "visual_only", @@ -438,6 +452,7 @@ export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [ }, { "evidenceId": "constantia", + "generic": "serif", "logicalFamily": "Constantia", "physicalFamily": null, "verdict": "visual_only", @@ -462,6 +477,7 @@ export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [ }, { "evidenceId": "corbel", + "generic": "sans-serif", "logicalFamily": "Corbel", "physicalFamily": null, "verdict": "visual_only", @@ -486,6 +502,7 @@ export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [ }, { "evidenceId": "lucida-console", + "generic": "monospace", "logicalFamily": "Lucida Console", "physicalFamily": "Cousine", "verdict": "cell_width_only", @@ -514,6 +531,7 @@ export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [ }, { "evidenceId": "aptos-display", + "generic": "sans-serif", "logicalFamily": "Aptos Display", "physicalFamily": null, "verdict": "customer_supplied", @@ -535,6 +553,7 @@ export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [ }, { "evidenceId": "cambria-math", + "generic": "serif", "logicalFamily": "Cambria Math", "physicalFamily": null, "verdict": "preserve_only", @@ -556,6 +575,7 @@ export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [ }, { "evidenceId": "helvetica", + "generic": "sans-serif", "logicalFamily": "Helvetica", "physicalFamily": "Liberation Sans", "verdict": "metric_safe", @@ -584,6 +604,7 @@ export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [ }, { "evidenceId": "calibri-light", + "generic": "sans-serif", "logicalFamily": "Calibri Light", "physicalFamily": "Carlito", "verdict": "visual_only", @@ -612,6 +633,7 @@ export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [ }, { "evidenceId": "baskerville-old-face", + "generic": "serif", "logicalFamily": "Baskerville Old Face", "physicalFamily": "Bacasime Antique", "verdict": "visual_only", @@ -651,6 +673,7 @@ export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = [ }, { "evidenceId": "cooper-black", + "generic": "serif", "logicalFamily": "Cooper Black", "physicalFamily": "Caprasimo", "verdict": "metric_safe", diff --git a/packages/fallbacks/src/fallbacks.ts b/packages/fallbacks/src/fallbacks.ts index c9a1729..5ea2ab4 100644 --- a/packages/fallbacks/src/fallbacks.ts +++ b/packages/fallbacks/src/fallbacks.ts @@ -83,6 +83,7 @@ function buildFallback( lineBreakSafe: LINE_BREAK_SAFE_VERDICTS.has(verdict), faces: row.faces, evidenceId: row.evidenceId, + generic: row.generic, ...(glyphExceptions && glyphExceptions.length > 0 ? { glyphExceptions } : {}), @@ -94,16 +95,16 @@ function decideRow( row: SubstitutionEvidence, canRenderFamily: CanRenderFamily | undefined, ): FallbackDecision { - const { policyAction, physicalFamily, verdict, evidenceId } = row; + const { policyAction, physicalFamily, verdict, evidenceId, generic } = row; // Deliberate non-substitution policies first: nothing renders in the original's place. if (policyAction === "preserve_only") - return { kind: "preserve_only", evidenceId }; + return { kind: "preserve_only", evidenceId, generic }; if (policyAction === "customer_supplied") - return { kind: "customer_supplied", evidenceId }; + return { kind: "customer_supplied", evidenceId, generic }; // substitute / category_fallback with no named open family: docfonts knows the font but recommends // no renderable family - distinct from the `no_substitute` verdict (read the row for that nuance). if (physicalFamily === null) - return { kind: "no_recommended_fallback", evidenceId }; + return { kind: "no_recommended_fallback", evidenceId, generic }; // Named substitute the consumer does not bundle: surfaced so a UI can say which font to add. if (canRenderFamily && !canRenderFamily(physicalFamily)) return { @@ -111,6 +112,7 @@ function decideRow( substituteFamily: physicalFamily, verdict, evidenceId, + generic, }; return { kind: "fallback", @@ -146,6 +148,7 @@ function decideRowForFace( kind: "face_missing", substituteFamily: base.fallback.substituteFamily, evidenceId: row.evidenceId, + generic: row.generic, }; const faceVerdict = row.faceVerdicts?.[face] ?? row.verdict; return { diff --git a/packages/fallbacks/src/index.ts b/packages/fallbacks/src/index.ts index 47e6545..7a2c85f 100644 --- a/packages/fallbacks/src/index.ts +++ b/packages/fallbacks/src/index.ts @@ -16,6 +16,7 @@ export { } from "./fallbacks.js"; export type { AdvanceDelta, + CssGeneric, FaceCoverage, FaceSlot, FallbackDecision, diff --git a/packages/fallbacks/src/types.ts b/packages/fallbacks/src/types.ts index 268a9f0..d2407af 100644 --- a/packages/fallbacks/src/types.ts +++ b/packages/fallbacks/src/types.ts @@ -26,6 +26,13 @@ export type GateStatus = "pass" | "not_run" | "fail"; /** RIBBI face slot - the renderer's coarse face bucket. */ export type FaceSlot = "regular" | "bold" | "italic" | "boldItalic"; +/** + * CSS generic family for the logical font: the broad category a renderer can drop in as a + * last-resort `font-family` keyword when no named substitute renders. Only the categories consumers + * currently need. + */ +export type CssGeneric = "serif" | "sans-serif" | "monospace"; + /** Advance-width divergence vs the proprietary oracle, as fractions (0 = identical advances). */ export interface AdvanceDelta { meanDelta: number; @@ -66,6 +73,8 @@ export interface SubstitutionEvidence { evidenceId: string; /** the proprietary family the document asks for, e.g. "Cambria". */ logicalFamily: string; + /** the logical font's broad CSS category, for a last-resort generic `font-family` keyword. */ + generic: CssGeneric; /** the physical substitute rendered in its place; null when no candidate is recommended. */ physicalFamily: string | null; /** worst-face fidelity verdict (the public summary; see `faceVerdicts` when faces disagree). */ @@ -120,6 +129,8 @@ export interface FontFallback { faces: FaceCoverage; /** stable reviewed-evidence id; look the full row up in {@link SUBSTITUTION_EVIDENCE}. */ evidenceId: string; + /** the logical font's broad CSS category, for a last-resort generic `font-family` keyword. */ + generic: CssGeneric; /** * Named glyph-level divergences that qualify this fallback (e.g. one codepoint reflows). Scoped to * the lookup: a family lookup ({@link getRenderableFallback}) carries ALL of the row's exceptions; a @@ -137,7 +148,9 @@ export interface FontFallback { * but the consumer does not bundle it (`asset_missing`), and the deliberate non-substitution policies * (`preserve_only`, `customer_supplied`). The face-aware lookups add `face_missing`: a substitute is * recommended for the family but does NOT provide the requested face. `evidenceId` on the terminal - * kinds points back into {@link SUBSTITUTION_EVIDENCE} for the full row (verdict, faces, ...). + * kinds points back into {@link SUBSTITUTION_EVIDENCE} for the full row (verdict, faces, ...). The + * known (non-`unknown`) kinds also carry the logical font's `generic`, so a consumer with no + * renderable substitute can still emit a same-category generic `font-family` keyword. */ export type FallbackDecision = | { kind: "fallback"; fallback: FontFallback } @@ -146,6 +159,7 @@ export type FallbackDecision = substituteFamily: string; verdict: Verdict; evidenceId: string; + generic: CssGeneric; } | { /** the family has a renderable substitute, but it does not provide the requested face - route @@ -153,8 +167,9 @@ export type FallbackDecision = kind: "face_missing"; substituteFamily: string; evidenceId: string; + generic: CssGeneric; } - | { kind: "no_recommended_fallback"; evidenceId: string } - | { kind: "customer_supplied"; evidenceId: string } - | { kind: "preserve_only"; evidenceId: string } + | { kind: "no_recommended_fallback"; evidenceId: string; generic: CssGeneric } + | { kind: "customer_supplied"; evidenceId: string; generic: CssGeneric } + | { kind: "preserve_only"; evidenceId: string; generic: CssGeneric } | { kind: "unknown" };