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

195 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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('<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.