28 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 ✅ (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 ascustom:<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 (additivethemesarray, name auto-suffix on clash, ids reassigned by the store, rolled back with datasets on a failed import). - Shipped font roster ✅ (2026-06-14) — 11 self-hosted families via @fontsource
(
styles/chart-fonts.css, full subsets bundled) extendingTHEME_FONT_OPTIONSto 17 entries;collectFontFamilies(core) + adocument.fonts.loadgate at the top ofrenderSpec(before the layout/probe pass, which measures text regardless of renderer); Workbox precaches thelatinsubset of the roster (~520KB) plus every 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). - User font upload ✅ (2026-06-16) —
FontAsset(core/font-asset.ts) + thefontsstore @ DB v3, registered as aFontFaceat 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:parseFontAxesreadsfvar(uncompressed ttf/otf) and the face registers withwght/wdthranges, so one file drives the whole weight range — only weight/width survive Vega's text rendering (nofont-variation-settingshook). NoCustomTheme.fontsfield: 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. - Font export round-trip ✅ (2026-06-16) — uploaded faces travel base64-encoded in the
§08 envelope (additive
fontsarray, decoded on import, skipped on family clash so a self-backup doesn't pile up copies, rolled back with the rest on a failed import, registered live so they render without reload), and the per-chart SVG export embeds the referenced uploaded faces as@font-facedata-URIs so an exported vector renders the right type off-app. Shared base64 +fontDataUri/primaryFamilyName/serializeFontAssetmachinery incore/font-asset.ts; SVG embed + family-matching incore/chart-export.ts. Roster and system stacks are never embedded (decoration with their own fallbacks; their bytes aren't in the font library). - Deferred — Google Fonts opt-in tier; 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 — vertical tab list + per-panel accordion (not popovers, not sub-modals),
all in the one xlarge modal. One panel per config domain, switched by a vertical tab rail
(L1); within a panel, related properties group into a single-expand accordion (L2). No
nested overlays/focus traps, no contention with the one-open-popover registry; panel + JSON
- gallery stay visible together. The two-level grouping rule is recorded in arch 10 (Organizing a large control surface).
- Color model — scheme picker that materializes to swatches. A
rangefamily takes either an explicit color array or a named scheme written as Vega's range-scheme object{ scheme: name }. (A bare scheme-name string passes vega-lite compile but Vega rejects it at render — "Unrecognized scale range value" — so the controls write the object form, read either, andnormalizeRangeSchemesheals the bare form at render.) 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 (each an accordion of the groups below): Color (range.category, mark.color,
sequential range.heatmap/ramp, range.diverging) · Marks (per type — bars, lines &
areas, points, arc — plus generic opacity/fill/tooltips) · Type (base font, axis
title/label size+weight) · Title (anchor, colour, size/weight/style, offset, subtitle block)
· Layout (background, view fill/border/radius, padding, default size) · Axes & grid
(grid, ticks, domain, labels — base axis only; the per-channel variants stay JSON) · Legend
(placement/direction, title, labels, symbols, gradient, box) · Headers (facet title/label
colour/size/weight) · Formats (number/date/normalized formats, count title).
Build order: (a) core foundation — scheme catalog + immutable config path get/set +
schemeColors materialize, with tests; (b) Color panel (highest payoff); (c) the remaining
per-domain panels; (d) wire the navigation (vertical tabs + per-panel accordion) into the
modal.
6. Status log
-
2026-06-21 — structured-control surface expanded + accordion everywhere. New panels — Marks (per mark type), Title & subtitle, Headers (facets), Formats — and deepened Axes/Legend/Layout, covering the brand-tuning bulk of the config; the raw JSON stays the escape hatch for the long tail. Every panel is a single-expand accordion with a per-section set-count badge, switched by a vertical tab list, and the modal holds one
id → label → Panelregistry. Shared primitives extracted:WeightRow(UI),enumValue/countSet(core). The gallery is now fully reflective — sample specs use bare marks so no control is shadowed (guarded by a test), and a normalized area + a temporal facet were added sonormalizedNumberFormatandtimeFormathave mirrors; default chart size and tooltips are the only non-previewable controls. Recorded in arch 10 (Organizing a large control surface), arch 05 (Structured controls), and spec §04. Remaining in §4: Google Fonts opt-in tier; built-in preset gallery; Color-panel swatch reorder. -
2026-06-16 (slice 7) — font export round-trip + SVG embed. Uploaded faces now survive a workspace transfer and travel inside an exported SVG. Core:
serializeFontAsset/deserializeFontAsset(+ base64 helpers),primaryFamilyName, andfontDataUriinfont-asset.ts; the §08 envelope grew an additivefontsarray (export-envelope.ts),normalizeImportdecodes it, and a newdropClashingFontsenforces the skip-on-clash merge rule (import-normalize.ts). Service:transfer.tsexports all library fonts, and import dedupes by family, commits fonts in the pre-snippet phase (rolled back with datasets/themes on a failed snippet write), and registers the new faces on success. SVG:referencedUploadedFontsembedFontsInSvginchart-export.ts, wired through the renderer'stoImageURL('svg')with the referenced faces resolved in LivePreview'sgetImageUrl(config captured per render). Decision (font clash): skip, not rename — a font is identified by its family (the key in config slots), so an existing same-named face satisfies the reference, and a self-backup doesn't accrete "Font 2" copies; recorded in spec §08 → Name conflicts. Verified: typecheck, lint, full tests. Remaining in §4: Google Fonts opt-in tier; preset gallery.
-
2026-06-16 (slice 6) — user font upload + variable-font weight support. New
FontAssetentity (instance #4 of the entity-store kind, confirmed by an eng-council pre-build consult):fontsstore @ DB v3, adapter/migration,FontStore,font-persistence,services/fonts, and thefont-facesFontFace-registration seam wired intostartup. Type panel gains an upload control + a managed list; user fonts lead the dropdown. Variable fonts parsefvarand register withwght/wdthranges (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 per-card try/catch, so the categorical/sequential/diverging charts blanked the moment a scheme was picked. Predates this session's panels/fonts (shipped with the Color panel). Fix: the controls write Vega's range-scheme object{ scheme: name }and read either form;normalizeRangeSchemes(core) heals a bare-form config at the render-resolution points (chartConfigForSelectionfor the live preview/export, and the builder gallery), so themes saved/imported with the old form self-heal. The gallery's catch now surfaces the error message in the card (fail-loud, arch 02) so a render failure on valid JSON isn't invisible again. Regression cover: a real vega-lite→vega compile/parse/run asserting{ scheme }renders and the bare string throws, plusnormalizeRangeSchemesunit tests. Verified: typecheck, lint, tests (983). -
2026-06-14 (slice 5) — shipped font roster. 11 self-hosted families (
styles/chart-fonts.css, imported in main.tsx, separate from the UI Plex in base.css): Inter · Libre Franklin · Roboto Condensed · IBM Plex Sans Condensed · IBM Plex Serif · Source Serif 4 · Spectral · Space Grotesk · Playfair Display · Caveat · Space Mono, at 400 + 600 (Space Mono 400 + 700).THEME_FONT_OPTIONSgrew to 17 (roster grouped by role, then the system stacks); each roster stack carries a category fallback. The render path now gates on fonts:collectFontFamilies(core, the read-counterpart ofapplyFontToConfig; skipsdata/datasets) gathers the families a spec+config use andrenderSpecawaitsdocument.fonts.loadfor them before the first layout pass — Vega measures text via canvasmeasureTextregardless of renderer, so a face loading after embed would lay out with fallback metrics. Best-effort + 3s-capped so a slow first fetch never freezes the preview. Precache strategy (vite.config Workbox): thelatinsubset of every family (~520KB for the roster) + all Plex Sans/Mono subsets (UI capability) are precached; latin-ext and non-latin scripts are runtime-cached CacheFirst (*-latin-[0-9]*excludes latin-ext; the Plex Sans brace-list avoids matching the condensed roster font). Verified: typecheck, lint, full tests (976), production build + precache-manifest inspection. Note: @fontsource ships legacy.woffbeside.woff2; modern browsers use woff2, so the.woffsit unused in dist (pre-existing for Plex — neither precached nor runtime-cached). -
2026-06-14 (slice 4b complete) — Layout / Axes & grid / Legend panels + Type size/weight. The remaining structured-control panels, built on a small shared primitives module
ThemeFields.tsx(ControlSection,ColorRow,NumberRow,SelectRow) so the panels read declaratively and match the Color panel's look. Each control writes one config path through the same inlinemutateDraftConfig+setConfigValuethe Color panel uses, with the minimal-diff delete (clearing a value removes the key, pruning emptied objects). Leaf coercion (asString/asNumber/asBoolean) moved into coretheme-controls.tsbeside the path get/set, tested there. Panels: Layout (background andviewfill/border as tri-state default·transparent/ none·custom, corner radius, scalar padding with a JSON hint when it's a per-side object); Axes & grid (grid visibility/color/dash-preset, domain/label/title color, label angle — baseaxisonly); Legend (orient, title/label color+size, symbol size); Type rounded out with title and axis title/label size+weight (font family relocated into the extractedTypeControls). Resolved while building: each generic row label ("Size", "Color", "Weight") repeats across sections, soControlSectionis arole="group"labelled by its heading and rows take an accessible-name override — the visible label stays short, the control's announced name is qualified ("Title size"). The font-roster decision (slice 5) was teed up with a throwaway visual specimen. Verified: typecheck, lint, full tests (969). Remaining in 4b: swatch reorder (Color panel). -
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) +schemeColorsresolution (categorical arrays passthrough, continuous interpolators sampled to hex), all tested.vega-scaleadded as a declared dep (focused sub-package, likevega-expression) with a typings shim invite-env.d.ts(its package.jsonexportsomitstypes). Store gains the genericmutateDraftConfig(fn)write path;applyDraftFontrefactored onto it. Modal gains an APG tab strip — Color (categorical scheme/swatches + materialize, defaultmark.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);SelectControlgained an optional per-optionpreviewso the scheme dropdowns show swatch strips (categorical) / gradient bars (continuous); every swatch is a reusableSwatchRow(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.SelectControloptions gained alabelStyle, 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
themesarray (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 generalizeddedupeIncomingNames(the dataset dedupe, now shared), reassigns ids throughCustomThemeStore.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.
CustomThemeentity through the full stack (core → theme-store @ DB v2 → CustomThemeStore → theme-persistence → startup hydrate); selection model extended tocustom:<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) extendsTHEME_FONT_OPTIONSand adds thedocument.fonts.loadgate. Spec updated (§01C, §04 Chart theme + Theme Builder, §09C/E/G) + architecture 05 §3. Same-day follow-ups from first use:openDBnow verifies the store layout and self-heals an interrupted upgrade (arch 02 §2.1,db.test.tson 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/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.