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

19 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) — 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. 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:<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.