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
+16 -1
View File
@@ -376,7 +376,22 @@ useAppStore.subscribe((s, prev) => {
The store stays DOM-free; the adapter (the `applyTheme` subscriber) lives at the edge.
### Debounced auto-save of the draft spec
### Adding a persisted UI preference (the established chain)
A small global preference (preview fit mode, chart theme) follows one chain — five touch
points, in order:
1. `core/settings.ts` — field on `UserSettings` + `defaultSettings()` + `loadSettings`
validation (an unknown stored value falls back to the default, never breaks the app).
2. `infrastructure/settings-store.ts` — slice loader/saver pair using the per-slice
write-through merge (doc 02 §5), so other writers of the shared record survive.
3. `stores/AppStore.ts` — field + setter (the store stays browser-free).
4. `orchestration/preferences.ts``initX()` hydrates the store from the adapter;
`wireX()` subscribes store → adapter.
5. `main.tsx` — call both before `createRoot().render` (the store is the single source
of truth from first paint).
The UI control only calls the AppStore setter; persistence follows from the subscriber.
The Monaco editor writes every keystroke into `draftSpec`. We do **not** persist on
every keystroke. A startup subscriber observes the draft and debounces the expensive
+8 -3
View File
@@ -243,7 +243,7 @@ Small, frequently-read structured records live in `localStorage`, not IndexedDB:
The pattern is **load-with-fallback, per-slice write-through merge.**
- **Load-with-fallback:** merge the parsed stored object over a complete `DEFAULTS` constant. Missing keys (a setting added in a later build) and malformed JSON silently fall back to defaults — the app always gets a fully-populated object and never `undefined`-crashes on a new field. For `UserSettings`, the normalization is **pure and lives in `@core/settings` (`loadSettings(raw)` / `defaultSettings()`)** — it clamps ranges and validates enums; the infra adapter is just the thin localStorage reader (`loadUserSettings()` = `loadSettings(readRaw())`).
- **Per-slice write-through merge:** every update reads current, merges in **just its slice**, and writes back. **The one `astrolabe:settings` record has multiple independent writers**, because settings are distributed and live-applied (spec §07 — no central save, no Apply step): the header theme toggle writes `ui.theme`, the preview Fit control writes `ui.previewFitMode`, and the per-pane settings clusters write `editor`/`performance`/`formatting`. **A writer that replaced the whole record would clobber the slices it doesn't own** — so each must field-merge. (There is deliberately no whole-record `saveSettings`.)
- **Per-slice write-through merge:** every update reads current, merges in **just its slice**, and writes back. **The one `astrolabe:settings` record has multiple independent writers**, because settings are distributed and live-applied (spec §07 — no central save, no Apply step): the header theme toggle writes `ui.theme`, the preview Fit control writes `ui.previewFitMode`, the preview Chart-theme picker writes `ui.chartTheme`, and the per-pane settings clusters write `editor`/`performance`/`formatting`. **A writer that replaced the whole record would clobber the slices it doesn't own** — so each must field-merge. (There is deliberately no whole-record `saveSettings`.)
- **Environment-guarded:** `localStorage` is absent or throws in some test/SSR contexts; guard access and degrade to defaults rather than throwing.
```ts
@@ -263,7 +263,11 @@ export interface UserSettings {
tabSize: number;
};
performance: { renderDebounce: number };
ui: { theme: 'light' | 'dark'; previewFitMode: 'default' | 'width' | 'height' | 'full' };
ui: {
theme: 'light' | 'dark';
previewFitMode: 'default' | 'width' | 'height' | 'full';
chartTheme: ChartThemeId; // 'astrolabe' | 'stock' | vega-themes preset id
};
formatting: { dateFormat: 'smart' | 'iso' | 'custom'; customDateFormat: string };
}
@@ -280,7 +284,7 @@ const DEFAULTS: UserSettings = {
tabSize: 2,
},
performance: { renderDebounce: 1500 },
ui: { theme: 'light', previewFitMode: 'default' },
ui: { theme: 'light', previewFitMode: 'default', chartTheme: 'astrolabe' },
formatting: { dateFormat: 'smart', customDateFormat: '' },
};
@@ -324,6 +328,7 @@ export function loadSettings(): UserSettings {
// record, so the others survive (see the per-slice rule above):
// saveUiTheme(theme) -> { ...current, ui: { ...current.ui, theme } }
// savePreviewFitMode(mode) -> { ...current, ui: { ...current.ui, previewFitMode } }
// saveChartTheme(chartTheme) -> { ...current, ui: { ...current.ui, chartTheme } }
// saveManagedSettings(managed) -> { ...current, editor, performance, formatting }
```
@@ -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.
@@ -429,9 +429,25 @@ Arrow/Home/End rove). The selected option carries `aria-current` and a visible
colour alone. The same control doubles as an **action picker** (no `value`; e.g. "Add field
to which channel?"). A custom `triggerClassName` _replaces_ the default trigger styling, so
chip-styled triggers (the pill's type chip, the shelf's field chips) stay chips.
The single-open registry means **disclosures cannot nest**: a SelectControl inside a
settings popover would close — and unmount — its own parent on open. A control that needs
its own popover sits beside the gear in the pane header, never inside the panel.
_(Consulted via /council → WAI-ARIA APG disclosure/menu-button/radio, Carbon, NN/g #4. This
bullet is the contract; cite it, not the source.)_
**Resolved — editor commands need a visible home; hidden surfaces are accelerators only.**
A command that exists _only_ in Monaco's right-click context menu or F1 palette is
undiscoverable (NN/g #6 recognition-over-recall — those surfaces demand the user already
know the command exists). Every editor command gets a **visible toolbar home**; when the
toolbar can't afford a dedicated button (Carbon menu-buttons: "use an overflow menu when
additional options are available and there is a space constraint"), the home is a
SelectControl **action picker** grouping related commands (e.g. the spec editor's _Config_
menu: merge chart theme / extract config), with `detail` lines saying what each does.
Context-menu and palette registrations stay, as the NN/g #7 expert accelerators, but they
call the same functions as the visible control — one code path, two doors.
_(Consulted via /council → NN/g #6/#7, Carbon menu-buttons/overflow-menu. This bullet is
the contract; cite it, not the source.)_
**Resolved — field→channel assignment: explicit choice, visible armed state.** Clicking a
shelf field with no channel armed opens an explicit **channel chooser** (the channels that
accept the field; an occupied one is labelled with what it replaces) — never a silent