mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
206 lines
14 KiB
Markdown
206 lines
14 KiB
Markdown
# 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 **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
|
||
|
||
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. Status log
|
||
|
||
- **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 2–3)** — **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
|
||
(5–6) remain.
|
||
- **2026-06-12** — scope written; audit, vega-editor read, font research done (numbers
|
||
above). Slice 1 (layer split) implemented.
|