11 KiB
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, anddocs/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-themesnpm package (~14 preset configs: excel, ggplot2, fivethirtyeight, latimes, powerbi, googlecharts, urbaninstitute, dark, four Carbon themes) + acustomsentinel. 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.configat 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;
customis "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('<weight> 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
- Layer split ✅ (refactor, no visible change) —
vega-themes.tsis base + expressive per UI theme, merged into the existing exports viamergeChartLayers. - 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 aSelectControlin 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). - 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 intospec.config(existing keys win, render-identical), or liftspec.configout to the clipboard (copy before remove — a failed copy aborts). - Custom named themes — new IndexedDB entity
{ name, config, fonts? }+ list UI; created by duplicating a preset or extract-from-spec; appears in the slice-2 selector. Export/import as JSON alongside the library. - Shipped font roster — fontsource packages,
@font-faceregistration, selector metadata (which themes/fonts pair),document.fonts.loadgate in the render path, precache strategy above. Roster finalized via visual specimen. - User font upload — FontFace-from-IndexedDB tier; theme entity's
fontsfield carries{ family, source: 'file' }. - 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. Status log
- 2026-06-12 (slices 2–3) — theme selector + merge/extract shipped.
ChartThemeId/chartConfigForSelectionin core;ui.chartThemepersisted via thepreviewFitModeorchestration pattern;SelectControlpicker in the preview header; LivePreview renders (and therefore exports) with the selection;core/spec-config.tsmerge/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.