# Chart Theming — Enhancement Scope > **Status:** scope consolidated 2026-06-12. Single forward-looking home for chart-theme > work: separating the opinionated house style from the legibility minimum, a preview > theme selector, config merge/extract, custom named themes, and fonts (shipped roster + > user-loaded). Read against `src/core/vega-themes.ts`, > `src/app/services/chart-renderer.ts`, and `docs/architecture/05` §3. > > **Goal (the brief):** "here's how you can easily transform your Vega-Lite charts to not > look like stock Vega-Lite charts" — make the house style one option among several, > let a user apply custom branding (colors **and fonts**) quickly, and keep every byte > self-hosted and offline-capable. --- ## 1. Where we stand (the audit) Everything opinionated lives in **one file** — `src/core/vega-themes.ts` — injected as vega-embed's `config` option at embed time (`chart-renderer.ts`). It is never baked into the stored spec; pasting a snippet into the Vega editor renders stock. No CSS reaches into the chart DOM. Exports (PNG/SVG) render through the same view, so they **carry the theme**. Merge precedence (verified in `vega-lite/src/compile/compile.ts`): `mergeConfig(opt.config, spec.config)` — **the spec's own `config` wins** over our injected theme, property by property. A snippet can already opt out of any of it. Exact diff vs. stock Vega-Lite (defaults read from `vega-parser/src/config.js`): | Property | Astrolabe (light / dark) | Stock Vega-Lite | | -------------------------- | --------------------------- | -------------------- | | `background` | transparent | `white` | | `font` | IBM Plex Sans stack | `sans-serif` | | `title` | 16px / 600 / app text color | 13px / bold / black | | `axis.domainColor` | `#c6c6c6` / `#525252` | `#888` | | `axis.gridColor` | `#e0e0e0` / `#393939` | `#ddd` | | `axis.gridDash` | `[2,2]` | solid | | `axis.labelColor` | `#525252` / `#a8a8a8` | black | | `axis.titleColor` | `#161616` / `#f4f4f4` | black | | `axis.label/titleFontSize` | 11 / 12 | 10 / 11 | | `axis.titleFontWeight` | 600 | bold (700) | | `range.category` | Carbon data-viz 14-color | tableau10 (10-color) | | `view.stroke` | transparent | `#ddd` plot border | Untouched: everything else — notably the **default mark color stays Vega blue `#4c78a8`**; the Carbon palette only kicks in once a color encoding exists. The config splits into two layers with different standing: - **Base (legibility/integration)** — required for charts to be readable on our panes at all, dark mode especially: `background: transparent` + the guide _colors_ (stock black text on a dark pane is illegible). Structurally the same job `[data-theme]` does for the rest of the app. - **Expressive (house style)** — genuinely opinionated: Plex, the Carbon categorical palette, dotted grid, bumped guide sizes/weights, 16px title, no plot border. Strip it and charts still work in both UI themes; they just look like Vega-Lite. ## 2. What vega-editor does (and what we take) Read from the local clone (`reference/vega-editor`, `components/config-editor/`): - **Theme dropdown = the `vega-themes` npm package** (~14 preset configs: excel, ggplot2, fivethirtyeight, latimes, powerbi, googlecharts, urbaninstitute, dark, four Carbon themes) + a `custom` sentinel. Already in our tree — vega-embed depends on it. - **Picking a theme is a one-shot copy** of the preset JSON into a config editor pane; any hand-edit flips back to `custom`. No live binding. - **The config pane** feeds `opt.config` at compile — the slot we already use. Two Monaco context-menu commands bridge pane ↔ spec: **Merge Config Into Spec** (pane → `spec.config`, spec's existing keys win, pane empties) and **Extract Config From Spec** (the inverse). - **No custom-theme saving.** One global localStorage state blob; `custom` is "whatever is in the pane". Nothing to borrow for named themes — that part is our own design. The structural mismatch: vega-editor is a scratchpad for one transient document; Astrolabe is a library. **Decided 2026-06-12:** theme choice is **not per-snippet** — `spec.config` _is_ the per-snippet mechanism, and merge/extract makes it ergonomic. The app-level selector is a global preference. ## 3. Fonts (researched 2026-06-12) **The hard constraint:** `vega-scenegraph/src/util/text.js` measures every label via canvas `measureText` **regardless of renderer**. A font that finishes loading after embed leaves the whole layout measured with fallback metrics. Any custom-font path must `await document.fonts.load(' 11px "Family"')` per used face **before** `renderSpec`. Once loaded, SVG view, canvas view, and PNG export all work for free. **Known limitation:** SVG _export_ carries only the family name — a viewer without the font sees fallback (industry standard; data-URI `@font-face` embedding is a heavy maybe-later). **Shipped roster (self-hosted, no CDN — same `@fontsource` mechanism as Plex).** Measured latin woff2 sizes (jsdelivr, 2026-06-12): regular text faces run **13–25KB per weight**; handwriting (Caveat) ~50KB. A ~9-family roster at ~2 weights ≈ **400–450KB latin**. All-subsets multiplier ≈ 3–4× (Inter: 87KB all-subsets vs 23KB latin, one weight). Current dist is 7.8MB with 412KB of Plex — the roster roughly doubles font payload; acceptable. Candidate roster (final pick deserves a visual specimen pass, not a chat decision): | Role | Faces (weights) | | --------------------- | ----------------------------------------------------------------------------------------------------- | | Already shipped, free | IBM Plex Sans, IBM Plex Mono | | Dataviz sans | Inter (400/600), Roboto Condensed (400/600), Libre Franklin (400/600) | | Brand-coherent | IBM Plex Serif (400/600), IBM Plex Sans Condensed (400/600) | | Editorial serif | Source Serif 4 _or_ Spectral (400/600) | | Exotic / display | Space Grotesk (400/600), Playfair Display (400/700), Caveat (400/600, "sketch"), Space Mono (400/700) | **Subset/precache strategy:** chart fonts are decoration with automatic per-glyph fallback (`unicode-range`), not app capability — non-latin data labels falling back to the system font is degraded styling, not a broken app (contrast the Plex Cyrillic lesson, which was UI capability). Plan: **ship all subsets in dist** (~1.2–1.5MB dist growth), **precache latin only** (~+400KB), runtime-cache the remaining subsets same-origin (CacheFirst) so a used subset persists offline after first render. **User-loaded fonts (the branding case — primary).** Real brand fonts are licensed and usually _not_ on Google Fonts. Path: upload woff2/ttf → bytes in IndexedDB (the datasets persistence pattern) → `new FontFace(family, bytes)` + `document.fonts.add()` at startup and before render. Fully local, offline-native, no privacy question. **Google Fonts CDN tier — deferred, opt-in only.** Verified: keyless catalog at `fonts.google.com/metadata/fonts` (1,936 families; a names-only list is ~30KB raw, so the _picker_ can ship static and offline), CSS2 endpoint live, Workbox CacheFirst on `fonts.gstatic.com` makes a chosen font offline after first use. Tension: `base.css` says fonts are "never a CDN", and font requests expose the user's IP to Google. If this ships, it is an explicit per-font user action, never automatic. ## 4. Build order 1. **Layer split** ✅ (refactor, no visible change) — `vega-themes.ts` is base + expressive per UI theme, merged into the existing exports via `mergeChartLayers`. 2. **Preview theme selector** ✅ — global pref `ui.chartTheme`: **Astrolabe** (follows UI theme, default) · **Stock Vega-Lite** (empty config) · all 14 vega-themes presets · (later) custom themes. Governs LivePreview **and export** (same view). Resolved design points: the control is a `SelectControl` in the **preview header** (a select nested inside PreviewSettings would close its own parent — SelectControl and SettingsPopover share the one-open-popover registry); Onboarding/Chart-Builder previews stay house-styled; preset/stock backgrounds render verbatim (a white chart card on the dark pane is an honest destination preview). 3. **Merge/extract config** ✅ — `core/spec-config.ts` (`mergeConfigIntoSpec`, `extractConfigFromSpec`), surfaced as the editor toolbar's **Config** menu (SelectControl action picker — council: NN/g #6, Carbon overflow; arch 10 §5 records the rule) with Monaco context-menu/palette as accelerators on the same functions (spec §03G): bake the active theme into `spec.config` (existing keys win, render-identical), or lift `spec.config` out to the clipboard (copy before remove — a failed copy aborts). 4. **Custom named themes** ✅ (2026-06-12) — IndexedDB entity `{ id, name, config }` (`core/custom-theme.ts`, themes store @ DB v2) + the **Theme Builder** modal: theme list, JSON config editor, a font control that populates one family across every font slot (`applyFontToConfig`), and a live multi-chart gallery (`core/theme-preview-specs.ts`) so one edit is previewed across titles, axes, legends, headers, and the major marks. Created by duplicating the currently-selected theme (house/preset/custom) or via the editor's **Extract Config to New Theme** action (spec §03G); appears in the selector as `custom:` (the "Edit themes…" action row sits right after the customs, before the preset roster); deleting the active one falls back to Astrolabe. Custom themes travel in the §08 workspace export/import envelope (additive `themes` array, name auto-suffix on clash, ids reassigned by the store, rolled back with datasets on a failed import). 5. **Shipped font roster** — fontsource packages, `@font-face` registration, selector metadata (which themes/fonts pair), `document.fonts.load` gate in the render path, precache strategy above. Roster finalized via visual specimen. 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. **Rejected:** per-snippet theme field (2026-06-12 — `spec.config` + merge/extract covers it without a second mechanism). ## 5. Structured controls (slice 4b) The builder today is a raw JSON textarea + one font dropdown + the live gallery. Slice 4b adds a strip of structured controls above the editor — accelerators that write into the JSON, never replacing it. The JSON stays the source of truth and the full-power escape hatch; controls cover the common ~80% (color, type, spacing, grid), not all 72 config properties (that is the trap vega-editor deliberately avoids by staying JSON). **Hard constraint — the builder must preserve unknown keys.** Verified by compiling: the vega-themes presets carry Vega-_layer_ keys (`symbol`, `shape`, `path`, `group`) that are not in the Vega-Lite `Config` schema, and Vega-Lite forwards the whole config to Vega unchanged — they take effect. So a structured control must **merge into** the existing config (immutable path-set that spreads siblings), never rebuild it from a closed schema-typed model, or it silently drops those keys on a round-trip. Same shape as `applyFontToConfig`, which walks and rewrites rather than reconstructing. **Resolved design points:** - **Surfacing — inline tab strip** (not popovers, not sub-modals). Tabs (Color / Type / Layout / Axes & grid / Legend) sit between the toolbar and the JSON+gallery, all in the one xlarge modal. No nested overlays/focus traps, no contention with the one-open-popover registry, and panels + JSON + gallery stay visible together. - **Color model — scheme picker that materializes to swatches.** A `range` family takes either an explicit color array or a named Vega scheme string (both verified to compile). Pick a named scheme for the quick path; "materialize" expands it to an editable swatch array for brand tuning. Catalog ships 15 categorical + 24 sequential + 10 diverging schemes; categorical schemes resolve to arrays, continuous ones to interpolators sampled into stops for the gradient preview and the materialize action. - Structured controls are gated on valid JSON (same as the font control): a parse error disables them and the textarea is the fix. - Controls write **minimal** config — clearing a value deletes the key rather than writing a default, so a theme stays a diff against stock, not a full dump. **Panels:** Color (`range.category` swatches/scheme, `mark.color`, `range.heatmap`/`ramp`/ `diverging`) · Type (base `font`, title/axis/legend size+weight) · Layout (`background` incl. transparent, `padding`, `view.stroke`/`fill`/cornerRadius) · Axes & grid (grid on/off + color - dash, domain, label color/angle — base `axis` only; the 25 variants stay JSON) · Legend (orient, label/title color+size, symbol size). **Build order:** (a) core foundation — scheme catalog + immutable config path get/set + `schemeColors` materialize, with tests; (b) Color panel (highest payoff); (c) Type, Layout, Axes, Legend panels; (d) wire the tab strip into the modal. **Not in slice 4b:** the house style's own gaps — no `mark.color` (single-series charts stay Vega-blue), unset legend/header/padding — are left for a separate house-style redo, not papered over here. Minor cleanup noted: `theme-preview-specs.ts` declares `$schema` v5 while the app standardizes on v6. ## 6. Status log - **2026-06-14 (slice 4b, first increment)** — **structured-control foundation + Color panel.** Core `theme-controls.ts`: immutable config path get/set (preserves siblings — the Vega-layer-key guarantee — and prunes on delete) + the named-scheme catalog (15 categorical / 24 sequential / 10 diverging) + `schemeColors` resolution (categorical arrays passthrough, continuous interpolators sampled to hex), all tested. `vega-scale` added as a declared dep (focused sub-package, like `vega-expression`) with a typings shim in `vite-env.d.ts` (its package.json `exports` omits `types`). Store gains the generic `mutateDraftConfig(fn)` write path; `applyDraftFont` refactored onto it. Modal gains an APG tab strip — **Color** (categorical scheme/swatches + materialize, default `mark.color`, sequential/diverging gradient pickers) and **Type** (the relocated font control). Tabpanel gated on valid JSON. From first-use feedback, same day: the modal body is now **controls + JSON on the left, gallery as a full-height right rail** (the previews were starved before); `SelectControl` gained an optional per-option `preview` so the scheme dropdowns show swatch strips (categorical) / gradient bars (continuous); every swatch is a reusable `SwatchRow` (color picker + copyable/editable hex field); and sequential/diverging gained **Materialize → editable stops**, so custom gradient colors are possible, not just named schemes. Second feedback pass: the raw JSON is now a **collapsed disclosure** at the bottom of the controls column (it was eating half the first screen), forced open only on a parse error; the structured controls fill the column. `SelectControl` options gained a `labelStyle`, so the **font dropdown renders each name in its own family** (the type analogue of the color swatches) and its trigger shows the current font in-face. Verified: typecheck, lint, full tests (950). Remaining: Layout / Axes & grid / Legend panels; swatch reorder. - **2026-06-12 (slice 4 close-out)** — **custom themes in the §08 envelope.** The workspace export now writes a `themes` array (additive — no format bump; importers treat it as optional, so pre-theme envelopes stay valid). Import normalizes each record (`normalizeCustomTheme`), auto-suffixes name clashes via the generalized `dedupeIncomingNames` (the dataset dedupe, now shared), reassigns ids through `CustomThemeStore.addThemes` (selection untouched), and rolls themes back together with datasets when the atomic snippet write fails. Toast counts gain a theme clause. Spec §08 updated ("Dataset conflicts" → "Name conflicts"). Slice 4 is now fully done; next is slice 5 (shipped font roster). - **2026-06-12 (slice 4)** — **custom named themes + Theme Builder shipped.** `CustomTheme` entity through the full stack (core → theme-store @ DB v2 → CustomThemeStore → theme-persistence → startup hydrate); selection model extended to `custom:` with missing-record fallback to the house style; Theme Builder modal (xlarge, list + name + config JSON + font-apply control + 7-card live gallery, canvas renderer, per-card chain-lock); picker gains custom entries + an "Edit themes…" action row. The font control ships with render-safe faces only (Plex + web-safe stacks) — the roster slice (5) extends `THEME_FONT_OPTIONS` and adds the `document.fonts.load` gate. Spec updated (§01C, §04 Chart theme + Theme Builder, §09C/E/G) + architecture 05 §3. Same-day follow-ups from first use: `openDB` now verifies the store layout and self-heals an interrupted upgrade (arch 02 §2.1, `db.test.ts` on fake-indexeddb); "Edit themes…" moved before the preset roster (discoverability); **Extract Config to New Theme** added as the third Config-menu action (spec §03G) — config block → saved theme, selected, removed from the spec. Not yet done: themes in the §08 export/import envelope. - **2026-06-12 (slices 2–3)** — **theme selector + merge/extract shipped.** `ChartThemeId`/`chartConfigForSelection` in core; `ui.chartTheme` persisted via the `previewFitMode` orchestration pattern; `SelectControl` picker in the preview header; LivePreview renders (and therefore exports) with the selection; `core/spec-config.ts` merge/extract behind two Monaco editor actions. Spec updated (§03G, §04 Chart theme, §07, §09C) + architecture 05 §3 rewritten to the layered/selectable model. Verified: typecheck, eslint, full tests (802), build. Custom named themes (slice 4) and fonts (5–6) remain. - **2026-06-12** — scope written; audit, vega-editor read, font research done (numbers above). Slice 1 (layer split) implemented.