Chart theming: user font upload (FontFace-from-IndexedDB) + variable-font weight support

This commit is contained in:
2026-06-16 21:41:04 +03:00
parent 713f396c5c
commit 5d3aba608a
20 changed files with 1191 additions and 40 deletions
+6 -2
View File
@@ -514,9 +514,13 @@ is in the maintained plan, not the archive.
**Chart theming** (`exploration/chart-theming-scope.md`):
- **User font upload** — FontFace-from-IndexedDB tier; theme `fonts: { family, source: 'file' }`.
- ~~**User font upload** — FontFace-from-IndexedDB tier~~ ✅ (incl. variable-font weight
support; arch 05 → User-uploaded fonts). Font dependencies derive from the config, not a
theme field.
- **Google Fonts opt-in CDN tier** — keyless catalog, opt-in only.
- **SVG export font embedding** — embed face data so exported SVGs render off-app.
- **SVG export font embedding** — embed face data so exported SVGs render off-app; carries
the **§08 export font-byte round-trip** too (shared base64 machinery — user fonts don't yet
travel with an exported workspace).
- **Theme↔font pairing metadata** — a suggestion nicety.
- **Built-in expressive theme preset gallery** — e.g. "Editorial", "Terminal", "Sketch".
@@ -149,6 +149,8 @@ async function rerender(node: HTMLElement, spec: TopLevelSpec, config: Config) {
swallow under §7's fail-loud rule). Chart fonts are self-hosted in
`styles/chart-fonts.css` (offered by the Theme Builder's font control); only
their latin subsets are precached, the rest runtime-cached (vite.config Workbox).
User-uploaded faces are registered on `document.fonts` too, so the same gate
resolves them (§3 → User-uploaded fonts).
- **Do** call `view.finalize()` on every previous view before rendering a new
one, and on component unmount.
- **Do** keep exactly one live view per preview node.
@@ -248,6 +250,41 @@ key-by-key), so a snippet can always override or opt out locally. The
that boundary deliberately: merge bakes the selected theme into `spec.config`
(spec keys win — rendering unchanged), extract lifts `spec.config` out.
### User-uploaded fonts
Beyond the self-hosted roster, a user can upload font files (`.woff2`/`.woff`/
`.ttf`/`.otf`) for chart themes. A `FontAsset` (`core/font-asset.ts`) holds the
raw bytes and persists through the standard entity-store stack — a `fonts`
IndexedDB store, `infrastructure/font-store.ts` (+ `font-migrations.ts`),
`stores/FontStore.ts`, `orchestration/font-persistence.ts` — the same
one-tier-each shape as datasets and custom themes. The browser seam that turns
stored bytes into a live face, `infrastructure/font-faces.ts`, registers on
`document.fonts` at startup (before the first render, so the §2 font gate
resolves user faces like the roster) and on each upload; uploads/deletes are
orchestrated by `services/fonts.ts`. The Type panel lists user fonts ahead of the
roster and applies one via the same `applyFontToConfig` transform.
**Font dependencies are derived from the config, never stored on the theme.** A
`CustomTheme` carries no font field: the family a theme (or a snippet) uses is
already in its config's `font`/`*Font` slots, that JSON config is the source of
truth, and snippets use uploaded fonts with no theme to carry such a field. So a
used face is discovered by scanning configs/specs for the families they reference
(`collectFontFamilies`, core) and matching against the font library — a stored
field would only drift from the config it duplicates.
(Embedding the matched faces' bytes into the §08 workspace export and per-chart
SVG export is the remaining transfer work, on shared base64 machinery.)
**Variable fonts: weight is the only leverageable axis.** `parseFontAxes` reads
the OpenType `fvar` table (uncompressed `ttf`/`otf` only — `woff`/`woff2` wrap
their tables in compression we don't unpack, so those register as static), and a
variable face is registered with its `wght``weight` and `wdth``stretch` ranges
so one file serves the whole weight range, driven by the Type panel's weight
controls. Only those two axes take effect because Vega's text rendering emits a
CSS font shorthand (family/size/weight/style) with **no `font-variation-settings`
hook** — optical size, grade, and custom axes pin at the registered default and
can't be exposed. Declaring the `wdth` range also defaults the face to normal
width rather than a variable font's possibly-condensed default instance.
### Structured controls
The builder's panels — Color, Type, Layout, Axes & grid, Legend
+22 -4
View File
@@ -162,10 +162,20 @@ ships, it is an explicit per-font user action, never automatic.
subset of the UI Plex Sans/Mono, and runtime-caches the rest (latin-ext + non-latin)
CacheFirst so a script works offline after first use. Roster picked from a visual
specimen. Not done here: theme↔font pairing metadata (a suggestion nicety, deferred).
6. **User font upload** — FontFace-from-IndexedDB tier; theme entity's `fonts` field
carries `{ family, source: 'file' }`.
7. **Deferred** — Google Fonts opt-in tier; SVG export font embedding; built-in
expressive preset gallery ("Editorial", "Terminal", "Sketch") showcasing the roster.
6. **User font upload** ✅ (2026-06-16) — `FontAsset` (`core/font-asset.ts`) + the `fonts`
store @ DB v3, registered as a `FontFace` at startup so the render gate resolves user
faces like the roster (full entity-store stack: adapter/migration, `FontStore`,
`font-persistence`, `services/fonts`; arch 05 → User-uploaded fonts). The Type panel
offers uploads ahead of the roster. **Variable fonts** are supported: `parseFontAxes`
reads `fvar` (uncompressed ttf/otf) and the face registers with `wght`/`wdth` ranges, so
one file drives the whole weight range — only weight/width survive Vega's text rendering
(no `font-variation-settings` hook). No `CustomTheme.fonts` field: a used face is derived
by scanning configs/specs (`collectFontFamilies`) — the config is the
source of truth, and snippets use fonts with no theme to carry a field. Not done here:
embedding font bytes into the §08 export + SVG export (shared base64 machinery, with item 7).
7. **Deferred** — Google Fonts opt-in tier; SVG export font embedding (+ §08 font-byte
round-trip, shared machinery); built-in expressive preset gallery ("Editorial",
"Terminal", "Sketch") showcasing the roster.
**Rejected:** per-snippet theme field (2026-06-12 — `spec.config` + merge/extract covers
it without a second mechanism).
@@ -224,6 +234,14 @@ the app standardizes on v6.
## 6. Status log
- **2026-06-16 (slice 6)** — **user font upload + variable-font weight support.** New
`FontAsset` entity (instance #4 of the entity-store kind, confirmed by an eng-council
pre-build consult): `fonts` store @ DB v3, adapter/migration, `FontStore`,
`font-persistence`, `services/fonts`, and the `font-faces` `FontFace`-registration seam
wired into `startup`. Type panel gains an upload control + a managed list; user fonts lead
the dropdown. Variable fonts parse `fvar` and register with `wght`/`wdth` ranges (weight
is the only axis Vega's text rendering can drive). Font dependencies are derived from the
config at export, not stored on the theme. Deferred: font bytes in the §08/SVG exports.
- **2026-06-14 (Color panel bugfix)** — **scheme picks rendered blank.** A named scheme
was written into `config.range.*` as a bare string, which vega-lite compiles but Vega
rejects at render ("Unrecognized scale range value") — silently caught by the gallery's