mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Chart theming: selectable chart theme + spec↔config merge/extract
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user