Chart fonts: self-hosted roster + document.fonts.load render gate

This commit is contained in:
2026-06-14 13:08:16 +03:00
parent df6a0b3845
commit b03c3b08ab
8 changed files with 353 additions and 10 deletions
+51
View File
@@ -3,6 +3,7 @@ import {
CURRENT_THEME_VERSION,
THEME_FONT_OPTIONS,
applyFontToConfig,
collectFontFamilies,
createCustomTheme,
} from './custom-theme';
import { THEME_PREVIEW_SPECS } from './theme-preview-specs';
@@ -62,13 +63,63 @@ describe('applyFontToConfig', () => {
});
});
describe('collectFontFamilies', () => {
it('collects the top-level font and every *Font slot', () => {
const config = {
font: 'Inter',
axis: { labelFont: 'Spectral', titleFont: 'Spectral' },
legend: { labelFont: 'Caveat' },
};
expect(collectFontFamilies(config)).toEqual(new Set(['Inter', 'Spectral', 'Caveat']));
});
it('walks arrays (layered specs)', () => {
const spec = { layer: [{ mark: { font: 'A' } }, { mark: { font: 'B' } }] };
expect(collectFontFamilies(spec)).toEqual(new Set(['A', 'B']));
});
it('skips data and datasets (rows are never fonts)', () => {
const spec = {
data: { values: [{ font: 'NotAFont', x: 1 }] },
datasets: { d: [{ titleFont: 'AlsoNot' }] },
config: { font: 'Real' },
};
expect(collectFontFamilies(spec)).toEqual(new Set(['Real']));
});
it('ignores non-string font values', () => {
expect(collectFontFamilies({ font: 42, fontSize: 11 })).toEqual(new Set());
});
it('returns family stacks verbatim (the render gate loads them as-is)', () => {
expect(collectFontFamilies({ font: '"Inter", system-ui, sans-serif' })).toEqual(
new Set(['"Inter", system-ui, sans-serif']),
);
});
it('finds the font a theme applies (round-trip with applyFontToConfig)', () => {
const applied = applyFontToConfig({ axis: { labelFont: 'Old' } }, 'New');
expect(collectFontFamilies(applied)).toEqual(new Set(['New']));
});
});
describe('THEME_FONT_OPTIONS', () => {
it('offers distinct, non-empty CSS stacks', () => {
expect(THEME_FONT_OPTIONS.length).toBeGreaterThanOrEqual(4);
const values = THEME_FONT_OPTIONS.map((f) => f.value);
const labels = THEME_FONT_OPTIONS.map((f) => f.label);
expect(new Set(values).size).toBe(values.length);
expect(new Set(labels).size).toBe(labels.length);
expect(values.every((v) => v.trim().length > 0)).toBe(true);
});
it('every self-hosted family (quoted primary) carries a category fallback', () => {
// A quoted primary that hasn't loaded must degrade to a sensible system font,
// so each roster stack lists at least one fallback after the primary.
for (const { value } of THEME_FONT_OPTIONS.filter((o) => o.value.startsWith('"'))) {
expect(value).toContain(',');
}
});
});
describe('THEME_PREVIEW_SPECS', () => {
+50 -6
View File
@@ -84,18 +84,62 @@ export interface ThemeFontOption {
}
/**
* Fonts the builder can apply today: the two self-hosted Plex faces the app
* already loads, plus web-safe/system stacks that need no loading at all. Every
* entry is render-safe without a `document.fonts.load` gate — Plex is loaded by
* the UI before any chart renders, the rest resolve to locally installed faces.
* The self-hosted roster (scope doc §4.5) extends this list and brings the
* pre-render loading gate with it.
* Fonts the builder can apply: the self-hosted roster (scope doc §3 — chart
* fonts, registered in styles/chart-fonts.css) followed by web-safe/system
* stacks that need no loading. The roster faces require their woff2 to be loaded
* before a chart measures text, which the render path's font gate handles
* (`collectFontFamilies` + `document.fonts.load`); the system stacks resolve to
* locally installed faces. Order is by role so the in-face dropdown reads as a
* specimen. Every primary family pairs with a category-appropriate fallback.
*/
export const THEME_FONT_OPTIONS: ReadonlyArray<ThemeFontOption> = [
// Sans
{ value: '"IBM Plex Sans", system-ui, -apple-system, sans-serif', label: 'IBM Plex Sans' },
{ value: '"Inter", system-ui, sans-serif', label: 'Inter' },
{ value: '"Libre Franklin", system-ui, sans-serif', label: 'Libre Franklin' },
{ value: '"Roboto Condensed", system-ui, sans-serif', label: 'Roboto Condensed' },
{ value: '"IBM Plex Sans Condensed", system-ui, sans-serif', label: 'IBM Plex Sans Condensed' },
// Serif
{ value: '"IBM Plex Serif", Georgia, serif', label: 'IBM Plex Serif' },
{ value: '"Source Serif 4", Georgia, serif', label: 'Source Serif 4' },
{ value: '"Spectral", Georgia, serif', label: 'Spectral' },
// Mono
{ value: '"IBM Plex Mono", ui-monospace, monospace', label: 'IBM Plex Mono' },
{ value: '"Space Mono", ui-monospace, monospace', label: 'Space Mono' },
// Display
{ value: '"Space Grotesk", system-ui, sans-serif', label: 'Space Grotesk' },
{ value: '"Playfair Display", Georgia, serif', label: 'Playfair Display' },
// Handwriting
{ value: '"Caveat", cursive', label: 'Caveat' },
// System / web-safe (no load)
{ value: 'system-ui, -apple-system, sans-serif', label: 'System UI' },
{ value: 'Helvetica, Arial, sans-serif', label: 'Helvetica / Arial' },
{ value: 'Georgia, "Times New Roman", serif', label: 'Georgia' },
{ value: '"Courier New", Courier, monospace', label: 'Courier' },
];
/**
* Collect every font family stack referenced under a `font` or `*Font` key
* anywhere in `value` (a Vega-Lite spec or config) — the read-counterpart of
* `applyFontToConfig`'s write. The render path uses it to know which faces to
* `document.fonts.load` before measuring text. `data`/`datasets` are skipped:
* they hold dataset rows (potentially huge, never fonts), so walking them is
* wasted work. Returns the distinct stacks, in no particular order.
*/
export function collectFontFamilies(value: unknown): Set<string> {
const out = new Set<string>();
const walk = (node: unknown): void => {
if (Array.isArray(node)) {
for (const item of node) walk(item);
return;
}
if (!isJsonObject(node)) return;
for (const [key, v] of Object.entries(node)) {
if (key === 'data' || key === 'datasets') continue;
if ((key === 'font' || key.endsWith('Font')) && typeof v === 'string') out.add(v);
else walk(v);
}
};
walk(value);
return out;
}