From b03c3b08aba6c6ab3084c19c126d633e8e20f2ec Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Sun, 14 Jun 2026 13:08:16 +0300 Subject: [PATCH] Chart fonts: self-hosted roster + document.fonts.load render gate --- package-lock.json | 110 +++++++++++++++++++++++++++++ package.json | 11 +++ src/app/services/chart-renderer.ts | 42 +++++++++++ src/core/custom-theme.test.ts | 51 +++++++++++++ src/core/custom-theme.ts | 56 +++++++++++++-- src/main.tsx | 3 + styles/chart-fonts.css | 51 +++++++++++++ vite.config.ts | 39 ++++++++-- 8 files changed, 353 insertions(+), 10 deletions(-) create mode 100644 styles/chart-fonts.css diff --git a/package-lock.json b/package-lock.json index da1e0b5..620a6ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,19 @@ "name": "astrolabe", "version": "0.1.0", "dependencies": { + "@fontsource/caveat": "^5.2.8", "@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/ibm-plex-sans": "^5.2.8", + "@fontsource/ibm-plex-sans-condensed": "^5.2.8", + "@fontsource/ibm-plex-serif": "^5.2.7", + "@fontsource/inter": "^5.2.8", + "@fontsource/libre-franklin": "^5.2.8", + "@fontsource/playfair-display": "^5.2.8", + "@fontsource/roboto-condensed": "^5.2.8", + "@fontsource/source-serif-4": "^5.2.9", + "@fontsource/space-grotesk": "^5.2.10", + "@fontsource/space-mono": "^5.2.9", + "@fontsource/spectral": "^5.2.8", "json-stringify-pretty-compact": "^4.0.0", "monaco-editor": "^0.54.0", "react": "^19.2.7", @@ -2204,6 +2215,15 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@fontsource/caveat": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/caveat/-/caveat-5.2.8.tgz", + "integrity": "sha512-9fUUfFE2IQFKbx+xOcaeQxxmh8iJguEb8z+j1PeueO4UUx+XfT4pRm/B04ZDvFA794/iRxY/IibmP8ZKtIf4rw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@fontsource/ibm-plex-mono": { "version": "5.2.7", "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.7.tgz", @@ -2222,6 +2242,96 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@fontsource/ibm-plex-sans-condensed": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans-condensed/-/ibm-plex-sans-condensed-5.2.8.tgz", + "integrity": "sha512-FpGhTq7Yja1E4fz3HKhjSJadNQCuE45e4YhznhsQ2FaReuv4lV+IrTTRJne2O35tXL4EP3lD7vb19rW6qG9iUw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ibm-plex-serif": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-serif/-/ibm-plex-serif-5.2.7.tgz", + "integrity": "sha512-04owmc2OQQ/DAMjXjEMJc+7V4dBVmR01aIFOg4cuqS8pQ4HbvtlYs/u6+O0vCt21EMx5M/azIbgx43iKyisEOA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/inter": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/libre-franklin": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/libre-franklin/-/libre-franklin-5.2.8.tgz", + "integrity": "sha512-1n4FW4CbAql7G+aZIUmcX4M8R8bkpzbTXSS4ZYZwKd2rrq+bHzymF+PCC69FVIn7W9VFngGa8humDHvyFuLJwA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/playfair-display": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/playfair-display/-/playfair-display-5.2.8.tgz", + "integrity": "sha512-fUEhib70SszNhQVsGbUMSsWJhr7Je0rdeuZdtGpDNu0GKF1xJM8QhpI/y0pckU25GcChXm9TLOmeZupkvvZo2g==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/roboto-condensed": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/roboto-condensed/-/roboto-condensed-5.2.8.tgz", + "integrity": "sha512-M2OxXXGJQeif+LhxHjWiCEX3BHIYeZf64CcUTeh9AQFGp7DXo2UAXZiZUUOalVdOiuUtQSIi2CDHLDWFiyoECQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/source-serif-4": { + "version": "5.2.9", + "resolved": "https://registry.npmjs.org/@fontsource/source-serif-4/-/source-serif-4-5.2.9.tgz", + "integrity": "sha512-er/Pym9emsEVJNf947umJ4kXarXfsiN6CN7kTYinefKRaHLwiquiiHOZvKvxWgkV8JMCf3pV3g0NcsPFpVCH9w==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/space-grotesk": { + "version": "5.2.10", + "resolved": "https://registry.npmjs.org/@fontsource/space-grotesk/-/space-grotesk-5.2.10.tgz", + "integrity": "sha512-XNXEbT74OIITPqw2H6HXwPDp85fy43uxfBwFR5PU+9sLnjuLj12KlhVM9nZVN6q6dlKjkuN8JisW/OBxwxgUew==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/space-mono": { + "version": "5.2.9", + "resolved": "https://registry.npmjs.org/@fontsource/space-mono/-/space-mono-5.2.9.tgz", + "integrity": "sha512-b61faFOHEISQ/pD25G+cfGY9o/WW6lRv6hBQQfpWvEJ4y1V+S4gmth95EVyBE2VL3qDYHeVQ8nBzrplzdXTDDg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/spectral": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/spectral/-/spectral-5.2.8.tgz", + "integrity": "sha512-0CqZ8/z8A38BdeSz5t2NSd5tCO1R9P1k7SF2VpRLMbZTMSSR6+2Dk0ZBMfEQK/IrBLQS1HgHLMqpdTwRpnKpGQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", diff --git a/package.json b/package.json index 5d775e6..22243ac 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,19 @@ "*.{json,css,md}": "prettier --write" }, "dependencies": { + "@fontsource/caveat": "^5.2.8", "@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/ibm-plex-sans": "^5.2.8", + "@fontsource/ibm-plex-sans-condensed": "^5.2.8", + "@fontsource/ibm-plex-serif": "^5.2.7", + "@fontsource/inter": "^5.2.8", + "@fontsource/libre-franklin": "^5.2.8", + "@fontsource/playfair-display": "^5.2.8", + "@fontsource/roboto-condensed": "^5.2.8", + "@fontsource/source-serif-4": "^5.2.9", + "@fontsource/space-grotesk": "^5.2.10", + "@fontsource/space-mono": "^5.2.9", + "@fontsource/spectral": "^5.2.8", "json-stringify-pretty-compact": "^4.0.0", "monaco-editor": "^0.54.0", "react": "^19.2.7", diff --git a/src/app/services/chart-renderer.ts b/src/app/services/chart-renderer.ts index e6766af..9b20a5a 100644 --- a/src/app/services/chart-renderer.ts +++ b/src/app/services/chart-renderer.ts @@ -10,6 +10,7 @@ import vegaEmbed, { type Result as EmbedResult } from 'vega-embed'; import type { VisualizationSpec } from 'vega-embed'; import type { Config } from 'vega-lite'; +import { collectFontFamilies } from '@core/custom-theme'; /** Options for `RenderHandle.toImageURL` (spec §08 → Per-chart export). */ interface ImageExportOptions { @@ -145,6 +146,44 @@ function canvasObjectUrl(canvas: HTMLCanvasElement): Promise { }); } +/** Weights to preload per family — the roster ships 400/600 (700 for the mono). */ +const FONT_LOAD_WEIGHTS = ['400', '600', '700']; +/** + * Cap on waiting for fonts before rendering anyway. A precached/cached face + * resolves near-instantly; this only bounds the first fetch of an uncached + * subset so a slow network never freezes the preview — it renders with fallback + * metrics and sharpens on a later re-render once the face is cached. + */ +const FONT_LOAD_TIMEOUT_MS = 3000; + +/** + * Load the font faces a spec/config will use before rendering. Vega measures + * every text label via canvas `measureText` regardless of renderer (even the + * 'none' probe runs layout), so a face that finishes loading *after* embed + * leaves the whole chart laid out with fallback metrics. + * + * Font loading is a non-critical enhancement, and an individual face failing + * (offline, a 404, or a system family with no `@font-face`) is *expected* — the + * correct response is to render with fallback metrics, not to fail the chart. So + * we wait on `allSettled` (which never rejects; a rejected load is ignored by + * design) and bound a slow first fetch with the timeout. This is the sanctioned + * "safe to swallow" case from arch 02's fail-loud rule — the failure mode is + * graceful fallback, not a buried bug or lost data — kept explicit rather than + * hidden in a catch-all. No-op where the Font Loading API is absent (tests). + */ +async function ensureFontsLoaded(spec: VisualizationSpec, config: Config): Promise { + if (typeof document === 'undefined' || !document.fonts?.load) return; + const families = new Set([...collectFontFamilies(config), ...collectFontFamilies(spec)]); + if (families.size === 0) return; + const loads = [...families].flatMap((family) => + FONT_LOAD_WEIGHTS.map((weight) => document.fonts.load(`${weight} 16px ${family}`)), + ); + await Promise.race([ + Promise.allSettled(loads), + new Promise((resolve) => setTimeout(resolve, FONT_LOAD_TIMEOUT_MS)), + ]); +} + /** Embed a prepared spec into `node`. Always: no actions menu. Renderer per `options`. */ export async function renderSpec( node: HTMLElement, @@ -154,6 +193,9 @@ export async function renderSpec( ): Promise { const renderer = options.renderer ?? 'svg'; + // Load the chart's fonts before any layout pass measures text (see the helper). + await ensureFontsLoaded(spec, config); + // Canvas can't allocate past the browser's max dimension, and an oversized canvas // fails *silently* (a blank/broken surface, sometimes a null 2d context). So for // canvas we first run a headless ('none') layout pass — no canvas allocated — read diff --git a/src/core/custom-theme.test.ts b/src/core/custom-theme.test.ts index 6696526..42e5e8c 100644 --- a/src/core/custom-theme.test.ts +++ b/src/core/custom-theme.test.ts @@ -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', () => { diff --git a/src/core/custom-theme.ts b/src/core/custom-theme.ts index e3c355b..8724c20 100644 --- a/src/core/custom-theme.ts +++ b/src/core/custom-theme.ts @@ -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 = [ + // 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 { + const out = new Set(); + 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; +} diff --git a/src/main.tsx b/src/main.tsx index 555c2ac..fed8107 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -13,6 +13,9 @@ import { initPersistentStorage, registerServiceWorker } from './app/orchestratio import { initApp } from './app/orchestration/startup'; import { initTheme, wireTheme } from './app/orchestration/theme'; import '../styles/base.css'; +// Self-hosted chart-font roster (Theme Builder font control). Separate from the +// UI font in base.css; only its latin subsets are precached (see chart-fonts.css). +import '../styles/chart-fonts.css'; // Hydrate the persisted theme onto before first paint (no // FOUC), then keep store ↔ DOM ↔ localStorage in sync. Store stays DOM-free; diff --git a/styles/chart-fonts.css b/styles/chart-fonts.css new file mode 100644 index 0000000..6308e37 --- /dev/null +++ b/styles/chart-fonts.css @@ -0,0 +1,51 @@ +/* + * Chart font roster — self-hosted via @fontsource (docs/chart-theming-scope.md + * §3, §4 slice 5). These are the families the Theme Builder's font control + * offers for custom chart themes (`THEME_FONT_OPTIONS`), distinct from the UI + * font (IBM Plex, wired in base.css). Each `@font-face` is registered here so a + * chart rendered in this document can use it; the render path additionally + * `document.fonts.load`s the used faces before embed (chart text is measured via + * canvas `measureText` regardless of renderer, so a font that loads after embed + * would lay out with fallback metrics — see chart-renderer's font gate). + * + * Weights: 400 (body/labels) + 600 (titles/emphasis); Space Mono ships 400 + 700 + * (it has no 600). The family-level weight import pulls every script subset + * (latin, cyrillic, greek, vietnamese, …) with `unicode-range`, so the browser + * only *downloads* the subset a glyph needs. Unlike the UI font, chart fonts are + * decoration with automatic per-glyph fallback, so only the latin subsets are + * precached for offline; other subsets are runtime-cached on first use (Workbox + * config in vite.config.ts). A non-latin label in a custom font that hasn't been + * fetched falls back to the system font — degraded styling, not a broken app. + */ + +/* Dataviz sans */ +@import '@fontsource/inter/400.css'; +@import '@fontsource/inter/600.css'; +@import '@fontsource/libre-franklin/400.css'; +@import '@fontsource/libre-franklin/600.css'; +@import '@fontsource/roboto-condensed/400.css'; +@import '@fontsource/roboto-condensed/600.css'; +@import '@fontsource/ibm-plex-sans-condensed/400.css'; +@import '@fontsource/ibm-plex-sans-condensed/600.css'; + +/* Serif */ +@import '@fontsource/ibm-plex-serif/400.css'; +@import '@fontsource/ibm-plex-serif/600.css'; +@import '@fontsource/source-serif-4/400.css'; +@import '@fontsource/source-serif-4/600.css'; +@import '@fontsource/spectral/400.css'; +@import '@fontsource/spectral/600.css'; + +/* Display */ +@import '@fontsource/space-grotesk/400.css'; +@import '@fontsource/space-grotesk/600.css'; +@import '@fontsource/playfair-display/400.css'; +@import '@fontsource/playfair-display/600.css'; + +/* Handwriting */ +@import '@fontsource/caveat/400.css'; +@import '@fontsource/caveat/600.css'; + +/* Mono */ +@import '@fontsource/space-mono/400.css'; +@import '@fontsource/space-mono/700.css'; diff --git a/vite.config.ts b/vite.config.ts index 3d685a2..d4dda62 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -56,13 +56,44 @@ export default defineConfig({ ], }, workbox: { - // Workbox's default globPatterns omit woff2; add it so the self-hosted - // Plex fonts are precached too (offline is a core requirement — without - // this the first offline load silently falls back to system fonts). - globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'], + // Precache the app, plus the font woff2 the offline-first UI and the + // common chart path need: + // - the bare `latin` subset of every family (`*-latin-[0-9]*` — the + // weight digit excludes `latin-ext`), the common chart case; + // - every subset of the UI fonts (IBM Plex Sans + Mono) — these are + // capability: the chrome must render any script (incl. latin-ext, + // Cyrillic, Greek) offline. The Sans brace-list names the subsets so + // it matches the UI Sans but not `ibm-plex-sans-condensed-*`, a roster + // chart font that stays latin-only like the rest. + // Every other subset (the roster's latin-ext and non-latin scripts) is + // decoration with per-glyph fallback: left out of the precache and + // runtime-cached on first use below, so the install stays small while the + // roster still works offline once a script has been seen. (Default + // globPatterns omit woff2 entirely — without this the first offline load + // silently falls back to system fonts.) + globPatterns: [ + '**/*.{js,css,html,ico,png,svg}', + '**/*-latin-[0-9]*.woff2', + '**/ibm-plex-sans-{latin,latin-ext,cyrillic,cyrillic-ext,greek,greek-ext,vietnamese}-*.woff2', + '**/ibm-plex-mono-*.woff2', + ], // Monaco and Vega vendor chunks exceed Workbox's 2 MiB default; raise the // ceiling so the whole app (offline is a core requirement) is precached. maximumFileSizeToCacheInBytes: 6 * 1024 * 1024, + runtimeCaching: [ + { + // Chart-roster non-latin font subsets: not precached, so fetch on + // demand and keep them offline-after-first-use. Precached woff2 + // (latin + Plex) are served from the precache and never reach here. + urlPattern: /\.woff2$/, + handler: 'CacheFirst', + options: { + cacheName: 'chart-font-subsets', + expiration: { maxEntries: 120, maxAgeSeconds: 60 * 60 * 24 * 365 }, + cacheableResponse: { statuses: [0, 200] }, + }, + }, + ], }, }), ],