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. 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 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 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.** 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())`). - **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. - **Environment-guarded:** `localStorage` is absent or throws in some test/SSR contexts; guard access and degrade to defaults rather than throwing.
```ts ```ts
@@ -263,7 +263,11 @@ export interface UserSettings {
tabSize: number; tabSize: number;
}; };
performance: { renderDebounce: 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 }; formatting: { dateFormat: 'smart' | 'iso' | 'custom'; customDateFormat: string };
} }
@@ -280,7 +284,7 @@ const DEFAULTS: UserSettings = {
tabSize: 2, tabSize: 2,
}, },
performance: { renderDebounce: 1500 }, performance: { renderDebounce: 1500 },
ui: { theme: 'light', previewFitMode: 'default' }, ui: { theme: 'light', previewFitMode: 'default', chartTheme: 'astrolabe' },
formatting: { dateFormat: 'smart', customDateFormat: '' }, formatting: { dateFormat: 'smart', customDateFormat: '' },
}; };
@@ -324,6 +328,7 @@ export function loadSettings(): UserSettings {
// record, so the others survive (see the per-slice rule above): // record, so the others survive (see the per-slice rule above):
// saveUiTheme(theme) -> { ...current, ui: { ...current.ui, theme } } // saveUiTheme(theme) -> { ...current, ui: { ...current.ui, theme } }
// savePreviewFitMode(mode) -> { ...current, ui: { ...current.ui, previewFitMode } } // savePreviewFitMode(mode) -> { ...current, ui: { ...current.ui, previewFitMode } }
// saveChartTheme(chartTheme) -> { ...current, ui: { ...current.ui, chartTheme } }
// saveManagedSettings(managed) -> { ...current, editor, performance, formatting } // 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 ## 3. Theme Follows the UI Theme
A Vega-Lite **config** object styles every chart globally — fonts, axis colors, A Vega-Lite **config** object styles every chart globally — fonts, axis colors,
background, the categorical color range, default mark colors. Astrolabe ships one background, the categorical color range. Astrolabe ships one config per UI theme
config per UI theme so charts visually belong to the app rather than looking like so charts visually belong to the app rather than looking like stock Vega-Lite.
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 - **Base** (`lightBaseConfig`/`darkBaseConfig`) — the legibility minimum:
// src/core/vega-themes.ts (sketch) `background: 'transparent'` (the pane shows through) plus guide colors on the
import type { Config } from 'vega-lite'; 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 = { `mergeChartLayers(base, expressive)` produces `lightChartConfig`/
background: 'transparent', `darkChartConfig`, and `chartConfigFor(uiTheme)` is the one UI-theme → config
font: '"Inter", sans-serif', mapping. The split exists so a non-house style can keep the base layer while
title: { fontSize: 15, fontWeight: 600, color: '#1c1c1e' }, swapping the expressive one (future custom themes).
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' },
};
export const darkChartConfig: Config = { ### Selectable chart themes
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' },
};
```
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 - `'astrolabe'` resolves via `chartConfigFor(uiTheme)` (follows light/dark);
// src/core/vega-themes.ts - `'stock'` resolves to `{}` — nothing injected, pure Vega-Lite defaults;
import type { UiTheme } from './theme'; // core-local — never import from src/app - 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> = { `chartConfigForSelection(selection, uiTheme)` is the only resolver. The choice
light: lightChartConfig, lives in `AppStore.chartTheme`, persisted as `ui.chartTheme` by
dark: darkChartConfig, `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 { Render-time precedence: vega-lite merges the injected config **under** the
return CHART_CONFIG[theme]; 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`
The renderer reads the active UI theme (from the store) and passes the matching config into (spec keys win — rendering unchanged), extract lifts `spec.config` out.
`renderSpec`. When the theme changes, the same subscriber that drives
re-rendering picks up the new config and the chart restyles automatically.
### Rules ### Rules
- **Do** keep `chartConfigFor` as the _only_ place that maps a UI theme to a Vega - **Do** keep `chartConfigForSelection` as the _only_ place that maps the user's
config. Adding a UI theme = adding one config and one map entry. selection (and UI theme) to a Vega config.
- **Do** set chart `background: 'transparent'` so the pane's own background shows - **Do** set chart `background: 'transparent'` in the house configs so the
through and theme switches look seamless. 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" — - **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. 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 - **Don't** write the injected config into the user's stored spec implicitly;
applied at embed time via the embed options, leaving the spec theme-agnostic. 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) ### 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` (localStorage `ui.theme`). On load, `initTheme()` — called from `main.tsx`
**before** `createRoot().render` — hydrates the saved theme. Chart and editor **before** `createRoot().render` — hydrates the saved theme. Chart and editor
follow by subscribing to `uiTheme`: `LivePreview` re-embeds with 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 purely from the `[data-theme]` token swap in `styles/tokens.css`. The header
`ThemeToggle` is the user control. `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 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 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. 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 _(Consulted via /council → WAI-ARIA APG disclosure/menu-button/radio, Carbon, NN/g #4. This
bullet is the contract; cite it, not the source.)_ 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 **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 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 accept the field; an occupied one is labelled with what it replaces) — never a silent
+170
View File
@@ -0,0 +1,170 @@
# 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** — new IndexedDB entity `{ name, config, fonts? }` + list UI;
created by duplicating a preset or extract-from-spec; appears in the slice-2
selector. Export/import as JSON alongside the library.
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 (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.
+7
View File
@@ -76,3 +76,10 @@ When a snippet's spec embeds its data inline, the user can lift that data out in
- A toast confirms the dataset was created, and the modal closes. - A toast confirms the dataset was created, and the modal closes.
- The user can cancel the modal at any time, leaving the spec unchanged. - The user can cancel the modal at any time, leaving the spec unchanged.
- Dataset-side specifics (formats, storage, the bidirectional snippet↔dataset link) are described in _Datasets_. - Dataset-side specifics (formats, storage, the bidirectional snippet↔dataset link) are described in _Datasets_.
## G. Spec ↔ Config Actions
Two editor actions make the injected chart theme portable (see _Live Preview → Chart theme_). Their visible home is a **Config** menu in the editor toolbar (a value-select disclosure listing both actions with a one-line description each), disabled when no snippet is active or the read-only published view is shown; the editor's right-click context menu and F1 command palette offer the same actions as expert accelerators. Both actions replace the document as a single undoable edit (⌘/Ctrl+Z restores), reformatted in the app's JSON style, and both refuse with a clear toast when the document is not a valid JSON object.
- **Merge Chart Theme into Spec** — bakes the currently selected chart theme into the spec's own `config` block, deep-merging under any existing `config` so the spec's own keys win and the rendered result is unchanged. Use it before publishing a spec somewhere the app's theme won't follow. When the selected theme injects nothing (Stock Vega-Lite), the action explains there is nothing to merge.
- **Extract Config from Spec** — removes the spec's `config` block and copies it to the clipboard, for cleaning baked-in styling out of a pasted spec. The clipboard copy happens **before** the removal; if the copy fails, the spec is left unchanged so the config is never lost. A spec with no config block reports that and changes nothing.
+15
View File
@@ -36,6 +36,21 @@ Behavior of the selected mode:
- The selected mode persists across sessions, stored in _Settings_ as `previewFitMode`. - The selected mode persists across sessions, stored in _Settings_ as `previewFitMode`.
- The default is the natural Original mode. - The default is the natural Original mode.
## Chart theme
The preview pane header carries a **Chart theme** picker — a value-select disclosure choosing which Vega-Lite config is injected when charts render:
- **Astrolabe** (default) — the house style; follows the app's light/dark theme.
- **Stock Vega-Lite** — injects nothing; charts render exactly as plain Vega-Lite defaults would anywhere else (white background, default palette and fonts).
- **Presets** — the `vega-themes` preset configs (Excel, ggplot2, FiveThirtyEight, LA Times, Power BI, the Carbon family, …), rendered verbatim and independent of the app's light/dark theme.
Behavior:
- The choice is a **global preference**, not per-snippet; it persists across sessions, stored in _Settings_ as `ui.chartTheme`.
- The injected config applies at render time only — it is never written into the snippet's stored spec. A spec's own `config` block overrides the injected config property by property, so a snippet can opt out of any part of it locally (see also _Spec Editor → Spec ↔ config actions_).
- Image export reflects the selected theme: exports render from the same themed view.
- The Chart Builder preview and onboarding thumbnails are app surfaces and stay house-styled regardless of this choice.
## Export control ## Export control
The preview pane header also carries a per-chart **Export** control — a disclosure for copying or downloading the current chart's spec, or downloading its rendered image (PNG/SVG). It exports what the preview shows. The behavior is specified in _Import & Export → Per-chart export_; it lives in this header because the image formats are produced from the live rendered view. The preview pane header also carries a per-chart **Export** control — a disclosure for copying or downloading the current chart's spec, or downloading its rendered image (PNG/SVG). It exports what the preview shows. The behavior is specified in _Import & Export → Per-chart export_; it lives in this header because the image formats are produced from the live rendered view.
+1
View File
@@ -74,6 +74,7 @@ Governs how dates are rendered throughout the app, for example the timestamps sh
The following preferences also persist locally across sessions and, like the clusters above, are managed by controls in the pane they affect; they are documented in their own sections: The following preferences also persist locally across sessions and, like the clusters above, are managed by controls in the pane they affect; they are documented in their own sections:
- **Preview fit mode** — how the preview is sized/fit; see _Live Preview_. - **Preview fit mode** — how the preview is sized/fit; see _Live Preview_.
- **Chart theme** — which config charts render and export with (`ui.chartTheme`); see _Live Preview → Chart theme_.
- **Snippet sort preference** — the snippet list's sort field and direction; see _Snippet Library_. - **Snippet sort preference** — the snippet list's sort field and direction; see _Snippet Library_.
## Behaviors ## Behaviors
+2 -1
View File
@@ -77,12 +77,13 @@ The current **Dataset** version is `2`. The v1→v2 migration reflects the URL-s
| `performance.renderDebounce` | number | Delay (ms) before re-rendering the preview after edits. | | `performance.renderDebounce` | number | Delay (ms) before re-rendering the preview after edits. |
| `ui.theme` | string | App theme: `light` or `dark`. | | `ui.theme` | string | App theme: `light` or `dark`. |
| `ui.previewFitMode` | string | Preview sizing: `default`, `width`, `height`, or `full`. | | `ui.previewFitMode` | string | Preview sizing: `default`, `width`, `height`, or `full`. |
| `ui.chartTheme` | string | Chart theme: `astrolabe`, `stock`, or a preset id. |
| `formatting.dateFormat` | string | Date display mode: `smart`, `iso`, or `custom`. | | `formatting.dateFormat` | string | Date display mode: `smart`, `iso`, or `custom`. |
| `formatting.customDateFormat` | string | Pattern used when `dateFormat = custom`. | | `formatting.customDateFormat` | string | Pattern used when `dateFormat = custom`. |
A reference shape: A reference shape:
UserSettings = { version, editor: { fontSize, theme, minimap, wordWrap, lineNumbers, tabSize }, performance: { renderDebounce }, ui: { theme, previewFitMode }, formatting: { dateFormat, customDateFormat } } UserSettings = { version, editor: { fontSize, theme, minimap, wordWrap, lineNumbers, tabSize }, performance: { renderDebounce }, ui: { theme, previewFitMode, chartTheme }, formatting: { dateFormat, customDateFormat } }
## D. App / UI preferences (persisted separately) ## D. App / UI preferences (persisted separately)
+8 -5
View File
@@ -8,11 +8,14 @@ record the resolution into the contract (`docs/architecture/09`+`10` and the rel
## Open ## Open
_(none — the 2026-06-12 batch resolved all parked items: type-cycle chip → direct-pick - **Chart theme picker placement & header crowding** (`LivePreview.tsx` — ChartThemeControl).
SelectControl; field-assignment flow → explicit channel chooser + visible armed state, drag The picker sits in the preview header because nesting a SelectControl inside the
still deferred; "or constant" → "Use a constant" ghost button; chart-level controls → PreviewSettings popover is impossible today (one-open-popover registry: the select would
properties strip under the preview. Resolutions recorded in `architecture/10` §5 and close/unmount its own parent). Header now holds Fit + theme + export + gear; at narrow
`spec/06`.)_ pane widths the long trigger labels ("FiveThirtyEight", "Urban Institute") may crowd it.
Council questions: does the picker deserve header prominence (the "transform your chart"
showcase) or settings-cluster placement (a persistent global pref); should the popover
registry learn nesting; 16 flat options — group presets under a heading?
## Deferred (not design debts, revisit on demand) ## Deferred (not design debts, revisit on demand)
+1
View File
@@ -17,6 +17,7 @@
"vega": "^6.2.0", "vega": "^6.2.0",
"vega-embed": "^7.1.0", "vega-embed": "^7.1.0",
"vega-lite": "^6.4.2", "vega-lite": "^6.4.2",
"vega-themes": "3.0.0",
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
+1
View File
@@ -33,6 +33,7 @@
"vega": "^6.2.0", "vega": "^6.2.0",
"vega-embed": "^7.1.0", "vega-embed": "^7.1.0",
"vega-lite": "^6.4.2", "vega-lite": "^6.4.2",
"vega-themes": "3.0.0",
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
+48 -8
View File
@@ -10,6 +10,8 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { act } from 'react'; import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client'; import { createRoot, type Root } from 'react-dom/client';
import { chartConfigForSelection } from '@core/vega-themes';
import { useAppStore } from '../stores/AppStore';
import { usePreviewStore } from '../stores/PreviewStore'; import { usePreviewStore } from '../stores/PreviewStore';
import { useSnippetStore } from '../stores/SnippetStore'; import { useSnippetStore } from '../stores/SnippetStore';
import { useDatasetStore } from '../stores/DatasetStore'; import { useDatasetStore } from '../stores/DatasetStore';
@@ -24,10 +26,12 @@ const H = vi.hoisted(() => ({
calls: 0, calls: 0,
pending: [] as Array<() => void>, pending: [] as Array<() => void>,
destroyed: [] as number[], destroyed: [] as number[],
configs: [] as unknown[],
})); }));
vi.mock('../services/chart-renderer', () => ({ vi.mock('../services/chart-renderer', () => ({
renderSpec: (node: HTMLElement) => { renderSpec: (node: HTMLElement, _spec: unknown, config: unknown) => {
const id = ++H.calls; const id = ++H.calls;
H.configs.push(config);
return new Promise((resolve) => { return new Promise((resolve) => {
H.pending.push(() => { H.pending.push(() => {
node.replaceChildren(); // a real embed wipes then rebuilds the host node.replaceChildren(); // a real embed wipes then rebuilds the host
@@ -64,6 +68,7 @@ beforeEach(() => {
H.calls = 0; H.calls = 0;
H.pending.length = 0; H.pending.length = 0;
H.destroyed.length = 0; H.destroyed.length = 0;
H.configs.length = 0;
usePreviewStore.setState({ error: null, busy: false }); usePreviewStore.setState({ error: null, busy: false });
useSnippetStore.getState().reset(); useSnippetStore.getState().reset();
useDatasetStore.getState().reset(); useDatasetStore.getState().reset();
@@ -82,21 +87,27 @@ afterEach(() => {
}); });
describe('LivePreview busy overlay', () => { describe('LivePreview busy overlay', () => {
// The overlay is the aria-hidden element carrying the "Rendering…" label — a
// bare [aria-hidden] query would also match decorative bits of the header
// controls (e.g. the chart-theme select's caret).
const overlay = () =>
[...container.querySelectorAll('[aria-hidden="true"]')].find((el) =>
/rendering/i.test(el.textContent ?? ''),
) ?? null;
test('does not render the busy overlay when busy=false', () => { test('does not render the busy overlay when busy=false', () => {
// The overlay element should not be in the DOM at all during normal operation. // The overlay element should not be in the DOM at all during normal operation.
expect(container.querySelector('[aria-hidden="true"]')).toBeNull(); expect(overlay()).toBeNull();
}); });
test('renders the busy overlay when PreviewStore.busy=true', () => { test('renders the busy overlay when PreviewStore.busy=true', () => {
act(() => usePreviewStore.setState({ busy: true })); act(() => usePreviewStore.setState({ busy: true }));
const overlay = container.querySelector('[aria-hidden="true"]'); expect(overlay()).not.toBeNull();
expect(overlay).not.toBeNull();
}); });
test('overlay carries a visible label for sighted users', () => { test('overlay carries a visible label for sighted users', () => {
act(() => usePreviewStore.setState({ busy: true })); act(() => usePreviewStore.setState({ busy: true }));
const label = container.querySelector('[aria-hidden="true"]')?.textContent; expect(overlay()?.textContent).toMatch(/rendering/i);
expect(label).toMatch(/rendering/i);
}); });
test('the preview body carries aria-busy=true when busy', () => { test('the preview body carries aria-busy=true when busy', () => {
@@ -113,9 +124,9 @@ describe('LivePreview busy overlay', () => {
test('overlay disappears when busy returns to false', () => { test('overlay disappears when busy returns to false', () => {
act(() => usePreviewStore.setState({ busy: true })); act(() => usePreviewStore.setState({ busy: true }));
expect(container.querySelector('[aria-hidden="true"]')).not.toBeNull(); expect(overlay()).not.toBeNull();
act(() => usePreviewStore.setState({ busy: false })); act(() => usePreviewStore.setState({ busy: false }));
expect(container.querySelector('[aria-hidden="true"]')).toBeNull(); expect(overlay()).toBeNull();
}); });
}); });
@@ -186,3 +197,32 @@ describe('LivePreview render serialization', () => {
} }
}); });
}); });
describe('LivePreview chart theme', () => {
const tick = (ms = 6000) => act(async () => void (await vi.advanceTimersByTimeAsync(ms)));
test('the selected chart theme decides the config passed to renderSpec', async () => {
vi.useFakeTimers();
try {
act(() => {
useAppStore.setState({ chartTheme: 'stock', uiTheme: 'dark' });
useSnippetStore.setState({ draftText: '{"data":{"values":[]},"mark":"point"}' });
});
await tick();
act(() => H.pending[0]());
await tick(0);
// Stock = inject nothing; vega-lite's own defaults apply.
expect(H.configs[0]).toEqual({});
// Switching the theme re-renders with the new config (no text change needed).
act(() => useAppStore.setState({ chartTheme: 'astrolabe' }));
await tick();
act(() => H.pending[1]());
await tick(0);
expect(H.configs[1]).toEqual(chartConfigForSelection('astrolabe', 'dark'));
} finally {
vi.useRealTimers();
act(() => useAppStore.setState({ chartTheme: 'astrolabe', uiTheme: 'light' }));
}
});
});
+31 -3
View File
@@ -20,7 +20,7 @@ import { useShallow } from 'zustand/react/shallow';
import type { VisualizationSpec } from 'vega-embed'; import type { VisualizationSpec } from 'vega-embed';
import type { FitMode } from '@core/rendering'; import type { FitMode } from '@core/rendering';
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering'; import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
import { chartConfigFor } from '@core/vega-themes'; import { CHART_THEME_OPTIONS, chartConfigForSelection } from '@core/vega-themes';
import { renderSpec, type RenderHandle } from '../services/chart-renderer'; import { renderSpec, type RenderHandle } from '../services/chart-renderer';
import { useAppStore } from '../stores/AppStore'; import { useAppStore } from '../stores/AppStore';
import { useDatasetStore } from '../stores/DatasetStore'; import { useDatasetStore } from '../stores/DatasetStore';
@@ -29,6 +29,7 @@ import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { useUserSettingsStore } from '../stores/UserSettingsStore'; import { useUserSettingsStore } from '../stores/UserSettingsStore';
import { ChartExport } from './ChartExport'; import { ChartExport } from './ChartExport';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl'; import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { SelectControl } from './SelectControl';
import { RangeControl, SettingRow, SettingsPopover } from './SettingsPopover'; import { RangeControl, SettingRow, SettingsPopover } from './SettingsPopover';
import styles from './LivePreview.module.css'; import styles from './LivePreview.module.css';
@@ -68,6 +69,30 @@ function FitControl() {
); );
} }
/**
* Chart theme picker (spec §04; docs/chart-theming-scope.md §4.2) — which config
* charts render (and export) with. A global preference, not per-snippet: a
* snippet's own `config` still overrides it property by property. Lives in the
* header, not inside PreviewSettings: 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.
*/
// TODO: header placement/crowding parked for the batched council pass (docs/ux-second-pass.md).
function ChartThemeControl() {
const chartTheme = useAppStore((s) => s.chartTheme);
const setChartTheme = useAppStore((s) => s.setChartTheme);
return (
<SelectControl
id="preview-chart-theme"
label="Chart theme"
options={CHART_THEME_OPTIONS}
value={chartTheme}
onSelect={setChartTheme}
triggerTitle="Chart theme — how charts are styled when rendered and exported"
/>
);
}
/** Preview settings cluster (spec §07 → Performance), disclosed beside Fit. */ /** Preview settings cluster (spec §07 → Performance), disclosed beside Fit. */
function PreviewSettings() { function PreviewSettings() {
const renderDebounce = useUserSettingsStore((s) => s.saved.performance.renderDebounce); const renderDebounce = useUserSettingsStore((s) => s.saved.performance.renderDebounce);
@@ -100,6 +125,7 @@ export function LivePreview() {
const shownText = useSnippetStore(selectShownText); const shownText = useSnippetStore(selectShownText);
const fitMode = useAppStore((s) => s.previewFitMode); const fitMode = useAppStore((s) => s.previewFitMode);
const uiTheme = useAppStore((s) => s.uiTheme); const uiTheme = useAppStore((s) => s.uiTheme);
const chartTheme = useAppStore((s) => s.chartTheme);
// Datasets feed reference resolution (spec §04 step 1). Re-rendering on a // Datasets feed reference resolution (spec §04 step 1). Re-rendering on a
// dataset change keeps a referencing chart live as its data is edited. // dataset change keeps a referencing chart live as its data is edited.
const datasets = useDatasetStore(useShallow((s) => s.datasets)); const datasets = useDatasetStore(useShallow((s) => s.datasets));
@@ -212,7 +238,7 @@ export function LivePreview() {
try { try {
const prepared = prepareSpecForRender(parsed, { fitMode, datasets }); const prepared = prepareSpecForRender(parsed, { fitMode, datasets });
const config = chartConfigFor(uiTheme); const config = chartConfigForSelection(chartTheme, uiTheme);
handleRef.current?.destroy(); handleRef.current?.destroy();
handleRef.current = null; handleRef.current = null;
const handle = await renderSpec(node, prepared as VisualizationSpec, config); const handle = await renderSpec(node, prepared as VisualizationSpec, config);
@@ -259,6 +285,7 @@ export function LivePreview() {
shownText, shownText,
fitMode, fitMode,
uiTheme, uiTheme,
chartTheme,
datasets, datasets,
setError, setError,
setBusy, setBusy,
@@ -321,8 +348,9 @@ export function LivePreview() {
<div className={styles.preview}> <div className={styles.preview}>
<div className={styles.header}> <div className={styles.header}>
<FitControl /> <FitControl />
{/* Right cluster: export this chart, then the preview settings gear. */} {/* Right cluster: chart theme, export this chart, then the settings gear. */}
<div className={styles.headerEnd}> <div className={styles.headerEnd}>
<ChartThemeControl />
<ChartExport chartReady={chartReady} getImageUrl={getImageUrl} /> <ChartExport chartReady={chartReady} getImageUrl={getImageUrl} />
<PreviewSettings /> <PreviewSettings />
</div> </div>
+56 -3
View File
@@ -13,7 +13,7 @@
* inline near the editor (spec §03E), mirroring the preview via PreviewStore. * inline near the editor (spec §03E), mirroring the preview via PreviewStore.
*/ */
import { useEffect, useRef } from 'react'; import { useEffect, useRef, type RefObject } from 'react';
// `edcore.main` is the full standalone editor — every feature contribution // `edcore.main` is the full standalone editor — every feature contribution
// (folding, suggest widget, word operations like Cmd+Backspace, find, bracket // (folding, suggest widget, word operations like Cmd+Backspace, find, bracket
// colorization, multi-cursor, …) — but WITHOUT the `monaco-editor` barrel's // colorization, multi-cursor, …) — but WITHOUT the `monaco-editor` barrel's
@@ -25,6 +25,11 @@ import '../infrastructure/monaco-env'; // side-effect: wire workers before creat
import { configureVegaLiteJson } from '../infrastructure/monaco-schema'; import { configureVegaLiteJson } from '../infrastructure/monaco-schema';
import { configureJsonFormatter, installFormatOnPaste } from '../infrastructure/monaco-format'; import { configureJsonFormatter, installFormatOnPaste } from '../infrastructure/monaco-format';
import { openModal } from '../modals/ModalCoordinator'; import { openModal } from '../modals/ModalCoordinator';
import {
installSpecConfigActions,
runExtractConfig,
runMergeChartTheme,
} from '../services/spec-config-actions';
import { useAppStore } from '../stores/AppStore'; import { useAppStore } from '../stores/AppStore';
import { confirm } from '../stores/ConfirmStore'; import { confirm } from '../stores/ConfirmStore';
import { hasInlineData } from '../stores/ExtractStore'; import { hasInlineData } from '../stores/ExtractStore';
@@ -35,6 +40,7 @@ import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores
import { useUserSettingsStore } from '../stores/UserSettingsStore'; import { useUserSettingsStore } from '../stores/UserSettingsStore';
import { Icon } from './Icon'; import { Icon } from './Icon';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl'; import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { SelectControl } from './SelectControl';
import { import {
NumberControl, NumberControl,
RangeControl, RangeControl,
@@ -139,7 +145,30 @@ configureVegaLiteJson();
// Register the compact JSON formatter once (Format Document + format-on-paste, §03A). // Register the compact JSON formatter once (Format Document + format-on-paste, §03A).
configureJsonFormatter(); configureJsonFormatter();
function EditorToolbar() { /** The two specconfig operations, surfaced as an overflow menu (council:
* Carbon menu-buttons overflow for additional options under space
* constraint; NN/g #6 a visible home, with the Monaco context menu and F1
* palette as the #7 accelerators on the same code paths). */
const CONFIG_ACTIONS = [
{
value: 'merge',
label: 'Merge chart theme into spec',
detail: 'Write the active chart theme into the config block',
},
{
value: 'extract',
label: 'Extract config from spec',
detail: 'Remove the config block and copy it to the clipboard',
},
] as const;
type ConfigActionId = (typeof CONFIG_ACTIONS)[number]['value'];
function EditorToolbar({
editorRef,
}: {
editorRef: RefObject<monaco.editor.IStandaloneCodeEditor | null>;
}) {
const activeId = useSnippetStore((s) => s.activeSnippetId); const activeId = useSnippetStore((s) => s.activeSnippetId);
const editorView = useSnippetStore((s) => s.editorView); const editorView = useSnippetStore((s) => s.editorView);
const setEditorView = useSnippetStore((s) => s.setEditorView); const setEditorView = useSnippetStore((s) => s.setEditorView);
@@ -161,6 +190,13 @@ function EditorToolbar() {
// the button and the Cmd/Ctrl+S shortcut (EventRouter) behave identically. // the button and the Cmd/Ctrl+S shortcut (EventRouter) behave identically.
const handlePublish = publishActiveSnippet; const handlePublish = publishActiveSnippet;
const handleConfigAction = (action: ConfigActionId) => {
const editor = editorRef.current;
if (!editor) return;
if (action === 'merge') runMergeChartTheme(editor);
else void runExtractConfig(editor);
};
const handleRevert = async () => { const handleRevert = async () => {
const ok = await confirm({ const ok = await confirm({
title: 'Revert draft', title: 'Revert draft',
@@ -207,6 +243,17 @@ function EditorToolbar() {
<span className={styles.actionLabel}>Extract to Dataset</span> <span className={styles.actionLabel}>Extract to Dataset</span>
</button> </button>
)} )}
<SelectControl
id="editor-config-actions"
label="Spec config actions"
heading="Spec config"
options={CONFIG_ACTIONS}
onSelect={handleConfigAction}
triggerClassName={styles.action}
triggerContent="Config"
triggerTitle="Spec config actions — merge the chart theme in, or extract the config out"
disabled={activeId === null || editorView === 'published'}
/>
<button <button
type="button" type="button"
className={`${styles.action} ${styles.collapsible}`} className={`${styles.action} ${styles.collapsible}`}
@@ -286,6 +333,11 @@ export function SpecEditor() {
// up the formatted text. No-op on the read-only published view / invalid JSON. // up the formatted text. No-op on the read-only published view / invalid JSON.
const pasteSub = installFormatOnPaste(editor); const pasteSub = installFormatOnPaste(editor);
// Merge-chart-theme / extract-config actions (context menu + F1 palette,
// docs/chart-theming-scope.md §4.3). Edits land via onDidChangeModelContent
// above, so the draft buffer stays in sync like any other edit.
const configActionsSub = installSpecConfigActions(editor);
// Cmd/Ctrl+S is owned globally by the EventRouter (docs/architecture/04 → // Cmd/Ctrl+S is owned globally by the EventRouter (docs/architecture/04 →
// "bind listeners in exactly one place"), which publishes before the // "bind listeners in exactly one place"), which publishes before the
// interactive-context gate so it works while the editor has focus. Monaco // interactive-context gate so it works while the editor has focus. Monaco
@@ -294,6 +346,7 @@ export function SpecEditor() {
return () => { return () => {
sub.dispose(); sub.dispose();
pasteSub.dispose(); pasteSub.dispose();
configActionsSub.dispose();
editor.dispose(); editor.dispose();
editorRef.current = null; editorRef.current = null;
}; };
@@ -336,7 +389,7 @@ export function SpecEditor() {
return ( return (
<div className={styles.editorPane}> <div className={styles.editorPane}>
<EditorToolbar /> <EditorToolbar editorRef={editorRef} />
<div className={styles.editorWrap}> <div className={styles.editorWrap}>
{activeId === null && <div className={styles.placeholder}>Select or create a snippet</div>} {activeId === null && <div className={styles.placeholder}>Select or create a snippet</div>}
<div className={styles.editor} ref={hostRef} /> <div className={styles.editor} ref={hostRef} />
@@ -1,9 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { defaultSettings } from '@core/settings'; import { defaultSettings } from '@core/settings';
import { import {
loadChartTheme,
loadPreviewFitMode, loadPreviewFitMode,
loadUiTheme, loadUiTheme,
loadUserSettings, loadUserSettings,
saveChartTheme,
saveManagedSettings, saveManagedSettings,
savePreviewFitMode, savePreviewFitMode,
saveUiTheme, saveUiTheme,
@@ -119,6 +121,34 @@ describe('settings-store · ui.previewFitMode', () => {
}); });
}); });
describe('settings-store · ui.chartTheme', () => {
beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub()));
afterEach(() => vi.unstubAllGlobals());
it('defaults to astrolabe when nothing is stored', () => {
expect(loadChartTheme()).toBe('astrolabe');
});
it('returns a stored valid chart theme', () => {
localStorage.setItem(KEY, JSON.stringify({ ui: { chartTheme: 'stock' } }));
expect(loadChartTheme()).toBe('stock');
});
it('falls back to astrolabe for an unrecognized id', () => {
localStorage.setItem(KEY, JSON.stringify({ ui: { chartTheme: 'neon' } }));
expect(loadChartTheme()).toBe('astrolabe');
});
it('round-trips and preserves the other ui slices', () => {
saveUiTheme('dark');
savePreviewFitMode('height');
saveChartTheme('powerbi');
expect(loadChartTheme()).toBe('powerbi');
expect(loadUiTheme()).toBe('dark');
expect(loadPreviewFitMode()).toBe('height');
});
});
describe('settings-store · full UserSettings record', () => { describe('settings-store · full UserSettings record', () => {
beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub())); beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub()));
afterEach(() => vi.unstubAllGlobals()); afterEach(() => vi.unstubAllGlobals());
+17 -1
View File
@@ -19,6 +19,7 @@
import type { FitMode } from '@core/rendering'; import type { FitMode } from '@core/rendering';
import { loadSettings, type UserSettings } from '@core/settings'; import { loadSettings, type UserSettings } from '@core/settings';
import type { UiTheme } from '@core/theme'; import type { UiTheme } from '@core/theme';
import { isChartThemeId, type ChartThemeId } from '@core/vega-themes';
const KEY = 'astrolabe:settings'; const KEY = 'astrolabe:settings';
@@ -28,12 +29,15 @@ const DEFAULT_THEME: UiTheme = 'light';
/** Spec §04 — the Fit control defaults to Original. */ /** Spec §04 — the Fit control defaults to Original. */
const DEFAULT_FIT_MODE: FitMode = 'default'; const DEFAULT_FIT_MODE: FitMode = 'default';
/** Spec §04 — the Chart theme picker defaults to the house style. */
const DEFAULT_CHART_THEME: ChartThemeId = 'astrolabe';
/** The managed slice the per-pane settings clusters own (theme + fit live in their own slices). */ /** The managed slice the per-pane settings clusters own (theme + fit live in their own slices). */
export type ManagedSettings = Pick<UserSettings, 'editor' | 'performance' | 'formatting'>; export type ManagedSettings = Pick<UserSettings, 'editor' | 'performance' | 'formatting'>;
/** Loose view of the stored record for the per-slice write-through merges. */ /** Loose view of the stored record for the per-slice write-through merges. */
interface StoredSettings { interface StoredSettings {
ui?: { theme?: unknown; previewFitMode?: unknown; [k: string]: unknown }; ui?: { theme?: unknown; previewFitMode?: unknown; chartTheme?: unknown; [k: string]: unknown };
[k: string]: unknown; [k: string]: unknown;
} }
@@ -118,6 +122,18 @@ export function loadPreviewFitMode(): FitMode {
return isFitMode(stored) ? stored : DEFAULT_FIT_MODE; return isFitMode(stored) ? stored : DEFAULT_FIT_MODE;
} }
/** The persisted chart theme, or the default — unknown ids fall back. */
export function loadChartTheme(): ChartThemeId {
const stored = readRaw().ui?.chartTheme;
return isChartThemeId(stored) ? stored : DEFAULT_CHART_THEME;
}
/** Persist the chart theme, preserving every other key already in the record. */
export function saveChartTheme(chartTheme: ChartThemeId): void {
const current = readRaw();
writeRaw({ ...current, ui: { ...current.ui, chartTheme } });
}
/** Persist the preview fit mode, preserving every other key already in the record. */ /** Persist the preview fit mode, preserving every other key already in the record. */
export function savePreviewFitMode(fitMode: FitMode): void { export function savePreviewFitMode(fitMode: FitMode): void {
const current = readRaw(); const current = readRaw();
+21 -3
View File
@@ -1,15 +1,20 @@
/** /**
* Preference orchestration bridges the (browser-free) AppStore to the settings * Preference orchestration bridges the (browser-free) AppStore to the settings
* adapter for the small UI preferences pulled forward ahead of the M5 Settings * adapter for the small UI preferences pulled forward ahead of the M5 Settings
* modal. Same storeadapter pattern as theme orchestration; currently the only * modal. Same storeadapter pattern as theme orchestration: the Live Preview
* such preference is the Live Preview fit mode (spec §04, `previewFitMode`). * fit mode (spec §04, `previewFitMode`) and the chart theme (`chartTheme`).
* *
* Unlike theme there is no FOUC concern (the preview renders after hydration * Unlike theme there is no FOUC concern (the preview renders after hydration
* anyway), but hydrating early keeps the store the single source of truth from * anyway), but hydrating early keeps the store the single source of truth from
* the first render. * the first render.
*/ */
import { loadPreviewFitMode, savePreviewFitMode } from '../infrastructure/settings-store'; import {
loadChartTheme,
loadPreviewFitMode,
saveChartTheme,
savePreviewFitMode,
} from '../infrastructure/settings-store';
import { useAppStore } from '../stores/AppStore'; import { useAppStore } from '../stores/AppStore';
/** Hydrate the persisted fit mode into the store. Call before render. */ /** Hydrate the persisted fit mode into the store. Call before render. */
@@ -24,3 +29,16 @@ export function wirePreviewFitMode(): () => void {
savePreviewFitMode(state.previewFitMode); savePreviewFitMode(state.previewFitMode);
}); });
} }
/** Hydrate the persisted chart theme into the store. Call before render. */
export function initChartTheme(): void {
useAppStore.getState().setChartTheme(loadChartTheme());
}
/** Persist the chart theme on change. Returns a teardown that detaches the subscriber. */
export function wireChartTheme(): () => void {
return useAppStore.subscribe((state, prev) => {
if (state.chartTheme === prev.chartTheme) return;
saveChartTheme(state.chartTheme);
});
}
+175
View File
@@ -0,0 +1,175 @@
/**
* Spec config editor actions (docs/chart-theming-scope.md §4.3) the Monaco
* command-palette / context-menu pair over the portable core operations:
*
* - **Merge chart theme into spec** bakes the *currently selected* chart theme
* (the preview's Chart theme control) into the draft's `config` block, so the
* styling travels when the spec is published outside Astrolabe. The spec's
* existing `config` keys win rendering is unchanged.
* - **Extract config from spec** removes the draft's `config` block and copies
* it to the clipboard for cleaning baked-in styling out of a pasted spec.
* The clipboard write happens *before* the edit, so a clipboard failure
* never destroys the only copy.
*
* Both replace the document via `executeEdits`, so Z restores the previous
* text. Both no-op (with a toast naming the reason) on invalid JSON; Monaco's
* `!editorReadonly` precondition hides them on the published view.
*
* Surfacing (council: NN/g #6 recognition-over-recall, #7 accelerators; Carbon
* menu-buttons "use an overflow menu when additional options are available and
* there is a space constraint"): the visible home is the editor toolbar's
* **Config menu** (SpecEditor), which calls `runMergeChartTheme` /
* `runExtractConfig` directly; the context-menu/palette registrations here are
* the expert accelerators on the same code paths.
*/
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
import { formatJson } from '@core/json-format';
import { extractConfigFromSpec, isJsonObject, mergeConfigIntoSpec } from '@core/spec-config';
import { CHART_THEME_OPTIONS, chartConfigForSelection } from '@core/vega-themes';
import { copyText } from '../infrastructure/file-transfer';
import { useAppStore } from '../stores/AppStore';
import { notify } from '../stores/NotificationStore';
/** Parse the model's JSON, or toast (and return null) when it isn't a JSON object. */
function parseSpecObject(model: monaco.editor.ITextModel): Record<string, unknown> | null {
let parsed: unknown;
try {
parsed = JSON.parse(model.getValue());
} catch {
notify({
kind: 'error',
title: 'Spec is not valid JSON',
message: 'Fix the JSON syntax first, then try again.',
});
return null;
}
if (!isJsonObject(parsed)) {
notify({
kind: 'error',
title: 'Spec is not a JSON object',
message: 'Config actions need a top-level { … } Vega-Lite spec.',
});
return null;
}
return parsed;
}
/** Replace the whole document as one undoable edit, in the app's JSON style. */
function replaceDocument(
editor: monaco.editor.IStandaloneCodeEditor,
model: monaco.editor.ITextModel,
source: string,
next: Record<string, unknown>,
): void {
const raw = JSON.stringify(next);
const text = formatJson(raw, { indent: model.getOptions().tabSize }) ?? raw;
// Undo stops on both sides keep the replacement its own undo step — without
// the leading stop it can coalesce with the user's preceding typing, and ⌘Z
// would revert that too (Monaco's built-in actions bracket the same way).
editor.pushUndoStop();
editor.executeEdits(source, [{ range: model.getFullModelRange(), text }]);
editor.pushUndoStop();
}
/** Bake the currently selected chart theme into the draft's `config` block. */
export function runMergeChartTheme(editor: monaco.editor.IStandaloneCodeEditor): void {
const model = editor.getModel();
if (!model) return;
const spec = parseSpecObject(model);
if (!spec) return;
const { chartTheme, uiTheme } = useAppStore.getState();
// A Config is plain JSON data; the cast bridges its closed vega-lite type
// to the JsonObject the portable merge operates on.
const themeConfig = chartConfigForSelection(chartTheme, uiTheme) as Record<string, unknown>;
if (Object.keys(themeConfig).length === 0) {
notify({
kind: 'info',
title: 'Nothing to merge',
message: 'The Stock Vega-Lite chart theme injects no config.',
});
return;
}
const themeLabel = CHART_THEME_OPTIONS.find((o) => o.value === chartTheme)?.label ?? chartTheme;
replaceDocument(editor, model, 'merge-chart-theme', mergeConfigIntoSpec(spec, themeConfig));
notify({
kind: 'success',
title: 'Chart theme merged',
message: `The ${themeLabel} theme now travels in the specs config; existing keys were kept. Undo with ⌘/Ctrl+Z.`,
});
}
/** Remove the draft's `config` block, copying it to the clipboard first. */
export async function runExtractConfig(editor: monaco.editor.IStandaloneCodeEditor): Promise<void> {
const model = editor.getModel();
if (!model) return;
const spec = parseSpecObject(model);
if (!spec) return;
const { spec: rest, config } = extractConfigFromSpec(spec);
if (config === null) {
notify({
kind: 'info',
title: 'No config to extract',
message: 'This spec has no config block.',
});
return;
}
// Copy before removing — if the clipboard write fails, the spec keeps its
// config and nothing is lost.
try {
await copyText(JSON.stringify(config, null, 2));
} catch {
notify({
kind: 'error',
title: 'Could not copy the config',
message: 'Clipboard access failed, so the spec was left unchanged.',
});
return;
}
replaceDocument(editor, model, 'extract-config', rest);
notify({
kind: 'success',
title: 'Config extracted',
message: 'The config block was removed and copied to the clipboard. Undo with ⌘/Ctrl+Z.',
});
}
/**
* Register both actions on the editor (context menu + F1 palette the expert
* accelerators; the toolbar Config menu is the discoverable home). Returns a
* disposable that detaches them (dispose on editor unmount, like the
* format-on-paste hook).
*/
export function installSpecConfigActions(
editor: monaco.editor.IStandaloneCodeEditor,
): monaco.IDisposable {
const merge = editor.addAction({
id: 'astrolabe.merge-chart-theme',
label: 'Merge Chart Theme into Spec',
contextMenuGroupId: 'astrolabe',
contextMenuOrder: 1,
precondition: '!editorReadonly',
run: () => runMergeChartTheme(editor),
});
const extract = editor.addAction({
id: 'astrolabe.extract-config',
label: 'Extract Config from Spec',
contextMenuGroupId: 'astrolabe',
contextMenuOrder: 2,
precondition: '!editorReadonly',
run: () => runExtractConfig(editor),
});
return {
dispose() {
merge.dispose();
extract.dispose();
},
};
}
+7
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { FitMode } from '@core/rendering'; import type { FitMode } from '@core/rendering';
import type { UiTheme } from '@core/theme'; import type { UiTheme } from '@core/theme';
import type { ChartThemeId } from '@core/vega-themes';
import type { ModalName } from '../modals/types'; import type { ModalName } from '../modals/types';
/** /**
@@ -23,6 +24,8 @@ export interface AppState {
uiTheme: UiTheme; uiTheme: UiTheme;
/** Preview sizing mode (spec §04); persisted to Settings as `previewFitMode`. */ /** Preview sizing mode (spec §04); persisted to Settings as `previewFitMode`. */
previewFitMode: FitMode; previewFitMode: FitMode;
/** Chart theme selection (spec §04); persisted to Settings as `chartTheme`. */
chartTheme: ChartThemeId;
/** The currently open modal, or null. */ /** The currently open modal, or null. */
activeModal: ModalName | null; activeModal: ModalName | null;
@@ -31,6 +34,8 @@ export interface AppState {
toggleTheme: () => void; toggleTheme: () => void;
/** Set the preview fit mode — the Live Preview Fit control's action. */ /** Set the preview fit mode — the Live Preview Fit control's action. */
setPreviewFitMode: (mode: FitMode) => void; setPreviewFitMode: (mode: FitMode) => void;
/** Set the chart theme — the Live Preview settings cluster's action. */
setChartTheme: (theme: ChartThemeId) => void;
/** /**
* Low-level modal setter the single primitive that mutates `activeModal`. * Low-level modal setter the single primitive that mutates `activeModal`.
* High-level open/close (snapshot for unsaved-change detection, URL sync, * High-level open/close (snapshot for unsaved-change detection, URL sync,
@@ -43,10 +48,12 @@ export interface AppState {
export const useAppStore = create<AppState>((set) => ({ export const useAppStore = create<AppState>((set) => ({
uiTheme: 'light', uiTheme: 'light',
previewFitMode: 'default', previewFitMode: 'default',
chartTheme: 'astrolabe',
activeModal: null, activeModal: null,
setTheme: (uiTheme) => set({ uiTheme }), setTheme: (uiTheme) => set({ uiTheme }),
toggleTheme: () => set((s) => ({ uiTheme: s.uiTheme === 'dark' ? 'light' : 'dark' })), toggleTheme: () => set((s) => ({ uiTheme: s.uiTheme === 'dark' ? 'light' : 'dark' })),
setPreviewFitMode: (previewFitMode) => set({ previewFitMode }), setPreviewFitMode: (previewFitMode) => set({ previewFitMode }),
setChartTheme: (chartTheme) => set({ chartTheme }),
setActiveModal: (activeModal) => set({ activeModal }), setActiveModal: (activeModal) => set({ activeModal }),
})); }));
+5 -2
View File
@@ -20,7 +20,7 @@ describe('defaultSettings', () => {
tabSize: 2, tabSize: 2,
}, },
performance: { renderDebounce: 1500 }, performance: { renderDebounce: 1500 },
ui: { theme: 'light', previewFitMode: 'default' }, ui: { theme: 'light', previewFitMode: 'default', chartTheme: 'astrolabe' },
formatting: { dateFormat: 'smart', customDateFormat: '' }, formatting: { dateFormat: 'smart', customDateFormat: '' },
}); });
}); });
@@ -64,7 +64,7 @@ describe('loadSettings — valid records', () => {
tabSize: 4, tabSize: 4,
}, },
performance: { renderDebounce: 2500 }, performance: { renderDebounce: 2500 },
ui: { theme: 'dark', previewFitMode: 'full' }, ui: { theme: 'dark', previewFitMode: 'full', chartTheme: 'fivethirtyeight' },
formatting: { dateFormat: 'custom', customDateFormat: 'yyyy-MM-dd' }, formatting: { dateFormat: 'custom', customDateFormat: 'yyyy-MM-dd' },
}; };
expect(loadSettings(record)).toEqual(record); expect(loadSettings(record)).toEqual(record);
@@ -160,6 +160,7 @@ describe('loadSettings — enum validation', () => {
expect(loadSettings({ ui: { previewFitMode: 'tall' } }).ui.previewFitMode).toBe( expect(loadSettings({ ui: { previewFitMode: 'tall' } }).ui.previewFitMode).toBe(
d.ui.previewFitMode, d.ui.previewFitMode,
); );
expect(loadSettings({ ui: { chartTheme: 'comic-sans' } }).ui.chartTheme).toBe(d.ui.chartTheme);
expect(loadSettings({ formatting: { dateFormat: 'relative' } }).formatting.dateFormat).toBe( expect(loadSettings({ formatting: { dateFormat: 'relative' } }).formatting.dateFormat).toBe(
d.formatting.dateFormat, d.formatting.dateFormat,
); );
@@ -171,6 +172,8 @@ describe('loadSettings — enum validation', () => {
expect(loadSettings({ ui: { theme: 'dark' } }).ui.theme).toBe('dark'); expect(loadSettings({ ui: { theme: 'dark' } }).ui.theme).toBe('dark');
expect(loadSettings({ ui: { previewFitMode: 'width' } }).ui.previewFitMode).toBe('width'); expect(loadSettings({ ui: { previewFitMode: 'width' } }).ui.previewFitMode).toBe('width');
expect(loadSettings({ ui: { previewFitMode: 'height' } }).ui.previewFitMode).toBe('height'); expect(loadSettings({ ui: { previewFitMode: 'height' } }).ui.previewFitMode).toBe('height');
expect(loadSettings({ ui: { chartTheme: 'stock' } }).ui.chartTheme).toBe('stock');
expect(loadSettings({ ui: { chartTheme: 'latimes' } }).ui.chartTheme).toBe('latimes');
expect(loadSettings({ formatting: { dateFormat: 'iso' } }).formatting.dateFormat).toBe('iso'); expect(loadSettings({ formatting: { dateFormat: 'iso' } }).formatting.dateFormat).toBe('iso');
}); });
+11
View File
@@ -13,6 +13,8 @@
* guarantees that (the read-time migration, mirroring `migrateSnippet`). * guarantees that (the read-time migration, mirroring `migrateSnippet`).
*/ */
import { isChartThemeId, type ChartThemeId } from './vega-themes';
/** Current schema version for a UserSettings record (read-time migration target). */ /** Current schema version for a UserSettings record (read-time migration target). */
export const CURRENT_SETTINGS_VERSION = 1; export const CURRENT_SETTINGS_VERSION = 1;
@@ -57,6 +59,13 @@ export interface UserSettings {
* Preview_). Default `'default'`. * Preview_). Default `'default'`.
*/ */
previewFitMode: 'default' | 'width' | 'height' | 'full'; previewFitMode: 'default' | 'width' | 'height' | 'full';
/**
* Chart theme which config is injected when charts render (spec §04;
* docs/chart-theming-scope.md §4.2). `'astrolabe'` (default) is the house
* style following the UI theme; `'stock'` injects nothing; other ids are
* vega-themes presets. Set by the preview's settings cluster.
*/
chartTheme: ChartThemeId;
}; };
/** Date-rendering preferences (spec §07 → Formatting). */ /** Date-rendering preferences (spec §07 → Formatting). */
formatting: { formatting: {
@@ -97,6 +106,7 @@ export function defaultSettings(): UserSettings {
ui: { ui: {
theme: 'light', theme: 'light',
previewFitMode: 'default', previewFitMode: 'default',
chartTheme: 'astrolabe',
}, },
formatting: { formatting: {
dateFormat: 'smart', dateFormat: 'smart',
@@ -194,6 +204,7 @@ export function loadSettings(raw: unknown): UserSettings {
['default', 'width', 'height', 'full'] as const, ['default', 'width', 'height', 'full'] as const,
d.ui.previewFitMode, d.ui.previewFitMode,
), ),
chartTheme: isChartThemeId(ui.chartTheme) ? ui.chartTheme : d.ui.chartTheme,
}, },
formatting: { formatting: {
dateFormat: asEnum( dateFormat: asEnum(
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import { extractConfigFromSpec, isJsonObject, mergeConfigIntoSpec } from './spec-config';
describe('mergeConfigIntoSpec', () => {
const theme = {
background: 'transparent',
font: 'IBM Plex Sans',
axis: { labelColor: '#525252', gridDash: [2, 2] },
};
it('adds the config block to a spec without one', () => {
const spec = { mark: 'bar', data: { values: [] } };
const out = mergeConfigIntoSpec(spec, theme);
expect(out.config).toEqual(theme);
expect(out.mark).toBe('bar');
expect(spec).not.toHaveProperty('config'); // input untouched
});
it('the specs existing config wins key-by-key, deep', () => {
const spec = {
mark: 'bar',
config: { font: 'Georgia', axis: { labelColor: 'red' } },
};
const out = mergeConfigIntoSpec(spec, theme);
expect(out.config).toEqual({
background: 'transparent', // from the theme
font: 'Georgia', // spec wins
axis: { labelColor: 'red', gridDash: [2, 2] }, // merged: spec wins inside
});
});
it('arrays are replaced, not merged', () => {
const spec = { config: { axis: { gridDash: [8] } } };
const out = mergeConfigIntoSpec(spec, theme) as { config: { axis: { gridDash: number[] } } };
expect(out.config.axis.gridDash).toEqual([8]);
});
it('an empty config into a config-less spec stays config-less', () => {
expect(mergeConfigIntoSpec({ mark: 'bar' }, {})).toEqual({ mark: 'bar' });
});
it('a non-object spec config is replaced by the merge', () => {
const out = mergeConfigIntoSpec({ config: 'junk' }, theme);
expect(out.config).toEqual(theme);
});
});
describe('extractConfigFromSpec', () => {
it('removes and returns the config block', () => {
const spec = { mark: 'bar', config: { font: 'Georgia' } };
const out = extractConfigFromSpec(spec);
expect(out.config).toEqual({ font: 'Georgia' });
expect(out.spec).toEqual({ mark: 'bar' });
expect(spec).toHaveProperty('config'); // input untouched
});
it('returns null config when the spec has none', () => {
const out = extractConfigFromSpec({ mark: 'bar' });
expect(out.config).toBeNull();
expect(out.spec).toEqual({ mark: 'bar' });
});
it('an empty or non-object config extracts as null but is still removed', () => {
expect(extractConfigFromSpec({ mark: 'bar', config: {} })).toEqual({
spec: { mark: 'bar' },
config: null,
});
expect(extractConfigFromSpec({ mark: 'bar', config: 7 })).toEqual({
spec: { mark: 'bar' },
config: null,
});
});
});
describe('isJsonObject', () => {
it('accepts plain objects only', () => {
expect(isJsonObject({})).toBe(true);
expect(isJsonObject([])).toBe(false);
expect(isJsonObject(null)).toBe(false);
expect(isJsonObject('x')).toBe(false);
});
});
+78
View File
@@ -0,0 +1,78 @@
/**
* Spec config operations (docs/chart-theming-scope.md §4.3).
*
* The two halves of making the injected chart theme portable, mirroring the
* Vega editor's "Merge Config Into Spec" / "Extract Config From Spec" pair:
*
* - **Merge** bakes a config into the spec's own `config` block for
* publishing a snippet somewhere the app's theme won't follow it. The spec's
* existing `config` wins on conflicts, matching the render-time precedence
* (vega-lite layers `spec.config` over the injected config), so baking never
* changes how the chart looks.
* - **Extract** lifts the `config` block out of a spec for cleaning styling
* out of a pasted-in spec (the caller decides where the extracted config
* goes: clipboard today, a saved theme later).
*
* Pure object-in/object-out; JSON text handling (parse, format, undo) is the
* editor integration's job.
*/
/** A parsed JSON object (the only spec shape these operations accept). */
export type JsonObject = Record<string, unknown>;
/** Is the value a plain JSON object (not an array, not null)? */
export function isJsonObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Deep-merge `upper` over `lower`: plain objects merge recursively, everything
* else (arrays, scalars) is replaced by the upper value. The same shape of
* merge vega-lite applies between the embed-time config and `spec.config`.
*/
function deepMerge(lower: JsonObject, upper: JsonObject): JsonObject {
const out: JsonObject = { ...lower };
for (const [key, upperValue] of Object.entries(upper)) {
const lowerValue = out[key];
out[key] =
isJsonObject(lowerValue) && isJsonObject(upperValue)
? deepMerge(lowerValue, upperValue)
: upperValue;
}
return out;
}
/**
* Bake `config` into the spec's `config` block. The spec's existing `config`
* takes precedence key-by-key (deep), so the rendered result is unchanged
* the theme just travels with the spec now. Returns a new object; the input
* is not mutated. An empty merge result still writes `config: {}` only when
* the spec already had one; baking an empty config into a config-less spec is
* a no-op.
*/
export function mergeConfigIntoSpec(spec: JsonObject, config: JsonObject): JsonObject {
const specConfig = isJsonObject(spec.config) ? spec.config : {};
const merged = deepMerge(config, specConfig);
if (Object.keys(merged).length === 0 && !('config' in spec)) return { ...spec };
return { ...spec, config: merged };
}
/** Result of `extractConfigFromSpec`. */
export interface ExtractedConfig {
/** The spec without its `config` block (new object; input not mutated). */
spec: JsonObject;
/** The removed `config`, or null when the spec had none worth extracting. */
config: JsonObject | null;
}
/**
* Remove the spec's `config` block and hand it back separately. A missing,
* empty, or non-object `config` extracts as `null` (an empty/junk block is
* still removed from the spec there is nothing to keep).
*/
export function extractConfigFromSpec(spec: JsonObject): ExtractedConfig {
if (!('config' in spec)) return { spec: { ...spec }, config: null };
const { config, ...rest } = spec;
const extracted = isJsonObject(config) && Object.keys(config).length > 0 ? config : null;
return { spec: rest, config: extracted };
}
+87 -1
View File
@@ -1,5 +1,17 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { chartConfigFor, darkChartConfig, lightChartConfig } from './vega-themes'; import {
CHART_THEME_OPTIONS,
chartConfigFor,
chartConfigForSelection,
isChartThemeId,
darkBaseConfig,
darkChartConfig,
darkExpressiveConfig,
lightBaseConfig,
lightChartConfig,
lightExpressiveConfig,
mergeChartLayers,
} from './vega-themes';
import type { UiTheme } from './theme'; import type { UiTheme } from './theme';
/** /**
@@ -37,3 +49,77 @@ describe('chartConfigFor', () => {
expect(lightChartConfig.range?.category).not.toEqual(darkChartConfig.range?.category); expect(lightChartConfig.range?.category).not.toEqual(darkChartConfig.range?.category);
}); });
}); });
/**
* The layer split (docs/chart-theming-scope.md §1): base carries only the
* legibility minimum (background + guide colors); everything brand-flavored
* (font, palette, grid dash, sizes/weights, view stroke) lives in expressive.
* A future stock/custom chart style keeps base and swaps expressive.
*/
describe('chart config layers', () => {
const layers = [
{ base: lightBaseConfig, expressive: lightExpressiveConfig, full: lightChartConfig },
{ base: darkBaseConfig, expressive: darkExpressiveConfig, full: darkChartConfig },
];
it.each(layers)('base stays free of house style', ({ base }) => {
expect(base.font).toBeUndefined();
expect(base.range).toBeUndefined();
expect(base.view).toBeUndefined();
expect(base.axis?.gridDash).toBeUndefined();
expect(base.title).not.toHaveProperty('fontSize');
});
it.each(layers)('expressive stays free of legibility colors', ({ expressive }) => {
expect(expressive.background).toBeUndefined();
expect(expressive.axis?.labelColor).toBeUndefined();
expect(expressive.title).not.toHaveProperty('color');
});
it.each(layers)('layers merge into the full theme config', ({ base, expressive, full }) => {
expect(mergeChartLayers(base, expressive)).toEqual(full);
});
it('merges the nested title and axis groups instead of replacing them', () => {
const merged = mergeChartLayers(lightBaseConfig, lightExpressiveConfig);
// One property from each layer survives in the same nested object.
expect(merged.title).toMatchObject({ color: '#161616', fontSize: 16 });
expect(merged.axis).toMatchObject({ labelColor: '#525252', gridDash: [2, 2] });
});
});
describe('chartConfigForSelection', () => {
it('astrolabe follows the UI theme', () => {
expect(chartConfigForSelection('astrolabe', 'light')).toBe(lightChartConfig);
expect(chartConfigForSelection('astrolabe', 'dark')).toBe(darkChartConfig);
});
it('stock injects nothing (vega-lite defaults apply)', () => {
expect(chartConfigForSelection('stock', 'light')).toEqual({});
expect(chartConfigForSelection('stock', 'dark')).toEqual({});
});
it('presets resolve to a non-empty config independent of UI theme', () => {
const light = chartConfigForSelection('fivethirtyeight', 'light');
expect(Object.keys(light).length).toBeGreaterThan(0);
expect(chartConfigForSelection('fivethirtyeight', 'dark')).toBe(light);
});
it('every option id resolves to a config', () => {
for (const { value } of CHART_THEME_OPTIONS) {
expect(chartConfigForSelection(value, 'light')).toBeTruthy();
}
});
});
describe('isChartThemeId', () => {
it('accepts every option id', () => {
for (const { value } of CHART_THEME_OPTIONS) expect(isChartThemeId(value)).toBe(true);
});
it('rejects unknown and non-string values', () => {
expect(isChartThemeId('comic-sans')).toBe(false);
expect(isChartThemeId(undefined)).toBe(false);
expect(isChartThemeId(7)).toBe(false);
});
});
+149 -21
View File
@@ -5,17 +5,29 @@
* visually belong to the app rather than looking like stock Vega-Lite. This is * visually belong to the app rather than looking like stock Vega-Lite. This is
* the single source of truth mapping a `UiTheme` to a config; it is applied at * the single source of truth mapping a `UiTheme` to a config; it is applied at
* embed time (never baked into the user's stored spec). Adding a UI theme = one * embed time (never baked into the user's stored spec). Adding a UI theme = one
* config object plus one map entry here. * base + expressive pair plus one map entry here.
* *
* Values track the design language: IBM Plex font, axis/grid colors from the * Each theme's config is two layers (docs/chart-theming-scope.md §1):
* Carbon neutral ramp (matching `--text-secondary` / `--border`), and a *
* categorical `range.category` palette transcribed from Carbon's data-viz * - **Base** the legibility/integration minimum: transparent background (the
* 14-color pairing (white theme for light, g100 for dark see * pane color shows through) and guide colors readable on the app's surfaces.
* carbon-charts `packages/core/scss/_color-palette.scss`). This is the * Without this layer, stock black-on-white chart text is illegible on the
* expressive "free color" layer (doc §3.5, §5). * dark pane. Colors match the app tokens (`--text`, `--text-secondary`,
* `--border`, `--border-strong`).
* - **Expressive** the house style: IBM Plex, the Carbon data-viz categorical
* palette (white theme for light, g100 for dark see carbon-charts
* `packages/core/scss/_color-palette.scss`), dotted grid, bumped guide
* sizes/weights, no plot border. This is the "free color" layer (doc §3.5,
* §5); charts render fine without it, just stock-looking.
*
* The split exists so a non-house chart style (stock preview, future custom
* themes) can keep the base layer while replacing the expressive one.
*/ */
import type { Config } from 'vega-lite'; import type { Config } from 'vega-lite';
// Preset chart styles from the vega-themes package (already in the dependency
// tree via vega-embed). Pure data — config objects only — so portable for core.
import * as presets from 'vega-themes';
import type { UiTheme } from './theme'; import type { UiTheme } from './theme';
const PLEX = '"IBM Plex Sans", system-ui, -apple-system, sans-serif'; const PLEX = '"IBM Plex Sans", system-ui, -apple-system, sans-serif';
@@ -56,42 +68,74 @@ const darkCategory = [
'#d4bbff', // purple 30 '#d4bbff', // purple 30
]; ];
export const lightChartConfig: Config = { /** Base layer — light: transparent background + guide colors on app tokens. */
export const lightBaseConfig: Config = {
background: 'transparent', background: 'transparent',
font: PLEX, title: { color: '#161616' }, // --text (light)
title: { fontSize: 16, fontWeight: 600, color: '#161616' },
axis: { axis: {
domainColor: '#c6c6c6', // --border-strong (light) domainColor: '#c6c6c6', // --border-strong (light)
gridColor: '#e0e0e0', // --border (light) gridColor: '#e0e0e0', // --border (light)
gridDash: [2, 2],
labelColor: '#525252', // --text-secondary (light) labelColor: '#525252', // --text-secondary (light)
titleColor: '#161616', // --text (light) titleColor: '#161616', // --text (light)
labelFontSize: 11,
titleFontSize: 12,
titleFontWeight: 600,
}, },
range: { category: lightCategory },
view: { stroke: 'transparent' },
}; };
export const darkChartConfig: Config = { /** Base layer — dark: transparent background + guide colors on app tokens. */
export const darkBaseConfig: Config = {
background: 'transparent', background: 'transparent',
font: PLEX, title: { color: '#f4f4f4' }, // --text (dark)
title: { fontSize: 16, fontWeight: 600, color: '#f4f4f4' },
axis: { axis: {
domainColor: '#525252', // --border-strong (dark) domainColor: '#525252', // --border-strong (dark)
gridColor: '#393939', // --border (dark) gridColor: '#393939', // --border (dark)
gridDash: [2, 2],
labelColor: '#a8a8a8', // --text-secondary (dark) labelColor: '#a8a8a8', // --text-secondary (dark)
titleColor: '#f4f4f4', // --text (dark) titleColor: '#f4f4f4', // --text (dark)
},
};
/** Expressive layer parts shared by both themes (everything but the palette). */
const sharedExpressive: Config = {
font: PLEX,
title: { fontSize: 16, fontWeight: 600 },
axis: {
gridDash: [2, 2],
labelFontSize: 11, labelFontSize: 11,
titleFontSize: 12, titleFontSize: 12,
titleFontWeight: 600, titleFontWeight: 600,
}, },
range: { category: darkCategory },
view: { stroke: 'transparent' }, view: { stroke: 'transparent' },
}; };
/** Expressive layer — light: house style + the light categorical palette. */
export const lightExpressiveConfig: Config = {
...sharedExpressive,
range: { category: lightCategory },
};
/** Expressive layer — dark: house style + the dark categorical palette. */
export const darkExpressiveConfig: Config = {
...sharedExpressive,
range: { category: darkCategory },
};
/**
* Merge a base and an expressive layer into one chart config. Shallow spread
* plus the two nested objects both layers contribute to (`title`, `axis`);
* the expressive layer wins on conflicts (there are none today the layers
* own disjoint properties).
*/
export function mergeChartLayers(base: Config, expressive: Config): Config {
return {
...base,
...expressive,
title: { ...base.title, ...expressive.title },
axis: { ...base.axis, ...expressive.axis },
};
}
export const lightChartConfig: Config = mergeChartLayers(lightBaseConfig, lightExpressiveConfig);
export const darkChartConfig: Config = mergeChartLayers(darkBaseConfig, darkExpressiveConfig);
const CHART_CONFIG: Record<UiTheme, Config> = { const CHART_CONFIG: Record<UiTheme, Config> = {
light: lightChartConfig, light: lightChartConfig,
dark: darkChartConfig, dark: darkChartConfig,
@@ -101,3 +145,87 @@ const CHART_CONFIG: Record<UiTheme, Config> = {
export function chartConfigFor(theme: UiTheme): Config { export function chartConfigFor(theme: UiTheme): Config {
return CHART_CONFIG[theme]; return CHART_CONFIG[theme];
} }
/**
* Selectable chart themes (docs/chart-theming-scope.md §4.2) the user-facing
* choice of how charts render, distinct from (and composed with) the UI theme:
*
* - `'astrolabe'` the house style above; resolves per UI theme. Default.
* - `'stock'` no injected config at all: charts render exactly as Vega-Lite
* defaults would anywhere else (white background, tableau10, sans-serif).
* - a `vega-themes` preset id that preset's config verbatim, UI-theme
* independent, exactly as it would render in the Vega editor's theme dropdown.
*
* The spec's own `config` overrides whatever is selected, property by property
* (vega-lite merges `opt.config` under `spec.config`), so a snippet can always
* opt out locally.
*/
export type ChartThemeId = 'astrolabe' | 'stock' | ChartThemePresetId;
/** The vega-themes presets we surface, in display order. */
const PRESET_IDS = [
'excel',
'ggplot2',
'quartz',
'vox',
'fivethirtyeight',
'latimes',
'urbaninstitute',
'googlecharts',
'powerbi',
'carbonwhite',
'carbong10',
'carbong90',
'carbong100',
'dark',
] as const;
export type ChartThemePresetId = (typeof PRESET_IDS)[number];
/** Empty config — the stock sentinel resolves to "inject nothing". */
const STOCK_CONFIG: Config = {};
export interface ChartThemeOption {
value: ChartThemeId;
label: string;
/** Secondary line for pickers (what the choice means). */
detail?: string;
}
/** Display metadata for every selectable chart theme, in display order. */
export const CHART_THEME_OPTIONS: ReadonlyArray<ChartThemeOption> = [
{ value: 'astrolabe', label: 'Astrolabe', detail: 'House style, follows light/dark' },
{ value: 'stock', label: 'Stock Vega-Lite', detail: 'No theme applied' },
{ value: 'excel', label: 'Excel' },
{ value: 'ggplot2', label: 'ggplot2' },
{ value: 'quartz', label: 'Quartz' },
{ value: 'vox', label: 'Vox' },
{ value: 'fivethirtyeight', label: 'FiveThirtyEight' },
{ value: 'latimes', label: 'LA Times' },
{ value: 'urbaninstitute', label: 'Urban Institute' },
{ value: 'googlecharts', label: 'Google Charts' },
{ value: 'powerbi', label: 'Power BI' },
{ value: 'carbonwhite', label: 'Carbon — White' },
{ value: 'carbong10', label: 'Carbon — G10' },
{ value: 'carbong90', label: 'Carbon — G90' },
{ value: 'carbong100', label: 'Carbon — G100' },
{ value: 'dark', label: 'Vega Dark' },
];
const CHART_THEME_IDS: ReadonlySet<string> = new Set(['astrolabe', 'stock', ...PRESET_IDS]);
/** Type guard for persisted values (load-with-fallback; unknown ids fall back). */
export function isChartThemeId(value: unknown): value is ChartThemeId {
return typeof value === 'string' && CHART_THEME_IDS.has(value);
}
/**
* Resolve the user's chart-theme selection to the config to inject at embed
* time. `'astrolabe'` follows the UI theme; presets ignore it (their look is
* fixed that's the point of previewing a destination style).
*/
export function chartConfigForSelection(selection: ChartThemeId, uiTheme: UiTheme): Config {
if (selection === 'astrolabe') return CHART_CONFIG[uiTheme];
if (selection === 'stock') return STOCK_CONFIG;
return presets[selection] as Config;
}
+11 -3
View File
@@ -1,7 +1,12 @@
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { App } from './app/App'; import { App } from './app/App';
import { initPanes, wirePanes } from './app/orchestration/panes'; import { initPanes, wirePanes } from './app/orchestration/panes';
import { initPreviewFitMode, wirePreviewFitMode } from './app/orchestration/preferences'; import {
initChartTheme,
initPreviewFitMode,
wireChartTheme,
wirePreviewFitMode,
} from './app/orchestration/preferences';
import { initSettings, wireSettings } from './app/orchestration/settings'; import { initSettings, wireSettings } from './app/orchestration/settings';
import { initSnippetSort, wireSnippetSort } from './app/orchestration/snippet-sort'; import { initSnippetSort, wireSnippetSort } from './app/orchestration/snippet-sort';
import { initPersistentStorage, registerServiceWorker } from './app/orchestration/pwa'; import { initPersistentStorage, registerServiceWorker } from './app/orchestration/pwa';
@@ -15,10 +20,13 @@ import '../styles/base.css';
initTheme(); initTheme();
wireTheme(); wireTheme();
// Hydrate + persist the small UI preferences (preview fit mode, pane widths) the // Hydrate + persist the small UI preferences (preview fit mode, chart theme,
// same way. Pane widths hydrate before render so the layout opens as left. // pane widths) the same way. Pane widths hydrate before render so the layout
// opens as left.
initPreviewFitMode(); initPreviewFitMode();
wirePreviewFitMode(); wirePreviewFitMode();
initChartTheme();
wireChartTheme();
initPanes(); initPanes();
wirePanes(); wirePanes();