From 44a601affd5c24c9df4896d8324ab5f621aaa840 Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Fri, 12 Jun 2026 16:48:48 +0300 Subject: [PATCH] =?UTF-8?q?Chart=20theming:=20selectable=20chart=20theme?= =?UTF-8?q?=20+=20spec=E2=86=94config=20merge/extract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture/01-state-and-stores.md | 17 +- docs/architecture/02-persistence.md | 11 +- .../05-rendering-theming-preview.md | 116 +++++------- .../10-interaction-and-feedback.md | 16 ++ docs/chart-theming-scope.md | 170 +++++++++++++++++ docs/spec/03-editor-and-drafts.md | 7 + docs/spec/04-live-preview.md | 15 ++ docs/spec/07-settings.md | 1 + docs/spec/09-data-model.md | 3 +- docs/ux-second-pass.md | 13 +- package-lock.json | 1 + package.json | 1 + src/app/components/LivePreview.test.tsx | 56 +++++- src/app/components/LivePreview.tsx | 34 +++- src/app/components/SpecEditor.tsx | 59 +++++- src/app/infrastructure/settings-store.test.ts | 30 +++ src/app/infrastructure/settings-store.ts | 18 +- src/app/orchestration/preferences.ts | 24 ++- src/app/services/spec-config-actions.ts | 175 ++++++++++++++++++ src/app/stores/AppStore.ts | 7 + src/core/settings.test.ts | 7 +- src/core/settings.ts | 11 ++ src/core/spec-config.test.ts | 82 ++++++++ src/core/spec-config.ts | 78 ++++++++ src/core/vega-themes.test.ts | 88 ++++++++- src/core/vega-themes.ts | 170 ++++++++++++++--- src/main.tsx | 14 +- 27 files changed, 1103 insertions(+), 121 deletions(-) create mode 100644 docs/chart-theming-scope.md create mode 100644 src/app/services/spec-config-actions.ts create mode 100644 src/core/spec-config.test.ts create mode 100644 src/core/spec-config.ts diff --git a/docs/architecture/01-state-and-stores.md b/docs/architecture/01-state-and-stores.md index cf4d7ff..d19760c 100644 --- a/docs/architecture/01-state-and-stores.md +++ b/docs/architecture/01-state-and-stores.md @@ -376,7 +376,22 @@ useAppStore.subscribe((s, prev) => { The store stays DOM-free; the adapter (the `applyTheme` subscriber) lives at the edge. -### Debounced auto-save of the draft spec +### Adding a persisted UI preference (the established chain) + +A small global preference (preview fit mode, chart theme) follows one chain — five touch +points, in order: + +1. `core/settings.ts` — field on `UserSettings` + `defaultSettings()` + `loadSettings` + validation (an unknown stored value falls back to the default, never breaks the app). +2. `infrastructure/settings-store.ts` — slice loader/saver pair using the per-slice + write-through merge (doc 02 §5), so other writers of the shared record survive. +3. `stores/AppStore.ts` — field + setter (the store stays browser-free). +4. `orchestration/preferences.ts` — `initX()` hydrates the store from the adapter; + `wireX()` subscribes store → adapter. +5. `main.tsx` — call both before `createRoot().render` (the store is the single source + of truth from first paint). + +The UI control only calls the AppStore setter; persistence follows from the subscriber. The Monaco editor writes every keystroke into `draftSpec`. We do **not** persist on every keystroke. A startup subscriber observes the draft and debounces the expensive diff --git a/docs/architecture/02-persistence.md b/docs/architecture/02-persistence.md index 5eaa647..45878cf 100644 --- a/docs/architecture/02-persistence.md +++ b/docs/architecture/02-persistence.md @@ -243,7 +243,7 @@ Small, frequently-read structured records live in `localStorage`, not IndexedDB: The pattern is **load-with-fallback, per-slice write-through merge.** - **Load-with-fallback:** merge the parsed stored object over a complete `DEFAULTS` constant. Missing keys (a setting added in a later build) and malformed JSON silently fall back to defaults — the app always gets a fully-populated object and never `undefined`-crashes on a new field. For `UserSettings`, the normalization is **pure and lives in `@core/settings` (`loadSettings(raw)` / `defaultSettings()`)** — it clamps ranges and validates enums; the infra adapter is just the thin localStorage reader (`loadUserSettings()` = `loadSettings(readRaw())`). -- **Per-slice write-through merge:** every update reads current, merges in **just its slice**, and writes back. **The one `astrolabe:settings` record has multiple independent writers**, because settings are distributed and live-applied (spec §07 — no central save, no Apply step): the header theme toggle writes `ui.theme`, the preview Fit control writes `ui.previewFitMode`, and the per-pane settings clusters write `editor`/`performance`/`formatting`. **A writer that replaced the whole record would clobber the slices it doesn't own** — so each must field-merge. (There is deliberately no whole-record `saveSettings`.) +- **Per-slice write-through merge:** every update reads current, merges in **just its slice**, and writes back. **The one `astrolabe:settings` record has multiple independent writers**, because settings are distributed and live-applied (spec §07 — no central save, no Apply step): the header theme toggle writes `ui.theme`, the preview Fit control writes `ui.previewFitMode`, the preview Chart-theme picker writes `ui.chartTheme`, and the per-pane settings clusters write `editor`/`performance`/`formatting`. **A writer that replaced the whole record would clobber the slices it doesn't own** — so each must field-merge. (There is deliberately no whole-record `saveSettings`.) - **Environment-guarded:** `localStorage` is absent or throws in some test/SSR contexts; guard access and degrade to defaults rather than throwing. ```ts @@ -263,7 +263,11 @@ export interface UserSettings { tabSize: number; }; performance: { renderDebounce: number }; - ui: { theme: 'light' | 'dark'; previewFitMode: 'default' | 'width' | 'height' | 'full' }; + ui: { + theme: 'light' | 'dark'; + previewFitMode: 'default' | 'width' | 'height' | 'full'; + chartTheme: ChartThemeId; // 'astrolabe' | 'stock' | vega-themes preset id + }; formatting: { dateFormat: 'smart' | 'iso' | 'custom'; customDateFormat: string }; } @@ -280,7 +284,7 @@ const DEFAULTS: UserSettings = { tabSize: 2, }, performance: { renderDebounce: 1500 }, - ui: { theme: 'light', previewFitMode: 'default' }, + ui: { theme: 'light', previewFitMode: 'default', chartTheme: 'astrolabe' }, formatting: { dateFormat: 'smart', customDateFormat: '' }, }; @@ -324,6 +328,7 @@ export function loadSettings(): UserSettings { // record, so the others survive (see the per-slice rule above): // saveUiTheme(theme) -> { ...current, ui: { ...current.ui, theme } } // savePreviewFitMode(mode) -> { ...current, ui: { ...current.ui, previewFitMode } } +// saveChartTheme(chartTheme) -> { ...current, ui: { ...current.ui, chartTheme } } // saveManagedSettings(managed) -> { ...current, editor, performance, formatting } ``` diff --git a/docs/architecture/05-rendering-theming-preview.md b/docs/architecture/05-rendering-theming-preview.md index 757e41b..fbe60aa 100644 --- a/docs/architecture/05-rendering-theming-preview.md +++ b/docs/architecture/05-rendering-theming-preview.md @@ -150,83 +150,66 @@ async function rerender(node: HTMLElement, spec: TopLevelSpec, config: Config) { ## 3. Theme Follows the UI Theme A Vega-Lite **config** object styles every chart globally — fonts, axis colors, -background, the categorical color range, default mark colors. Astrolabe ships one -config per UI theme so charts visually belong to the app rather than looking like -stock Vega-Lite. +background, the categorical color range. Astrolabe ships one config per UI theme +so charts visually belong to the app rather than looking like stock Vega-Lite. +`src/core/vega-themes.ts` is the single source of truth; each house config is +**two merged layers** (the full audit and forward plan live in +[`docs/chart-theming-scope.md`](../chart-theming-scope.md)): -```ts -// src/core/vega-themes.ts (sketch) -import type { Config } from 'vega-lite'; +- **Base** (`lightBaseConfig`/`darkBaseConfig`) — the legibility minimum: + `background: 'transparent'` (the pane shows through) plus guide colors on the + app's text/border tokens. Without it, stock black-on-white chart text is + illegible on the dark pane. +- **Expressive** (`lightExpressiveConfig`/`darkExpressiveConfig`) — the house + style: IBM Plex, the Carbon data-viz 14-color categorical palette, dotted + grid, bumped guide sizes/weights, no plot border. -export const lightChartConfig: Config = { - background: 'transparent', - font: '"Inter", sans-serif', - title: { fontSize: 15, fontWeight: 600, color: '#1c1c1e' }, - axis: { - domainColor: '#1c1c1e', - gridColor: '#e4e4e7', - gridDash: [3, 3], - labelColor: '#52525b', - titleColor: '#1c1c1e', - labelFontSize: 11, - titleFontSize: 12, - }, - range: { - category: ['#2f6df6', '#f5a524', '#17b890', '#e5484d', '#8b5cf6', '#0ea5e9'], - }, - view: { stroke: 'transparent' }, -}; +`mergeChartLayers(base, expressive)` produces `lightChartConfig`/ +`darkChartConfig`, and `chartConfigFor(uiTheme)` is the one UI-theme → config +mapping. The split exists so a non-house style can keep the base layer while +swapping the expressive one (future custom themes). -export const darkChartConfig: Config = { - background: 'transparent', - font: '"Inter", sans-serif', - title: { fontSize: 15, fontWeight: 600, color: '#f4f4f5' }, - axis: { - domainColor: '#a1a1aa', - gridColor: '#3f3f46', - gridDash: [3, 3], - labelColor: '#a1a1aa', - titleColor: '#f4f4f5', - labelFontSize: 11, - titleFontSize: 12, - }, - range: { - category: ['#5b8def', '#f5a524', '#2dd4a7', '#f0666b', '#a78bfa', '#38bdf8'], - }, - view: { stroke: 'transparent' }, -}; -``` +### Selectable chart themes -One mapping, in one place, is the single source of truth for theme → config: +On top of the house pair, the user picks a **chart theme** (spec §04 → Chart +theme) — `ChartThemeId = 'astrolabe' | 'stock' | `: -```ts -// src/core/vega-themes.ts -import type { UiTheme } from './theme'; // core-local — never import from src/app +- `'astrolabe'` resolves via `chartConfigFor(uiTheme)` (follows light/dark); +- `'stock'` resolves to `{}` — nothing injected, pure Vega-Lite defaults; +- preset ids resolve to the `vega-themes` package's configs verbatim (the same + presets as the Vega editor's theme dropdown; the package is already in the + tree as a vega-embed dependency). -const CHART_CONFIG: Record = { - light: lightChartConfig, - dark: darkChartConfig, -}; +`chartConfigForSelection(selection, uiTheme)` is the only resolver. The choice +lives in `AppStore.chartTheme`, persisted as `ui.chartTheme` by +`orchestration/preferences.ts` (the `previewFitMode` pattern), and is surfaced +by a `SelectControl` in the LivePreview header — **not** inside the +PreviewSettings popover: `SelectControl` and `SettingsPopover` share the +one-open-popover registry, so a select nested in the popover would close (and +unmount) its own parent on open. -export function chartConfigFor(theme: UiTheme): Config { - return CHART_CONFIG[theme]; -} -``` - -The renderer reads the active UI theme (from the store) and passes the matching config into -`renderSpec`. When the theme changes, the same subscriber that drives -re-rendering picks up the new config and the chart restyles automatically. +Render-time precedence: vega-lite merges the injected config **under** the +spec's own `config` (`mergeConfig(opt.config, spec.config)` — the spec wins +key-by-key), so a snippet can always override or opt out locally. The +`core/spec-config.ts` merge/extract operations (spec §03G) move styling across +that boundary deliberately: merge bakes the selected theme into `spec.config` +(spec keys win — rendering unchanged), extract lifts `spec.config` out. ### Rules -- **Do** keep `chartConfigFor` as the _only_ place that maps a UI theme to a Vega - config. Adding a UI theme = adding one config and one map entry. -- **Do** set chart `background: 'transparent'` so the pane's own background shows - through and theme switches look seamless. +- **Do** keep `chartConfigForSelection` as the _only_ place that maps the user's + selection (and UI theme) to a Vega config. +- **Do** set chart `background: 'transparent'` in the house configs so the + pane's own background shows through and theme switches look seamless. Preset + themes carry their own backgrounds (often white) and render as their authors + intended — honest preview beats pane-matching. +- **Do** keep the Chart Builder preview and onboarding thumbnails on + `chartConfigFor(uiTheme)` — they are app surfaces, not destination previews. - **Don't** inline colors or fonts into individual specs to "match the theme" — that is the config's job, and per-spec styling drifts from the app. -- **Don't** let the user's stored spec carry a `config`; the theme config is - applied at embed time via the embed options, leaving the spec theme-agnostic. +- **Don't** write the injected config into the user's stored spec implicitly; + it is applied at embed time, leaving the spec theme-agnostic. Baking it in is + the explicit, user-invoked merge action only. ### Theme flow (end to end) @@ -237,7 +220,8 @@ Theme spans several layers; the path is: (localStorage `ui.theme`). On load, `initTheme()` — called from `main.tsx` **before** `createRoot().render` — hydrates the saved theme. Chart and editor follow by subscribing to `uiTheme`: `LivePreview` re-embeds with -`chartConfigFor(theme)`, `SpecEditor` sets the Monaco theme. UI chrome repaints +`chartConfigForSelection(chartTheme, uiTheme)`, `SpecEditor` sets the Monaco +theme. UI chrome repaints purely from the `[data-theme]` token swap in `styles/tokens.css`. The header `ThemeToggle` is the user control. diff --git a/docs/architecture/10-interaction-and-feedback.md b/docs/architecture/10-interaction-and-feedback.md index f390ca3..ff88f6d 100644 --- a/docs/architecture/10-interaction-and-feedback.md +++ b/docs/architecture/10-interaction-and-feedback.md @@ -429,9 +429,25 @@ Arrow/Home/End rove). The selected option carries `aria-current` and a visible colour alone. The same control doubles as an **action picker** (no `value`; e.g. "Add field to which channel?"). A custom `triggerClassName` _replaces_ the default trigger styling, so chip-styled triggers (the pill's type chip, the shelf's field chips) stay chips. +The single-open registry means **disclosures cannot nest**: a SelectControl inside a +settings popover would close — and unmount — its own parent on open. A control that needs +its own popover sits beside the gear in the pane header, never inside the panel. _(Consulted via /council → WAI-ARIA APG disclosure/menu-button/radio, Carbon, NN/g #4. This bullet is the contract; cite it, not the source.)_ +**Resolved — editor commands need a visible home; hidden surfaces are accelerators only.** +A command that exists _only_ in Monaco's right-click context menu or F1 palette is +undiscoverable (NN/g #6 recognition-over-recall — those surfaces demand the user already +know the command exists). Every editor command gets a **visible toolbar home**; when the +toolbar can't afford a dedicated button (Carbon menu-buttons: "use an overflow menu when +additional options are available and there is a space constraint"), the home is a +SelectControl **action picker** grouping related commands (e.g. the spec editor's _Config_ +menu: merge chart theme / extract config), with `detail` lines saying what each does. +Context-menu and palette registrations stay, as the NN/g #7 expert accelerators, but they +call the same functions as the visible control — one code path, two doors. +_(Consulted via /council → NN/g #6/#7, Carbon menu-buttons/overflow-menu. This bullet is +the contract; cite it, not the source.)_ + **Resolved — field→channel assignment: explicit choice, visible armed state.** Clicking a shelf field with no channel armed opens an explicit **channel chooser** (the channels that accept the field; an occupied one is labelled with what it replaces) — never a silent diff --git a/docs/chart-theming-scope.md b/docs/chart-theming-scope.md new file mode 100644 index 0000000..1d5dc53 --- /dev/null +++ b/docs/chart-theming-scope.md @@ -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(' 11px "Family"')` per used face **before** +`renderSpec`. Once loaded, SVG view, canvas view, and PNG export all work for free. +**Known limitation:** SVG _export_ carries only the family name — a viewer without the +font sees fallback (industry standard; data-URI `@font-face` embedding is a heavy +maybe-later). + +**Shipped roster (self-hosted, no CDN — same `@fontsource` mechanism as Plex).** +Measured latin woff2 sizes (jsdelivr, 2026-06-12): regular text faces run **13–25KB per +weight**; handwriting (Caveat) ~50KB. A ~9-family roster at ~2 weights ≈ **400–450KB +latin**. All-subsets multiplier ≈ 3–4× (Inter: 87KB all-subsets vs 23KB latin, one +weight). Current dist is 7.8MB with 412KB of Plex — the roster roughly doubles font +payload; acceptable. + +Candidate roster (final pick deserves a visual specimen pass, not a chat decision): + +| Role | Faces (weights) | +| --------------------- | ----------------------------------------------------------------------------------------------------- | +| Already shipped, free | IBM Plex Sans, IBM Plex Mono | +| Dataviz sans | Inter (400/600), Roboto Condensed (400/600), Libre Franklin (400/600) | +| Brand-coherent | IBM Plex Serif (400/600), IBM Plex Sans Condensed (400/600) | +| Editorial serif | Source Serif 4 _or_ Spectral (400/600) | +| Exotic / display | Space Grotesk (400/600), Playfair Display (400/700), Caveat (400/600, "sketch"), Space Mono (400/700) | + +**Subset/precache strategy:** chart fonts are decoration with automatic per-glyph +fallback (`unicode-range`), not app capability — non-latin data labels falling back to +the system font is degraded styling, not a broken app (contrast the Plex Cyrillic +lesson, which was UI capability). Plan: **ship all subsets in dist** (~1.2–1.5MB dist +growth), **precache latin only** (~+400KB), runtime-cache the remaining subsets +same-origin (CacheFirst) so a used subset persists offline after first render. + +**User-loaded fonts (the branding case — primary).** Real brand fonts are licensed and +usually _not_ on Google Fonts. Path: upload woff2/ttf → bytes in IndexedDB (the +datasets persistence pattern) → `new FontFace(family, bytes)` + `document.fonts.add()` +at startup and before render. Fully local, offline-native, no privacy question. + +**Google Fonts CDN tier — deferred, opt-in only.** Verified: keyless catalog at +`fonts.google.com/metadata/fonts` (1,936 families; a names-only list is ~30KB raw, so +the _picker_ can ship static and offline), CSS2 endpoint live, Workbox CacheFirst on +`fonts.gstatic.com` makes a chosen font offline after first use. Tension: `base.css` +says fonts are "never a CDN", and font requests expose the user's IP to Google. If this +ships, it is an explicit per-font user action, never automatic. + +## 4. Build order + +1. **Layer split** ✅ (refactor, no visible change) — `vega-themes.ts` is base + + expressive per UI theme, merged into the existing exports via `mergeChartLayers`. +2. **Preview theme selector** ✅ — global pref `ui.chartTheme`: **Astrolabe** (follows + UI theme, default) · **Stock Vega-Lite** (empty config) · all 14 vega-themes presets · + (later) custom themes. Governs LivePreview **and export** (same view). Resolved + design points: the control is a `SelectControl` in the **preview header** (a select + nested inside PreviewSettings would close its own parent — SelectControl and + SettingsPopover share the one-open-popover registry); Onboarding/Chart-Builder + previews stay house-styled; preset/stock backgrounds render verbatim (a white chart + card on the dark pane is an honest destination preview). +3. **Merge/extract config** ✅ — `core/spec-config.ts` (`mergeConfigIntoSpec`, + `extractConfigFromSpec`), surfaced as the editor toolbar's **Config** menu + (SelectControl action picker — council: NN/g #6, Carbon overflow; arch 10 §5 records + the rule) with Monaco context-menu/palette as accelerators on the same functions + (spec §03G): bake the active theme into `spec.config` (existing keys win, + render-identical), or lift `spec.config` out to the clipboard (copy before remove — + a failed copy aborts). +4. **Custom named themes** — 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 2–3)** — **theme selector + merge/extract shipped.** + `ChartThemeId`/`chartConfigForSelection` in core; `ui.chartTheme` persisted via the + `previewFitMode` orchestration pattern; `SelectControl` picker in the preview header; + LivePreview renders (and therefore exports) with the selection; `core/spec-config.ts` + merge/extract behind two Monaco editor actions. Spec updated (§03G, §04 Chart theme, + §07, §09C) + architecture 05 §3 rewritten to the layered/selectable model. Verified: + typecheck, eslint, full tests (802), build. Custom named themes (slice 4) and fonts + (5–6) remain. +- **2026-06-12** — scope written; audit, vega-editor read, font research done (numbers + above). Slice 1 (layer split) implemented. diff --git a/docs/spec/03-editor-and-drafts.md b/docs/spec/03-editor-and-drafts.md index d104414..5a6c219 100644 --- a/docs/spec/03-editor-and-drafts.md +++ b/docs/spec/03-editor-and-drafts.md @@ -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. - 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_. + +## 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. diff --git a/docs/spec/04-live-preview.md b/docs/spec/04-live-preview.md index 8681f14..c47bc09 100644 --- a/docs/spec/04-live-preview.md +++ b/docs/spec/04-live-preview.md @@ -36,6 +36,21 @@ Behavior of the selected mode: - The selected mode persists across sessions, stored in _Settings_ as `previewFitMode`. - 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 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. diff --git a/docs/spec/07-settings.md b/docs/spec/07-settings.md index 753e2c8..cd89323 100644 --- a/docs/spec/07-settings.md +++ b/docs/spec/07-settings.md @@ -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: - **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_. ## Behaviors diff --git a/docs/spec/09-data-model.md b/docs/spec/09-data-model.md index b7c7689..57dcb63 100644 --- a/docs/spec/09-data-model.md +++ b/docs/spec/09-data-model.md @@ -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. | | `ui.theme` | string | App theme: `light` or `dark`. | | `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.customDateFormat` | string | Pattern used when `dateFormat = custom`. | 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) diff --git a/docs/ux-second-pass.md b/docs/ux-second-pass.md index 3b761f5..082d9db 100644 --- a/docs/ux-second-pass.md +++ b/docs/ux-second-pass.md @@ -8,11 +8,14 @@ record the resolution into the contract (`docs/architecture/09`+`10` and the rel ## Open -_(none — the 2026-06-12 batch resolved all parked items: type-cycle chip → direct-pick -SelectControl; field-assignment flow → explicit channel chooser + visible armed state, drag -still deferred; "or constant" → "Use a constant" ghost button; chart-level controls → -properties strip under the preview. Resolutions recorded in `architecture/10` §5 and -`spec/06`.)_ +- **Chart theme picker placement & header crowding** (`LivePreview.tsx` — ChartThemeControl). + The picker sits in the preview header because nesting a SelectControl inside the + PreviewSettings popover is impossible today (one-open-popover registry: the select would + close/unmount its own parent). Header now holds Fit + theme + export + gear; at narrow + 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) diff --git a/package-lock.json b/package-lock.json index 5b96e37..a974b78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "vega": "^6.2.0", "vega-embed": "^7.1.0", "vega-lite": "^6.4.2", + "vega-themes": "3.0.0", "zustand": "^5.0.14" }, "devDependencies": { diff --git a/package.json b/package.json index bb335ff..b757c3c 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "vega": "^6.2.0", "vega-embed": "^7.1.0", "vega-lite": "^6.4.2", + "vega-themes": "3.0.0", "zustand": "^5.0.14" }, "devDependencies": { diff --git a/src/app/components/LivePreview.test.tsx b/src/app/components/LivePreview.test.tsx index ccbdd7c..93392c6 100644 --- a/src/app/components/LivePreview.test.tsx +++ b/src/app/components/LivePreview.test.tsx @@ -10,6 +10,8 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { act } from 'react'; 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 { useSnippetStore } from '../stores/SnippetStore'; import { useDatasetStore } from '../stores/DatasetStore'; @@ -24,10 +26,12 @@ const H = vi.hoisted(() => ({ calls: 0, pending: [] as Array<() => void>, destroyed: [] as number[], + configs: [] as unknown[], })); vi.mock('../services/chart-renderer', () => ({ - renderSpec: (node: HTMLElement) => { + renderSpec: (node: HTMLElement, _spec: unknown, config: unknown) => { const id = ++H.calls; + H.configs.push(config); return new Promise((resolve) => { H.pending.push(() => { node.replaceChildren(); // a real embed wipes then rebuilds the host @@ -64,6 +68,7 @@ beforeEach(() => { H.calls = 0; H.pending.length = 0; H.destroyed.length = 0; + H.configs.length = 0; usePreviewStore.setState({ error: null, busy: false }); useSnippetStore.getState().reset(); useDatasetStore.getState().reset(); @@ -82,21 +87,27 @@ afterEach(() => { }); 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', () => { // 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', () => { 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', () => { act(() => usePreviewStore.setState({ busy: true })); - const label = container.querySelector('[aria-hidden="true"]')?.textContent; - expect(label).toMatch(/rendering/i); + expect(overlay()?.textContent).toMatch(/rendering/i); }); 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', () => { act(() => usePreviewStore.setState({ busy: true })); - expect(container.querySelector('[aria-hidden="true"]')).not.toBeNull(); + expect(overlay()).not.toBeNull(); 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' })); + } + }); +}); diff --git a/src/app/components/LivePreview.tsx b/src/app/components/LivePreview.tsx index b2991a0..1602733 100644 --- a/src/app/components/LivePreview.tsx +++ b/src/app/components/LivePreview.tsx @@ -20,7 +20,7 @@ import { useShallow } from 'zustand/react/shallow'; import type { VisualizationSpec } from 'vega-embed'; import type { FitMode } 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 { useAppStore } from '../stores/AppStore'; import { useDatasetStore } from '../stores/DatasetStore'; @@ -29,6 +29,7 @@ import { selectShownText, useSnippetStore } from '../stores/SnippetStore'; import { useUserSettingsStore } from '../stores/UserSettingsStore'; import { ChartExport } from './ChartExport'; import { SegmentedControl, type SegmentedOption } from './SegmentedControl'; +import { SelectControl } from './SelectControl'; import { RangeControl, SettingRow, SettingsPopover } from './SettingsPopover'; 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 ( + + ); +} + /** Preview settings cluster (spec §07 → Performance), disclosed beside Fit. */ function PreviewSettings() { const renderDebounce = useUserSettingsStore((s) => s.saved.performance.renderDebounce); @@ -100,6 +125,7 @@ export function LivePreview() { const shownText = useSnippetStore(selectShownText); const fitMode = useAppStore((s) => s.previewFitMode); const uiTheme = useAppStore((s) => s.uiTheme); + const chartTheme = useAppStore((s) => s.chartTheme); // Datasets feed reference resolution (spec §04 step 1). Re-rendering on a // dataset change keeps a referencing chart live as its data is edited. const datasets = useDatasetStore(useShallow((s) => s.datasets)); @@ -212,7 +238,7 @@ export function LivePreview() { try { const prepared = prepareSpecForRender(parsed, { fitMode, datasets }); - const config = chartConfigFor(uiTheme); + const config = chartConfigForSelection(chartTheme, uiTheme); handleRef.current?.destroy(); handleRef.current = null; const handle = await renderSpec(node, prepared as VisualizationSpec, config); @@ -259,6 +285,7 @@ export function LivePreview() { shownText, fitMode, uiTheme, + chartTheme, datasets, setError, setBusy, @@ -321,8 +348,9 @@ export function LivePreview() {
- {/* Right cluster: export this chart, then the preview settings gear. */} + {/* Right cluster: chart theme, export this chart, then the settings gear. */}
+
diff --git a/src/app/components/SpecEditor.tsx b/src/app/components/SpecEditor.tsx index 563b88a..fd63f77 100644 --- a/src/app/components/SpecEditor.tsx +++ b/src/app/components/SpecEditor.tsx @@ -13,7 +13,7 @@ * 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 // (folding, suggest widget, word operations like Cmd+Backspace, find, bracket // 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 { configureJsonFormatter, installFormatOnPaste } from '../infrastructure/monaco-format'; import { openModal } from '../modals/ModalCoordinator'; +import { + installSpecConfigActions, + runExtractConfig, + runMergeChartTheme, +} from '../services/spec-config-actions'; import { useAppStore } from '../stores/AppStore'; import { confirm } from '../stores/ConfirmStore'; import { hasInlineData } from '../stores/ExtractStore'; @@ -35,6 +40,7 @@ import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores import { useUserSettingsStore } from '../stores/UserSettingsStore'; import { Icon } from './Icon'; import { SegmentedControl, type SegmentedOption } from './SegmentedControl'; +import { SelectControl } from './SelectControl'; import { NumberControl, RangeControl, @@ -139,7 +145,30 @@ configureVegaLiteJson(); // Register the compact JSON formatter once (Format Document + format-on-paste, §03A). configureJsonFormatter(); -function EditorToolbar() { +/** The two spec↔config 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; +}) { const activeId = useSnippetStore((s) => s.activeSnippetId); const editorView = useSnippetStore((s) => s.editorView); const setEditorView = useSnippetStore((s) => s.setEditorView); @@ -161,6 +190,13 @@ function EditorToolbar() { // the button and the Cmd/Ctrl+S shortcut (EventRouter) behave identically. 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 ok = await confirm({ title: 'Revert draft', @@ -207,6 +243,17 @@ function EditorToolbar() { Extract to Dataset )} +