Chart theming: selectable chart theme + spec↔config merge/extract

This commit is contained in:
2026-06-12 16:48:48 +03:00
parent fe9d588103
commit 44a601affd
27 changed files with 1103 additions and 121 deletions
@@ -150,83 +150,66 @@ async function rerender(node: HTMLElement, spec: TopLevelSpec, config: Config) {
## 3. Theme Follows the UI Theme
A Vega-Lite **config** object styles every chart globally — fonts, axis colors,
background, the categorical color range, default mark colors. Astrolabe ships one
config per UI theme so charts visually belong to the app rather than looking like
stock Vega-Lite.
background, the categorical color range. Astrolabe ships one config per UI theme
so charts visually belong to the app rather than looking like stock Vega-Lite.
`src/core/vega-themes.ts` is the single source of truth; each house config is
**two merged layers** (the full audit and forward plan live in
[`docs/chart-theming-scope.md`](../chart-theming-scope.md)):
```ts
// src/core/vega-themes.ts (sketch)
import type { Config } from 'vega-lite';
- **Base** (`lightBaseConfig`/`darkBaseConfig`) — the legibility minimum:
`background: 'transparent'` (the pane shows through) plus guide colors on the
app's text/border tokens. Without it, stock black-on-white chart text is
illegible on the dark pane.
- **Expressive** (`lightExpressiveConfig`/`darkExpressiveConfig`) — the house
style: IBM Plex, the Carbon data-viz 14-color categorical palette, dotted
grid, bumped guide sizes/weights, no plot border.
export const lightChartConfig: Config = {
background: 'transparent',
font: '"Inter", sans-serif',
title: { fontSize: 15, fontWeight: 600, color: '#1c1c1e' },
axis: {
domainColor: '#1c1c1e',
gridColor: '#e4e4e7',
gridDash: [3, 3],
labelColor: '#52525b',
titleColor: '#1c1c1e',
labelFontSize: 11,
titleFontSize: 12,
},
range: {
category: ['#2f6df6', '#f5a524', '#17b890', '#e5484d', '#8b5cf6', '#0ea5e9'],
},
view: { stroke: 'transparent' },
};
`mergeChartLayers(base, expressive)` produces `lightChartConfig`/
`darkChartConfig`, and `chartConfigFor(uiTheme)` is the one UI-theme → config
mapping. The split exists so a non-house style can keep the base layer while
swapping the expressive one (future custom themes).
export const darkChartConfig: Config = {
background: 'transparent',
font: '"Inter", sans-serif',
title: { fontSize: 15, fontWeight: 600, color: '#f4f4f5' },
axis: {
domainColor: '#a1a1aa',
gridColor: '#3f3f46',
gridDash: [3, 3],
labelColor: '#a1a1aa',
titleColor: '#f4f4f5',
labelFontSize: 11,
titleFontSize: 12,
},
range: {
category: ['#5b8def', '#f5a524', '#2dd4a7', '#f0666b', '#a78bfa', '#38bdf8'],
},
view: { stroke: 'transparent' },
};
```
### Selectable chart themes
One mapping, in one place, is the single source of truth for theme → config:
On top of the house pair, the user picks a **chart theme** (spec §04 → Chart
theme) — `ChartThemeId = 'astrolabe' | 'stock' | <vega-themes preset id>`:
```ts
// src/core/vega-themes.ts
import type { UiTheme } from './theme'; // core-local — never import from src/app
- `'astrolabe'` resolves via `chartConfigFor(uiTheme)` (follows light/dark);
- `'stock'` resolves to `{}` — nothing injected, pure Vega-Lite defaults;
- preset ids resolve to the `vega-themes` package's configs verbatim (the same
presets as the Vega editor's theme dropdown; the package is already in the
tree as a vega-embed dependency).
const CHART_CONFIG: Record<UiTheme, Config> = {
light: lightChartConfig,
dark: darkChartConfig,
};
`chartConfigForSelection(selection, uiTheme)` is the only resolver. The choice
lives in `AppStore.chartTheme`, persisted as `ui.chartTheme` by
`orchestration/preferences.ts` (the `previewFitMode` pattern), and is surfaced
by a `SelectControl` in the LivePreview header — **not** inside the
PreviewSettings popover: `SelectControl` and `SettingsPopover` share the
one-open-popover registry, so a select nested in the popover would close (and
unmount) its own parent on open.
export function chartConfigFor(theme: UiTheme): Config {
return CHART_CONFIG[theme];
}
```
The renderer reads the active UI theme (from the store) and passes the matching config into
`renderSpec`. When the theme changes, the same subscriber that drives
re-rendering picks up the new config and the chart restyles automatically.
Render-time precedence: vega-lite merges the injected config **under** the
spec's own `config` (`mergeConfig(opt.config, spec.config)` — the spec wins
key-by-key), so a snippet can always override or opt out locally. The
`core/spec-config.ts` merge/extract operations (spec §03G) move styling across
that boundary deliberately: merge bakes the selected theme into `spec.config`
(spec keys win — rendering unchanged), extract lifts `spec.config` out.
### Rules
- **Do** keep `chartConfigFor` as the _only_ place that maps a UI theme to a Vega
config. Adding a UI theme = adding one config and one map entry.
- **Do** set chart `background: 'transparent'` so the pane's own background shows
through and theme switches look seamless.
- **Do** keep `chartConfigForSelection` as the _only_ place that maps the user's
selection (and UI theme) to a Vega config.
- **Do** set chart `background: 'transparent'` in the house configs so the
pane's own background shows through and theme switches look seamless. Preset
themes carry their own backgrounds (often white) and render as their authors
intended — honest preview beats pane-matching.
- **Do** keep the Chart Builder preview and onboarding thumbnails on
`chartConfigFor(uiTheme)` — they are app surfaces, not destination previews.
- **Don't** inline colors or fonts into individual specs to "match the theme" —
that is the config's job, and per-spec styling drifts from the app.
- **Don't** let the user's stored spec carry a `config`; the theme config is
applied at embed time via the embed options, leaving the spec theme-agnostic.
- **Don't** write the injected config into the user's stored spec implicitly;
it is applied at embed time, leaving the spec theme-agnostic. Baking it in is
the explicit, user-invoked merge action only.
### Theme flow (end to end)
@@ -237,7 +220,8 @@ Theme spans several layers; the path is:
(localStorage `ui.theme`). On load, `initTheme()` — called from `main.tsx`
**before** `createRoot().render` — hydrates the saved theme. Chart and editor
follow by subscribing to `uiTheme`: `LivePreview` re-embeds with
`chartConfigFor(theme)`, `SpecEditor` sets the Monaco theme. UI chrome repaints
`chartConfigForSelection(chartTheme, uiTheme)`, `SpecEditor` sets the Monaco
theme. UI chrome repaints
purely from the `[data-theme]` token swap in `styles/tokens.css`. The header
`ThemeToggle` is the user control.