Files
astrolabe/docs/chart-theming-scope.md
T

13 KiB
Raw Blame History

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 filesrc/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-snippetspec.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 1325KB per weight; handwriting (Caveat) ~50KB. A ~9-family roster at ~2 weights ≈ 400450KB latin. All-subsets multiplier ≈ 34× (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.21.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, except export/import) — 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:<id> (the "Edit themes…" action row sits right after the customs, before the preset roster); deleting the active one falls back to Astrolabe. Remaining: export/import as JSON alongside the library (touches the §08 envelope + import-normalize atomicity).
  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. Status log

  • 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:<id> 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 23)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 (56) remain.

  • 2026-06-12 — scope written; audit, vega-editor read, font research done (numbers above). Slice 1 (layer split) implemented.