Chart theming: custom named themes + Theme Builder

This commit is contained in:
2026-06-12 18:15:54 +03:00
parent 44a601affd
commit b193464f55
32 changed files with 2220 additions and 70 deletions
+19 -2
View File
@@ -91,6 +91,19 @@ export function openDB(): Promise<IDBDatabase> {
} }
``` ```
**Verify the layout, don't trust the version.** An interrupted upgrade can stamp
the new version without creating the new stores (observed in dev: a hot reload
opened a bumped `DB_VERSION` before the store-creation code for it existed) —
after which `onupgradeneeded` never fires again for that version and every
transaction on the missing store throws `NotFoundError`, permanently. The real
`openDB` therefore checks `db.objectStoreNames` against the expected store list
after every successful open and, if anything is missing, closes and reopens at
`db.version + 1` to force another (idempotent) upgrade pass. Two consequences:
the database **self-heals** instead of being stuck until manually deleted, and
the on-disk version may run **ahead of** `DB_VERSION` — so the open also
catches `VersionError` and retries without an explicit version. Covered by
`db.test.ts` (fake-indexeddb).
### 2.2 Promise-wrapped CRUD helpers ### 2.2 Promise-wrapped CRUD helpers
Wrap a single IDB request and a whole transaction so callers write linear `async/await` code. Wrap a single IDB request and a whole transaction so callers write linear `async/await` code.
@@ -478,5 +491,9 @@ spec §08 "no partial import is committed" contract holds and the user gets an a
3. Add the object store in `openDB`'s `onupgradeneeded`, guarded by `contains(...)`; bump `DB_VERSION` only if you changed store _layout_. 3. Add the object store in `openDB`'s `onupgradeneeded`, guarded by `contains(...)`; bump `DB_VERSION` only if you changed store _layout_.
4. Add a `migrate<Entity>()` function and call it on every read. 4. Add a `migrate<Entity>()` function and call it on every read.
5. Expose typed `load*/save*/ensure*` functions from one infrastructure module — and from _only_ there. 5. Expose typed `load*/save*/ensure*` functions from one infrastructure module — and from _only_ there.
6. If the tier has a budget, hook it into the storage monitor and propagate `QuotaExceededError`. 6. Add the app layer: a Zustand store whose low-level `add`/`update`/`remove` are the single mutation point for the collection, and a diffing **write-through subscriber** in `orchestration/` (the `dataset-persistence.ts` shape: compare the array against the previous snapshot, upsert changed records, delete missing ones, toast on failure).
7. Test the adapter against `fake-indexeddb` / a localStorage stub; test the migration with fixtures from each historical version. 7. Hydrate in `orchestration/startup.ts` and wire the subscriber **after** hydrate — wiring first would re-save every loaded record on each startup.
8. If the tier has a budget, hook it into the storage monitor and propagate `QuotaExceededError`.
9. Test the adapter against `fake-indexeddb` / a localStorage stub; test the migration with fixtures from each historical version.
The stack for one entity is four files with fixed roles: `infrastructure/<entity>-store.ts` (typed IDB adapter) + `infrastructure/<entity>-migrations.ts` (read-time upgrade) + `stores/<Entity>Store.ts` (in-memory collection + feature state) + `orchestration/<entity>-persistence.ts` (write-through), joined in `startup.ts`. Snippets, datasets, and custom themes each follow it.
@@ -172,21 +172,62 @@ swapping the expressive one (future custom themes).
### Selectable chart themes ### Selectable chart themes
On top of the house pair, the user picks a **chart theme** (spec §04 → Chart On top of the house pair, the user picks a **chart theme** (spec §04 → Chart
theme) — `ChartThemeId = 'astrolabe' | 'stock' | <vega-themes preset id>`: theme) — `ChartThemeSelection = 'astrolabe' | 'stock' | <vega-themes preset id>
| 'custom:<id>'`:
- `'astrolabe'` resolves via `chartConfigFor(uiTheme)` (follows light/dark); - `'astrolabe'` resolves via `chartConfigFor(uiTheme)` (follows light/dark);
- `'stock'` resolves to `{}` — nothing injected, pure Vega-Lite defaults; - `'stock'` resolves to `{}` — nothing injected, pure Vega-Lite defaults;
- preset ids resolve to the `vega-themes` package's configs verbatim (the same - 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 presets as the Vega editor's theme dropdown; the package is already in the
tree as a vega-embed dependency). tree as a vega-embed dependency);
- `custom:<id>` resolves to a saved `CustomTheme` record's config (spec §09G).
Selection is keyed by record **id**, not name, so a rename never invalidates
the persisted preference; a missing record (themes hydrate async from
IndexedDB; the record may be deleted) resolves to the house config rather
than rendering unstyled, and deleting the actively-selected theme resets
`AppStore.chartTheme` to `'astrolabe'` (CustomThemeStore.remove).
`chartConfigForSelection(selection, uiTheme)` is the only resolver. The choice `chartConfigForSelection(selection, uiTheme, customThemes)` is the only
lives in `AppStore.chartTheme`, persisted as `ui.chartTheme` by resolver; `chartThemeOptions(customThemes)` derives the full picker list
`orchestration/preferences.ts` (the `previewFitMode` pattern), and is surfaced (built-ins, customs, presets — memoize the call: it returns a fresh array). The
by a `SelectControl` in the LivePreview header — **not** inside the choice lives in `AppStore.chartTheme`, persisted as `ui.chartTheme` by
PreviewSettings popover: `SelectControl` and `SettingsPopover` share the `orchestration/preferences.ts` (the `previewFitMode` pattern; persistence
one-open-popover registry, so a select nested in the popover would close (and validates with `isChartThemeSelection`, which accepts `custom:<id>` on shape
unmount) its own parent on open. alone), 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. The "Edit themes…" action row opens the
Theme Builder without changing the selection (the VS Code theme-picker
pattern); it closes the custom-themes block — after the built-ins, **before**
the long preset roster — so it's visible without scrolling and sits next to
the entries it manages.
### Custom themes & the Theme Builder
`CustomTheme` records (`core/custom-theme.ts`) persist in their own IndexedDB
store through the standard stack: `infrastructure/theme-store.ts` (+ read-time
`theme-migrations.ts`), `stores/CustomThemeStore.ts` (the themes array plus the
builder's draft state), and `orchestration/theme-persistence.ts` (diffing
write-through, wired after hydrate in `startup.ts`) — the exact dataset
pattern, one tier each.
The Theme Builder modal (`ThemeBuilderModal`, registered as `themeBuilder`,
xlarge shell, no backdrop dismissal) edits a **draft** held in the store:
`{ name, configText }` plus `draftConfig` — the last text state that parsed.
The gallery (`core/theme-preview-specs.ts`, fixed inline-data swatch specs)
renders `draftConfig` per card through the shared `renderSpec` with the
**canvas** renderer and a per-card debounce + chain-lock (the LivePreview
serialization pattern, one lock per card) — so invalid JSON mid-edit never
blanks the preview, and seven concurrent embeds never interleave on a node.
`applyFontToConfig(config, family)` is the font control's transform: it sets
the top-level `font` and rewrites every `font`/`*Font` string slot at any
depth — explicit slots would otherwise keep overriding the new default.
Creation paths: the builder's "New theme" duplicates the currently selected
chart theme's resolved config, and the editor's **Extract Config to New
Theme** action (`runExtractConfigToTheme`, spec §03G) lifts a spec's `config`
block into a theme, selects it, and removes the block — the spec-to-library
direction of the same boundary the merge action crosses the other way.
Render-time precedence: vega-lite merges the injected config **under** the Render-time precedence: vega-lite merges the injected config **under** the
spec's own `config` (`mergeConfig(opt.config, spec.config)` — the spec wins spec's own `config` (`mergeConfig(opt.config, spec.config)` — the spec wins
+27 -3
View File
@@ -142,9 +142,17 @@ ships, it is an explicit per-font user action, never automatic.
(spec §03G): bake the active theme into `spec.config` (existing keys win, (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 — render-identical), or lift `spec.config` out to the clipboard (copy before remove —
a failed copy aborts). a failed copy aborts).
4. **Custom named themes** — new IndexedDB entity `{ name, config, fonts? }` + list UI; 4. **Custom named themes** ✅ (2026-06-12, except export/import) — IndexedDB entity
created by duplicating a preset or extract-from-spec; appears in the slice-2 `{ id, name, config }` (`core/custom-theme.ts`, themes store @ DB v2) + the **Theme
selector. Export/import as JSON alongside the library. Builder** modal: theme list, JSON config editor, a font control that populates one
family across every font slot (`applyFontToConfig`), and a live multi-chart gallery
(`core/theme-preview-specs.ts`) so one edit is previewed across titles, axes,
legends, headers, and the major marks. Created by duplicating the currently-selected
theme (house/preset/custom) or via the editor's **Extract Config to New Theme**
action (spec §03G); appears in the selector as `custom:<id>` (the "Edit themes…"
action row sits right after the customs, before the preset roster); deleting the
active one falls back to Astrolabe. **Remaining:** export/import as JSON alongside
the library (touches the §08 envelope + import-normalize atomicity).
5. **Shipped font roster** — fontsource packages, `@font-face` registration, selector 5. **Shipped font roster** — fontsource packages, `@font-face` registration, selector
metadata (which themes/fonts pair), `document.fonts.load` gate in the render path, metadata (which themes/fonts pair), `document.fonts.load` gate in the render path,
precache strategy above. Roster finalized via visual specimen. precache strategy above. Roster finalized via visual specimen.
@@ -158,6 +166,22 @@ it without a second mechanism).
## 5. Status log ## 5. Status log
- **2026-06-12 (slice 4)** — **custom named themes + Theme Builder shipped.**
`CustomTheme` entity through the full stack (core → theme-store @ DB v2 →
CustomThemeStore → theme-persistence → startup hydrate); selection model extended to
`custom:<id>` with missing-record fallback to the house style; Theme Builder modal
(xlarge, list + name + config JSON + font-apply control + 7-card live gallery, canvas
renderer, per-card chain-lock); picker gains custom entries + an "Edit themes…"
action row. The font control ships with render-safe faces only (Plex + web-safe
stacks) — the roster slice (5) extends `THEME_FONT_OPTIONS` and adds the
`document.fonts.load` gate. Spec updated (§01C, §04 Chart theme + Theme Builder,
§09C/E/G) + architecture 05 §3. Same-day follow-ups from first use: `openDB` now
verifies the store layout and self-heals an interrupted upgrade (arch 02 §2.1,
`db.test.ts` on fake-indexeddb); "Edit themes…" moved before the preset roster
(discoverability); **Extract Config to New Theme** added as the third Config-menu
action (spec §03G) — config block → saved theme, selected, removed from the spec.
Not yet done: themes in the §08 export/import envelope.
- **2026-06-12 (slices 23)** — **theme selector + merge/extract shipped.** - **2026-06-12 (slices 23)** — **theme selector + merge/extract shipped.**
`ChartThemeId`/`chartConfigForSelection` in core; `ui.chartTheme` persisted via the `ChartThemeId`/`chartConfigForSelection` in core; `ui.chartTheme` persisted via the
`previewFitMode` orchestration pattern; `SelectControl` picker in the preview header; `previewFitMode` orchestration pattern; `SelectControl` picker in the preview header;
+3 -3
View File
@@ -46,12 +46,12 @@ Notes:
## C. Modal System ## C. Modal System
The app shows at most one modal at a time. The modal set is: Datasets, About & Help, Donate, Chart Builder, and Extract-to-Dataset. (Settings are deliberately _not_ a modal — they are distributed to per-pane controls; see _Settings_.) The app shows at most one modal at a time. The modal set is: Datasets, About & Help, Donate, Chart Builder, Extract-to-Dataset, and Theme Builder. (Settings are deliberately _not_ a modal — they are distributed to per-pane controls; see _Settings_.)
- Opening any modal closes whichever modal was previously open; the two never overlap. - Opening any modal closes whichever modal was previously open; the two never overlap.
- Every modal can be dismissed by: clicking its close button, pressing **Escape**, or clicking the backdrop outside the modal body. - Every modal can be dismissed by: clicking its close button, pressing **Escape**, or clicking the backdrop outside the modal body. Exception: modals holding in-progress work (the Chart Builder and Theme Builder) ignore backdrop clicks; Escape and the close button still dismiss them.
- Clicking inside the modal body does not dismiss it. - Clicking inside the modal body does not dismiss it.
- The Chart Builder and Extract-to-Dataset modals are opened from within the Datasets / snippet workflows (see _Chart Builder_ and _Datasets_), not from the header. - The Chart Builder, Extract-to-Dataset, and Theme Builder modals are opened from within the Datasets / snippet / preview workflows (see _Chart Builder_, _Datasets_, and _Live Preview_), not from the header.
- Dismissing a modal returns the user to the underlying workspace unchanged. - Dismissing a modal returns the user to the underlying workspace unchanged.
## D. Keyboard Shortcuts ## D. Keyboard Shortcuts
+17 -1
View File
@@ -42,14 +42,30 @@ The preview pane header carries a **Chart theme** picker — a value-select disc
- **Astrolabe** (default) — the house style; follows the app's light/dark theme. - **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). - **Stock Vega-Lite** — injects nothing; charts render exactly as plain Vega-Lite defaults would anywhere else (white background, default palette and fonts).
- **Custom themes** — the user's saved themes (see _Theme Builder_ below), listed by name between the built-ins and the presets.
- **Edit themes…** — closes the custom-themes block (before the long preset roster, so it's visible without scrolling); opens the Theme Builder instead of changing the selection.
- **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. - **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: Behavior:
- The choice is a **global preference**, not per-snippet; it persists across sessions, stored in _Settings_ as `ui.chartTheme`. - The choice is a **global preference**, not per-snippet; it persists across sessions, stored in _Settings_ as `ui.chartTheme` (`custom:<id>` for a custom theme).
- 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_). - 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. - 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. - The Chart Builder preview and onboarding thumbnails are app surfaces and stay house-styled regardless of this choice.
- A selected custom theme whose record is missing (still loading, or deleted in another tab) renders as the house style; deleting the actively-selected theme resets the selection to Astrolabe.
## Theme Builder
The **Theme Builder** is a full-size modal for creating and editing custom chart themes — named, persistent Vega-Lite configs (see _Data Model → CustomTheme_). It opens from the Chart theme picker's "Edit themes…" entry.
Layout: a saved-theme list on the left; the open theme's editor on the right.
- **New theme** creates a theme seeded as a **copy of the chart theme currently selected** in the preview (house style, stock, a preset, or another custom theme), named after its source (e.g. "FiveThirtyEight copy") and auto-suffixed if taken. Duplicating a preset is the expected starting point. The other creation path is the editor's **Extract Config to New Theme** action (see _Spec Editor → Spec ↔ Config Actions_), which turns a pasted spec's `config` block into a theme directly.
- The editor shows the theme's **name** and its **config as editable JSON text**. Invalid JSON is reported inline and blocks saving; the text must parse to a JSON object.
- A **font control** applies a chosen font family across the whole config in one step: it sets the top-level `font` (Vega-Lite's default for every text mark, label, and title) and rewrites every explicit `font`/`labelFont`/`titleFont`/`subtitleFont` slot anywhere in the config — the slots that would otherwise keep overriding the new default. Offered fonts are limited to faces that render without loading (the app's own Plex faces and web-safe/system stacks) until the self-hosted font roster ships.
- A **gallery** of small fixed sample charts (bar with title, multi-series line with subtitle, stacked area, scatter with a gradient legend, heatmap, donut, facets with headers) re-renders live from the draft config — the same config-injection path the preview uses — so one edit is previewed across every chart surface a config styles. While the JSON is invalid, the gallery keeps showing the last valid state.
- **Save** commits the draft (disabled while unchanged or unparseable). Names are unique case-insensitively, like dataset names. **Delete** removes the theme after confirmation.
- Closing with unsaved edits prompts for discard, like other form modals. A backdrop click does not dismiss the builder (Escape and the close button do).
## Export control ## Export control
+31 -15
View File
@@ -65,21 +65,21 @@ The current **Dataset** version is `2`. The v1→v2 migration reflects the URL-s
**UserSettings** holds persisted user preferences as a single structured record. The semantics and UX of each option are covered in _Settings_; the shape below is the storage contract. **UserSettings** holds persisted user preferences as a single structured record. The semantics and UX of each option are covered in _Settings_; the shape below is the storage contract.
| Field | Type | Meaning | | Field | Type | Meaning |
| ----------------------------- | ------- | ---------------------------------------------------------- | | ----------------------------- | ------- | -------------------------------------------------------------------------------------------- |
| `version` | number | Schema version of the settings record, used for migration. | | `version` | number | Schema version of the settings record, used for migration. |
| `editor.fontSize` | number | Editor font size. | | `editor.fontSize` | number | Editor font size. |
| `editor.theme` | string | Editor color theme identifier. | | `editor.theme` | string | Editor color theme identifier. |
| `editor.minimap` | boolean | Whether the editor minimap is shown. | | `editor.minimap` | boolean | Whether the editor minimap is shown. |
| `editor.wordWrap` | string | `on` or `off`. | | `editor.wordWrap` | string | `on` or `off`. |
| `editor.lineNumbers` | string | `on` or `off`. | | `editor.lineNumbers` | string | `on` or `off`. |
| `editor.tabSize` | number | Spaces per indentation level. | | `editor.tabSize` | number | Spaces per indentation level. |
| `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. | | `ui.chartTheme` | string | Chart theme: `astrolabe`, `stock`, a preset id, or `custom:<id>` naming a _CustomTheme_ (G). |
| `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:
@@ -98,6 +98,7 @@ Some preferences persist independently of _UserSettings_ so they can update freq
| ---------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| Snippet store | All _Snippet_ records | Local, with a practical budget of about 5 MB. A storage monitor tracks usage and surfaces warnings as the budget fills (see _Snippet Library_). | | Snippet store | All _Snippet_ records | Local, with a practical budget of about 5 MB. A storage monitor tracks usage and surfaces warnings as the budget fills (see _Snippet Library_). |
| Dataset store | All _Dataset_ records | Local, in a separate, much higher-capacity store, suited to larger payloads. | | Dataset store | All _Dataset_ records | Local, in a separate, much higher-capacity store, suited to larger payloads. |
| Theme store | All _CustomTheme_ records (G) | Local, separate store; records are small (a config object plus metadata). |
| Settings & preferences | _UserSettings_ plus the app/UI preferences in (D) | Local, small. | | Settings & preferences | _UserSettings_ plus the app/UI preferences in (D) | Local, small. |
Everything stays in the browser — no server or account is involved. All tiers survive reload and function offline. Because capacity is finite and per-browser, _Import & Export_ is the supported path for backup and for moving data between browsers or devices. Everything stays in the browser — no server or account is involved. All tiers survive reload and function offline. Because capacity is finite and per-browser, _Import & Export_ is the supported path for backup and for moving data between browsers or devices.
@@ -110,3 +111,18 @@ Snippets and datasets are linked **bidirectionally by dataset name**: `snippet.d
- From a dataset, scanning snippets for its `name` in `datasetRefs` yields the snippets that reference it. - From a dataset, scanning snippets for its `name` in `datasetRefs` yields the snippets that reference it.
This name-based link is what the _Snippet Library_ and _Datasets_ surfaces use to show linkage in both directions. The actual resolution of a referenced dataset into spec data at render time is covered in _Live Preview_. This name-based link is what the _Snippet Library_ and _Datasets_ surfaces use to show linkage in both directions. The actual resolution of a referenced dataset into spec data at render time is covered in _Live Preview_.
## G. CustomTheme
A **CustomTheme** is a user-named Vega-Lite config saved in the library and offered by the _Live Preview → Chart theme_ picker alongside the built-in themes and presets. It is created and edited in the _Theme Builder_ (see _Live Preview_).
| Field | Type | Meaning |
| ---------- | -------------------- | --------------------------------------------------------------------------------------------- |
| `id` | number | Unique numeric identifier. The picker/persistence selection id is the string `custom:<id>`. |
| `version` | number | Schema version of this record, used for read-time migration (see _Schema versioning_ above). |
| `name` | string | Unique, human-readable name shown in the picker (case-insensitive uniqueness, like datasets). |
| `config` | object | The Vega-Lite config injected at render time when this theme is selected. |
| `created` | ISO-timestamp string | When the theme was first created. |
| `modified` | ISO-timestamp string | When the theme was last changed. |
Selection is keyed by `id` (not name) so renaming a theme never invalidates the persisted `ui.chartTheme`. A persisted `custom:<id>` whose record no longer exists is not an error: charts render with the house style until the record appears (themes hydrate asynchronously), and deleting the actively-selected theme resets the selection to `astrolabe` explicitly. Custom themes are not yet included in the _Import & Export_ envelope (planned; see `docs/chart-theming-scope.md`).
+14
View File
@@ -17,6 +17,20 @@ record the resolution into the contract (`docs/architecture/09`+`10` and the rel
showcase) or settings-cluster placement (a persistent global pref); should the popover showcase) or settings-cluster placement (a persistent global pref); should the popover
registry learn nesting; 16 flat options — group presets under a heading? registry learn nesting; 16 flat options — group presets under a heading?
- **"Edit themes…" action row inside the value picker** (`LivePreview.tsx`
ChartThemeControl). A non-value action lives inside a single-select disclosure (the
VS Code theme-picker pattern), placed after the custom-themes block and before the
preset roster (first-use feedback: at the very bottom it was invisible without
scrolling). Council questions: should an action be visually separated from the
values (divider, distinct styling); is a mid-list row that opens a modal instead of
selecting surprising to AT users?
- **Theme Builder config editor is a plain textarea** (`ThemeBuilderModal.tsx`). Monaco
(with the Vega-Lite config schema for completions) would match the main editor but is
heavy inside a modal and untested in that mounting. Revisit whether the builder deserves
a Monaco instance, and whether the gallery's canvas charts need text alternatives
beyond the per-card captions.
## Deferred (not design debts, revisit on demand) ## Deferred (not design debts, revisit on demand)
- **Drag-and-drop field assignment** — chips are click/keyboard-first by design; drag would - **Drag-and-drop field assignment** — chips are click/keyboard-first by design; drag would
+11
View File
@@ -28,6 +28,7 @@
"eslint": "^10.4.1", "eslint": "^10.4.1",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"fake-indexeddb": "^6.2.5",
"globals": "^17.6.0", "globals": "^17.6.0",
"happy-dom": "^20.0.0", "happy-dom": "^20.0.0",
"husky": "^9.1.7", "husky": "^9.1.7",
@@ -4829,6 +4830,16 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/fake-indexeddb": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz",
"integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+1
View File
@@ -44,6 +44,7 @@
"eslint": "^10.4.1", "eslint": "^10.4.1",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"fake-indexeddb": "^6.2.5",
"globals": "^17.6.0", "globals": "^17.6.0",
"happy-dom": "^20.0.0", "happy-dom": "^20.0.0",
"husky": "^9.1.7", "husky": "^9.1.7",
+42 -6
View File
@@ -15,21 +15,27 @@
* (M3) plugs into prepareSpecForRender without changing this component. * (M3) plugs into prepareSpecForRender without changing this component.
*/ */
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useShallow } from 'zustand/react/shallow'; 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 { CHART_THEME_OPTIONS, chartConfigForSelection } from '@core/vega-themes'; import {
chartConfigForSelection,
chartThemeOptions,
type ChartThemeSelection,
} from '@core/vega-themes';
import { openModal } from '../modals/ModalCoordinator';
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 { useCustomThemeStore } from '../stores/CustomThemeStore';
import { useDatasetStore } from '../stores/DatasetStore'; import { useDatasetStore } from '../stores/DatasetStore';
import { usePreviewStore } from '../stores/PreviewStore'; import { usePreviewStore } from '../stores/PreviewStore';
import { selectShownText, useSnippetStore } from '../stores/SnippetStore'; 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 { SelectControl, type SelectControlOption } 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';
@@ -78,16 +84,42 @@ function FitControl() {
* (and unmount) its own parent on open. * (and unmount) its own parent on open.
*/ */
// TODO: header placement/crowding parked for the batched council pass (docs/ux-second-pass.md). // TODO: header placement/crowding parked for the batched council pass (docs/ux-second-pass.md).
/** Sentinel option that opens the Theme Builder instead of selecting a theme. */
const EDIT_THEMES = 'edit-themes';
type ThemePickerValue = ChartThemeSelection | typeof EDIT_THEMES;
function ChartThemeControl() { function ChartThemeControl() {
const chartTheme = useAppStore((s) => s.chartTheme); const chartTheme = useAppStore((s) => s.chartTheme);
const setChartTheme = useAppStore((s) => s.setChartTheme); const setChartTheme = useAppStore((s) => s.setChartTheme);
const customThemes = useCustomThemeStore((s) => s.themes);
// Fresh-array derivation — memoize so the picker doesn't re-render the world
// (docs/architecture/01: derive with useMemo, never store).
const options = useMemo<ReadonlyArray<SelectControlOption<ThemePickerValue>>>(() => {
const list: SelectControlOption<ThemePickerValue>[] = [...chartThemeOptions(customThemes)];
// The "manage" entry rides in the value list (the VS Code theme-picker
// pattern); choosing it opens the builder and leaves the selection alone.
// It closes the custom-themes block — right after the built-ins, BEFORE the
// long preset roster — so it is visible without scrolling and sits next to
// the entries it manages.
// TODO: action row inside a value picker (visual separation? AT surprise?)
// parked for the batched council pass (docs/ux-second-pass.md).
list.splice(2 + customThemes.length, 0, {
value: EDIT_THEMES,
label: 'Edit themes…',
detail: 'Create and manage custom themes',
});
return list;
}, [customThemes]);
return ( return (
<SelectControl <SelectControl
id="preview-chart-theme" id="preview-chart-theme"
label="Chart theme" label="Chart theme"
options={CHART_THEME_OPTIONS} options={options}
value={chartTheme} value={chartTheme}
onSelect={setChartTheme} onSelect={(value) => {
if (value === EDIT_THEMES) openModal('themeBuilder');
else setChartTheme(value);
}}
triggerTitle="Chart theme — how charts are styled when rendered and exported" triggerTitle="Chart theme — how charts are styled when rendered and exported"
/> />
); );
@@ -126,6 +158,9 @@ export function LivePreview() {
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); const chartTheme = useAppStore((s) => s.chartTheme);
// Custom themes feed `custom:<id>` selection resolution; re-rendering on a
// change keeps the chart live while a selected theme is edited in the builder.
const customThemes = useCustomThemeStore((s) => s.themes);
// 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));
@@ -238,7 +273,7 @@ export function LivePreview() {
try { try {
const prepared = prepareSpecForRender(parsed, { fitMode, datasets }); const prepared = prepareSpecForRender(parsed, { fitMode, datasets });
const config = chartConfigForSelection(chartTheme, uiTheme); const config = chartConfigForSelection(chartTheme, uiTheme, customThemes);
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);
@@ -286,6 +321,7 @@ export function LivePreview() {
fitMode, fitMode,
uiTheme, uiTheme,
chartTheme, chartTheme,
customThemes,
datasets, datasets,
setError, setError,
setBusy, setBusy,
+5 -4
View File
@@ -23,10 +23,11 @@ export function ModalShell() {
const name = useAppStore((s) => s.activeModal); const name = useAppStore((s) => s.activeModal);
const config = getModalConfig(name); const config = getModalConfig(name);
// The Chart Builder is a near-fullscreen work surface; the Datasets manager is the // The Chart Builder and Theme Builder are near-fullscreen work surfaces; the
// standard large two-pane modal; everything else is a small form. Both large kinds // Datasets manager is the standard large two-pane modal; everything else is a
// get the static-title initial focus (APG dialog-modal) so content isn't skipped. // small form. Both large kinds get the static-title initial focus (APG
const isXLarge = name === 'chartBuilder'; // dialog-modal) so content isn't skipped.
const isXLarge = name === 'chartBuilder' || name === 'themeBuilder';
const isLarge = name === 'datasets' || isXLarge; const isLarge = name === 'datasets' || isXLarge;
// Move focus into the modal on open, return it to the trigger on close. For a // Move focus into the modal on open, return it to the trigger on close. For a
@@ -0,0 +1,299 @@
/* Theme Builder — list + editor + live gallery inside the xlarge modal shell. */
.builder {
display: grid;
grid-template-columns: 240px 1fr;
grid-template-rows: minmax(0, 1fr);
height: 100%;
min-height: 0;
min-width: 0;
}
/* ── Left: saved-theme list (mirrors the Datasets manager list pane) ───── */
.listPane {
display: flex;
flex-direction: column;
min-height: 0;
border-right: var(--border-width) solid var(--border);
}
.newButton {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
margin: var(--space-4);
height: 40px;
padding: 0 var(--space-5);
border: var(--border-width) solid transparent;
border-radius: var(--radius);
background: var(--accent);
color: var(--accent-contrast);
font: inherit;
font-weight: 600;
cursor: pointer;
transition: background var(--dur-fast) var(--ease);
}
.newButton:hover {
background: var(--accent-hover);
}
.list {
list-style: none;
margin: 0;
padding: 0;
flex: 1 1 auto;
min-height: 0;
overflow: auto;
border-top: var(--border-width) solid var(--border);
}
.empty {
color: var(--text-secondary);
font-size: 13px;
line-height: 1.4;
padding: var(--space-5) var(--space-4);
}
.item {
border-left: 2px solid transparent;
transition: background var(--dur-fast) var(--ease);
}
.item + .item {
border-top: var(--border-width) solid var(--border);
}
.item:hover {
background: var(--layer-01);
}
.itemActive {
background: var(--layer-01);
border-left-color: var(--accent);
}
.itemButton {
display: block;
width: 100%;
appearance: none;
border: none;
background: none;
padding: var(--space-3) var(--space-4);
font: inherit;
font-size: 13px;
font-weight: 500;
color: inherit;
text-align: left;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Right: the editor surface ──────────────────────────────────────────── */
.detailEmpty {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
font-size: 13px;
padding: var(--space-5);
}
.main {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.toolbar {
flex: 0 0 auto;
display: flex;
align-items: flex-end;
gap: var(--space-4);
padding: var(--space-4) var(--space-5);
border-bottom: var(--border-width) solid var(--border);
}
.nameField {
display: flex;
flex-direction: column;
gap: var(--space-1);
min-width: 200px;
}
.label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.input {
width: 100%;
height: 32px;
padding: 0 var(--space-3);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 13px;
}
.input:focus-visible {
outline: 2px solid var(--focus);
outline-offset: -1px;
}
.toolbarEnd {
margin-left: auto;
display: flex;
gap: var(--space-3);
}
.action {
height: 32px;
padding: 0 var(--space-4);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: transparent;
color: var(--text);
font: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background var(--dur-fast) var(--ease);
}
.action:hover:not(:disabled) {
background: var(--layer-01);
}
.action:disabled {
color: var(--text-placeholder);
border-color: var(--border);
cursor: not-allowed;
}
.action:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
.primary {
background: var(--accent);
border-color: transparent;
color: var(--accent-contrast);
font-weight: 600;
}
.primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.primary:disabled {
background: var(--layer-01);
}
.danger {
border-color: var(--border-strong);
color: var(--support-error);
}
.danger:hover:not(:disabled) {
background: var(--support-error);
color: var(--on-status);
border-color: transparent;
}
.errorMessage {
flex: 0 0 auto;
margin: 0;
padding: var(--space-3) var(--space-5);
font-size: 13px;
color: var(--support-error);
border-bottom: var(--border-width) solid var(--border);
}
/* ── Editor + gallery split ────────────────────────────────────────────── */
.work {
flex: 1 1 auto;
display: grid;
grid-template-columns: minmax(280px, 400px) 1fr;
grid-template-rows: minmax(0, 1fr);
min-height: 0;
min-width: 0;
}
.editorPane {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-4) var(--space-5);
border-right: var(--border-width) solid var(--border);
min-height: 0;
min-width: 0;
}
.configText {
flex: 1 1 auto;
width: 100%;
min-height: 0;
padding: var(--space-3);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.5;
resize: none;
}
.configText:focus-visible {
outline: 2px solid var(--focus);
outline-offset: -1px;
}
/* The gallery wraps fixed-size swatch cards; scrolls when they overflow. */
.gallery {
display: flex;
flex-wrap: wrap;
align-content: flex-start;
gap: var(--space-4);
padding: var(--space-4) var(--space-5);
overflow: auto;
min-height: 0;
min-width: 0;
}
.card {
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3);
border: var(--border-width) solid var(--border);
border-radius: var(--radius);
}
/* Reserve the card's box so the grid doesn't reflow while charts render. */
.cardHost {
min-width: 260px;
min-height: 180px;
display: flex;
align-items: center;
justify-content: center;
}
.cardCaption {
font-size: 11px;
color: var(--text-secondary);
}
@@ -0,0 +1,125 @@
/**
* ThemeBuilderModal structure and store wiring. The draft/save/font logic
* lives in CustomThemeStore (tested there); these cover the component's seams:
* the empty state, creation seeded from the active chart theme, the gallery
* cards, and the Save flow. vega-embed is mocked out (integration-heavy).
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { THEME_PREVIEW_SPECS } from '@core/theme-preview-specs';
import { useAppStore } from '../stores/AppStore';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { ThemeBuilderModal } from './ThemeBuilderModal';
vi.mock('../services/chart-renderer', () => ({
renderSpec: vi.fn(() =>
Promise.resolve({ destroy() {}, resize() {}, toImageURL: () => Promise.resolve('') }),
),
}));
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
vi.useFakeTimers();
useCustomThemeStore.getState().reset();
useAppStore.getState().setChartTheme('astrolabe');
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
});
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.useRealTimers();
});
const renderModal = () => act(() => root.render(<ThemeBuilderModal />));
describe('ThemeBuilderModal', () => {
test('shows the empty state when there are no themes', () => {
renderModal();
expect(container.textContent).toContain('No custom themes yet');
expect(container.textContent).toContain('Create a theme to start editing.');
});
test('New theme creates a copy of the active chart theme and opens the draft', () => {
renderModal();
const newButton = [...container.querySelectorAll('button')].find(
(b) => b.textContent === 'New theme',
)!;
act(() => newButton.click());
const s = useCustomThemeStore.getState();
expect(s.themes).toHaveLength(1);
expect(s.themes[0].name).toBe('Astrolabe copy');
// Seeded from the house config, not empty.
expect(s.themes[0].config.font).toContain('IBM Plex');
const nameInput = container.querySelector<HTMLInputElement>('#theme-name')!;
expect(nameInput.value).toBe('Astrolabe copy');
// One gallery card per preview spec.
expect(container.querySelectorAll('figure')).toHaveLength(THEME_PREVIEW_SPECS.length);
});
test('a preset selection seeds the copy from that preset', () => {
useAppStore.getState().setChartTheme('fivethirtyeight');
renderModal();
const newButton = [...container.querySelectorAll('button')].find(
(b) => b.textContent === 'New theme',
)!;
act(() => newButton.click());
expect(useCustomThemeStore.getState().themes[0].name).toBe('FiveThirtyEight copy');
});
test('Save is disabled until the draft is dirty, then commits', () => {
renderModal();
act(() => {
useCustomThemeStore.getState().createTheme('Brand', { font: 'Helvetica' });
});
const save = () =>
[...container.querySelectorAll('button')].find((b) => b.textContent === 'Save theme')!;
expect(save().disabled).toBe(true);
act(() => useCustomThemeStore.getState().updateDraft({ name: 'Brand 2026' }));
expect(save().disabled).toBe(false);
act(() => save().click());
expect(useCustomThemeStore.getState().themes[0].name).toBe('Brand 2026');
expect(save().disabled).toBe(true);
});
test('Save stays disabled while the config text is invalid JSON', () => {
renderModal();
act(() => {
useCustomThemeStore.getState().createTheme('Brand', {});
});
act(() => useCustomThemeStore.getState().updateDraft({ configText: '{oops' }));
const save = [...container.querySelectorAll('button')].find(
(b) => b.textContent === 'Save theme',
)!;
expect(save.disabled).toBe(true);
expect(container.textContent).toContain('Invalid JSON');
});
test('selecting another theme from the list swaps the draft', () => {
renderModal();
act(() => {
useCustomThemeStore.getState().createTheme('First', {});
useCustomThemeStore.getState().createTheme('Second', {});
});
const firstItem = [...container.querySelectorAll('button')].find(
(b) => b.textContent === 'First',
)!;
act(() => firstItem.click());
expect(container.querySelector<HTMLInputElement>('#theme-name')!.value).toBe('First');
});
});
+264
View File
@@ -0,0 +1,264 @@
/**
* Theme Builder create and edit custom chart themes (spec §04 Chart theme;
* docs/chart-theming-scope.md §4.4).
*
* Left pane: the saved-theme list plus "New theme" (seeded from whatever chart
* theme is currently selected, so any preset or the house style can be a
* starting point). Main pane: the draft's name, its config as editable JSON,
* a font control that writes one family across every font slot in the config,
* and a live gallery of small charts re-rendered from the draft config the
* same config-injection path the Live Preview uses, so what the gallery shows
* is exactly what selecting the theme will do.
*/
import { useEffect, useRef } from 'react';
import { THEME_FONT_OPTIONS } from '@core/custom-theme';
import type { JsonObject } from '@core/spec-config';
import { THEME_PREVIEW_SPECS, type ThemePreviewSpec } from '@core/theme-preview-specs';
import { chartConfigForSelection, chartThemeOptions } from '@core/vega-themes';
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
import { useAppStore } from '../stores/AppStore';
import { confirm } from '../stores/ConfirmStore';
import {
selectIsDraftDirty,
selectSelectedTheme,
useCustomThemeStore,
} from '../stores/CustomThemeStore';
import { notify } from '../stores/NotificationStore';
import { resnapshot } from '../modals/ModalCoordinator';
import { SelectControl } from './SelectControl';
import styles from './ThemeBuilderModal.module.css';
/** Debounce for gallery re-renders while the config text is edited (ms). */
const GALLERY_DEBOUNCE = 250;
/**
* One gallery card: a fixed sample spec rendered with the draft config. Canvas
* renderer seven concurrent SVG charts would put thousands of nodes in a
* modal; raster is invisible at swatch size (same trade-off as the Chart
* Builder preview). Renders are serialized per card with the LivePreview
* chain-lock pattern so a slow embed never interleaves with a newer one on the
* shared host node. Render failures blank the card silently the gallery is
* a preview aid; the config editor's parse error is the real feedback channel.
*/
function GalleryCard({ card, config }: { card: ThemePreviewSpec; config: JsonObject }) {
const hostRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<RenderHandle | null>(null);
const generationRef = useRef(0);
const chainRef = useRef<Promise<void>>(Promise.resolve());
useEffect(() => {
const node = hostRef.current;
if (!node) return;
const timer = setTimeout(() => {
const mine = ++generationRef.current;
const prior = chainRef.current;
let release!: () => void;
chainRef.current = new Promise<void>((r) => {
release = r;
});
void (async () => {
try {
await prior;
if (mine !== generationRef.current) return;
handleRef.current?.destroy();
handleRef.current = null;
const handle = await renderSpec(node, card.spec, config, {
renderer: 'canvas',
});
if (mine !== generationRef.current) {
handle.destroy();
return;
}
handleRef.current = handle;
} catch {
// Leave the card blank; the config editor reports the actionable error.
} finally {
release();
}
})();
}, GALLERY_DEBOUNCE);
return () => clearTimeout(timer);
}, [card, config]);
useEffect(
() => () => {
generationRef.current++;
handleRef.current?.destroy();
handleRef.current = null;
},
[],
);
return (
<figure className={styles.card}>
<div className={styles.cardHost} ref={hostRef} />
<figcaption className={styles.cardCaption}>{card.caption}</figcaption>
</figure>
);
}
export function ThemeBuilderModal() {
const themes = useCustomThemeStore((s) => s.themes);
const selectedId = useCustomThemeStore((s) => s.selectedId);
const draft = useCustomThemeStore((s) => s.draft);
const draftConfig = useCustomThemeStore((s) => s.draftConfig);
const parseError = useCustomThemeStore((s) => s.parseError);
const saveError = useCustomThemeStore((s) => s.saveError);
const dirty = useCustomThemeStore(selectIsDraftDirty);
const selectedTheme = useCustomThemeStore(selectSelectedTheme);
const handleNew = () => {
const app = useAppStore.getState();
const store = useCustomThemeStore.getState();
// Seed from whatever the picker currently shows — duplicating a preset (or
// the house style, or another custom theme) is the creation path (scope §4.4).
const seed = chartConfigForSelection(app.chartTheme, app.uiTheme, store.themes) as JsonObject;
const sourceLabel =
chartThemeOptions(store.themes).find((o) => o.value === app.chartTheme)?.label ?? 'Theme';
store.createTheme(`${sourceLabel} copy`, seed);
// The fresh draft is the new baseline — creating then closing isn't a loss.
resnapshot();
};
const handleSave = () => {
if (useCustomThemeStore.getState().saveDraft()) {
resnapshot();
// The saved name in the list is visible, but the commit itself has no
// other on-screen change (the draft stays open) — confirm it.
notify({ kind: 'success', title: 'Theme saved', message: 'Your chart theme was updated.' });
}
};
const handleDelete = async () => {
if (!selectedTheme) return;
const ok = await confirm({
title: 'Delete theme',
message: `Delete "${selectedTheme.name}"? This cannot be undone.`,
confirmLabel: 'Delete',
danger: true,
});
if (!ok) return;
const removedName = selectedTheme.name;
useCustomThemeStore.getState().remove(selectedTheme.id);
resnapshot();
notify({
kind: 'success',
title: 'Theme deleted',
message: `"${removedName}" was permanently removed.`,
});
};
return (
<div className={styles.builder}>
<div className={styles.listPane}>
<button type="button" className={styles.newButton} onClick={handleNew}>
New theme
</button>
<ul className={styles.list}>
{themes.length === 0 && (
<li className={styles.empty}>
No custom themes yet. A new theme starts as a copy of the chart theme currently
selected in the preview, so pick a preset you like as the starting point.
</li>
)}
{themes.map((theme) => (
<li
key={theme.id}
className={`${styles.item} ${theme.id === selectedId ? styles.itemActive : ''}`}
>
<button
type="button"
className={styles.itemButton}
aria-current={theme.id === selectedId || undefined}
onClick={() => useCustomThemeStore.getState().select(theme.id)}
>
{theme.name}
</button>
</li>
))}
</ul>
</div>
{draft === null ? (
<div className={styles.detailEmpty}>Create a theme to start editing.</div>
) : (
<div className={styles.main}>
<div className={styles.toolbar}>
<div className={styles.nameField}>
<label className={styles.label} htmlFor="theme-name">
Name
</label>
<input
id="theme-name"
className={styles.input}
value={draft.name}
onChange={(e) =>
useCustomThemeStore.getState().updateDraft({ name: e.target.value })
}
/>
</div>
<SelectControl
id="theme-builder-font"
label="Apply a font across the config"
heading="Apply font"
options={THEME_FONT_OPTIONS.map(({ value, label }) => ({ value, label }))}
onSelect={(family) => useCustomThemeStore.getState().applyDraftFont(family)}
triggerContent={<>Font</>}
triggerTitle="Write one font family into every font slot of the config"
/>
<div className={styles.toolbarEnd}>
<button
type="button"
className={`${styles.action} ${styles.danger}`}
onClick={() => void handleDelete()}
>
Delete
</button>
<button
type="button"
className={`${styles.action} ${styles.primary}`}
disabled={!dirty || parseError !== null}
onClick={handleSave}
>
Save theme
</button>
</div>
</div>
{(saveError ?? parseError) !== null && (
<p className={styles.errorMessage} role="alert">
{saveError ?? parseError}
</p>
)}
<div className={styles.work}>
<div className={styles.editorPane}>
<label className={styles.label} htmlFor="theme-config">
Config (Vega-Lite JSON)
</label>
{/* TODO: plain textarea vs a Monaco instance (config-schema completions), and
text alternatives for the canvas gallery beyond the captions parked for
the batched council pass (docs/ux-second-pass.md). */}
<textarea
id="theme-config"
className={styles.configText}
spellCheck={false}
value={draft.configText}
onChange={(e) =>
useCustomThemeStore.getState().updateDraft({ configText: e.target.value })
}
/>
</div>
<div className={styles.gallery} aria-label="Theme preview gallery">
{draftConfig !== null &&
THEME_PREVIEW_SPECS.map((card) => (
<GalleryCard key={card.id} card={card} config={draftConfig} />
))}
</div>
</div>
</div>
)}
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
/**
* IndexedDB wrapper store-layout verification and self-healing (the
* interrupted-upgrade recovery documented on `openDB`). Runs against
* fake-indexeddb; each test gets a pristine database.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IDBFactory } from 'fake-indexeddb';
import {
DATASETS_STORE,
SNIPPETS_STORE,
THEMES_STORE,
_resetDbForTests,
getAll,
openDB,
put,
} from './db';
const ALL_STORES = [SNIPPETS_STORE, DATASETS_STORE, THEMES_STORE];
/** Open the raw database at `version` with a custom (or absent) upgrade body. */
function rawOpen(version: number, upgrade?: (db: IDBDatabase) => void): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open('astrolabe', version);
req.onupgradeneeded = () => upgrade?.(req.result);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error ?? new Error('open failed'));
});
}
beforeEach(() => {
// A fresh factory per test — no databases survive between tests.
globalThis.indexedDB = new IDBFactory();
_resetDbForTests();
});
afterEach(() => {
_resetDbForTests();
});
describe('openDB', () => {
it('creates every expected store on first run', async () => {
const db = await openDB();
for (const store of ALL_STORES) expect(db.objectStoreNames.contains(store)).toBe(true);
});
it('upgrades a v1 database (snippets + datasets) to include the themes store', async () => {
const v1 = await rawOpen(1, (db) => {
db.createObjectStore(SNIPPETS_STORE, { keyPath: 'id' });
db.createObjectStore(DATASETS_STORE, { keyPath: 'id' });
});
v1.close();
const db = await openDB();
expect(db.objectStoreNames.contains(THEMES_STORE)).toBe(true);
});
it('self-heals a database stamped at the current version with stores missing', async () => {
// The interrupted-upgrade state: version already 2, but the themes store
// was never created (e.g. a hot reload opened v2 before the create-store
// code existed). onupgradeneeded will never fire again for v2.
const broken = await rawOpen(2, (db) => {
db.createObjectStore(SNIPPETS_STORE, { keyPath: 'id' });
db.createObjectStore(DATASETS_STORE, { keyPath: 'id' });
});
broken.close();
const db = await openDB();
expect(db.objectStoreNames.contains(THEMES_STORE)).toBe(true);
expect(db.version).toBe(3); // healed by a forced extra upgrade pass
// Transactions on every store now work.
await put(THEMES_STORE, { id: 1, name: 'ok' });
await expect(getAll(THEMES_STORE)).resolves.toEqual([{ id: 1, name: 'ok' }]);
});
it('opens a database whose version is already past DB_VERSION', async () => {
// A prior self-heal bump leaves the version above the code's constant; a
// versioned open would throw VersionError. All stores already exist here.
const ahead = await rawOpen(7, (db) => {
for (const store of ALL_STORES) db.createObjectStore(store, { keyPath: 'id' });
});
ahead.close();
const db = await openDB();
expect(db.version).toBe(7);
for (const store of ALL_STORES) expect(db.objectStoreNames.contains(store)).toBe(true);
});
it('self-heals an ahead-of-code database with stores missing', async () => {
const ahead = await rawOpen(5, (db) => {
db.createObjectStore(SNIPPETS_STORE, { keyPath: 'id' });
});
ahead.close();
const db = await openDB();
expect(db.version).toBe(6);
for (const store of ALL_STORES) expect(db.objectStoreNames.contains(store)).toBe(true);
});
it('preserves existing records across the self-heal upgrade', async () => {
const broken = await rawOpen(2, (db) => {
db.createObjectStore(SNIPPETS_STORE, { keyPath: 'id' });
db.createObjectStore(DATASETS_STORE, { keyPath: 'id' });
});
await new Promise<void>((resolve, reject) => {
const t = broken.transaction(SNIPPETS_STORE, 'readwrite');
t.objectStore(SNIPPETS_STORE).put({ id: 'a', name: 'kept' });
t.oncomplete = () => resolve();
t.onerror = () => reject(t.error ?? new Error('tx failed'));
});
broken.close();
await openDB();
await expect(getAll(SNIPPETS_STORE)).resolves.toEqual([{ id: 'a', name: 'kept' }]);
});
});
+49 -13
View File
@@ -11,35 +11,71 @@ const DB_NAME = 'astrolabe';
/** /**
* Store-layout version. Bump only when the set of object stores / indexes * Store-layout version. Bump only when the set of object stores / indexes
* changes independent of per-record schema versions (see snippet-migrations). * changes independent of per-record schema versions (see snippet-migrations).
* v2 added the `themes` store (custom chart themes).
*/ */
const DB_VERSION = 1; const DB_VERSION = 2;
export const SNIPPETS_STORE = 'snippets'; export const SNIPPETS_STORE = 'snippets';
export const DATASETS_STORE = 'datasets'; export const DATASETS_STORE = 'datasets';
export const THEMES_STORE = 'themes';
/** Every object store the app expects — the open-time verification checklist. */
const EXPECTED_STORES = [SNIPPETS_STORE, DATASETS_STORE, THEMES_STORE] as const;
let dbPromise: Promise<IDBDatabase> | null = null; let dbPromise: Promise<IDBDatabase> | null = null;
/** Open (and memoize) the database, creating object stores on first run. */ /**
export function openDB(): Promise<IDBDatabase> { * One `indexedDB.open` as a promise. Omitting `version` opens at whatever
if (dbPromise) return dbPromise; * version the database already has (never an upgrade). The upgrade handler
* creates every missing store guarded per store, so it is idempotent across
dbPromise = new Promise((resolve, reject) => { * any oldnew version jump.
const req = indexedDB.open(DB_NAME, DB_VERSION); */
function openAt(version?: number): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = version === undefined ? indexedDB.open(DB_NAME) : indexedDB.open(DB_NAME, version);
req.onupgradeneeded = () => { req.onupgradeneeded = () => {
const db = req.result; const db = req.result;
// Guard every create so upgrades stay idempotent. for (const store of EXPECTED_STORES) {
if (!db.objectStoreNames.contains(SNIPPETS_STORE)) { if (!db.objectStoreNames.contains(store)) {
db.createObjectStore(SNIPPETS_STORE, { keyPath: 'id' }); db.createObjectStore(store, { keyPath: 'id' });
} }
if (!db.objectStoreNames.contains(DATASETS_STORE)) {
db.createObjectStore(DATASETS_STORE, { keyPath: 'id' });
} }
}; };
req.onsuccess = () => resolve(req.result); req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error ?? new Error('Failed to open IndexedDB')); req.onerror = () => reject(req.error ?? new Error('Failed to open IndexedDB'));
}); });
}
/**
* Open (and memoize) the database, creating object stores on first run.
*
* The open **verifies** the store layout instead of trusting the version
* number: an interrupted upgrade can stamp the new version without creating
* the new stores (observed in dev a hot reload opened the bumped version
* before the store-creation code existed), after which `onupgradeneeded`
* never fires again and every transaction on the missing store throws
* NotFoundError. If any expected store is missing after a successful open,
* reopen at `version + 1` to force another (idempotent) upgrade pass the
* database self-heals rather than being stuck until manually deleted.
*/
export function openDB(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise;
dbPromise = openAt(DB_VERSION)
.catch((err: unknown) => {
// A database already past DB_VERSION (a prior self-heal bump) makes a
// versioned open throw VersionError; open at its current version instead.
if (err instanceof DOMException && err.name === 'VersionError') return openAt();
throw err;
})
.then((db) => {
if (EXPECTED_STORES.every((store) => db.objectStoreNames.contains(store))) return db;
const next = db.version + 1;
db.close();
return openAt(next);
});
return dbPromise; return dbPromise;
} }
+10 -6
View File
@@ -19,7 +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'; import { isChartThemeSelection, type ChartThemeSelection } from '@core/vega-themes';
const KEY = 'astrolabe:settings'; const KEY = 'astrolabe:settings';
@@ -30,7 +30,7 @@ const DEFAULT_THEME: UiTheme = 'light';
const DEFAULT_FIT_MODE: FitMode = 'default'; const DEFAULT_FIT_MODE: FitMode = 'default';
/** Spec §04 — the Chart theme picker defaults to the house style. */ /** Spec §04 — the Chart theme picker defaults to the house style. */
const DEFAULT_CHART_THEME: ChartThemeId = 'astrolabe'; const DEFAULT_CHART_THEME: ChartThemeSelection = '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'>;
@@ -122,14 +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 { * The persisted chart theme, or the default unknown ids fall back. A
* `custom:<id>` passes on shape; whether the record still exists is resolved at
* render time (missing custom themes render as the house style).
*/
export function loadChartTheme(): ChartThemeSelection {
const stored = readRaw().ui?.chartTheme; const stored = readRaw().ui?.chartTheme;
return isChartThemeId(stored) ? stored : DEFAULT_CHART_THEME; return isChartThemeSelection(stored) ? stored : DEFAULT_CHART_THEME;
} }
/** Persist the chart theme, preserving every other key already in the record. */ /** Persist the chart theme, preserving every other key already in the record. */
export function saveChartTheme(chartTheme: ChartThemeId): void { export function saveChartTheme(chartTheme: ChartThemeSelection): void {
const current = readRaw(); const current = readRaw();
writeRaw({ ...current, ui: { ...current.ui, chartTheme } }); writeRaw({ ...current, ui: { ...current.ui, chartTheme } });
} }
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { CURRENT_THEME_VERSION } from '@core/custom-theme';
import { migrateCustomTheme } from './theme-migrations';
describe('migrateCustomTheme', () => {
it('passes a current record through unchanged (plus version stamp)', () => {
const record = {
id: 3,
version: CURRENT_THEME_VERSION,
name: 'Brand',
config: { font: 'Georgia' },
created: '2026-06-12T10:00:00.000Z',
modified: '2026-06-12T11:00:00.000Z',
};
expect(migrateCustomTheme(record)).toEqual(record);
});
it('fills missing or invalid fields with safe defaults', () => {
const migrated = migrateCustomTheme({ id: '7', config: ['not', 'an', 'object'] });
expect(migrated.id).toBe(7);
expect(migrated.version).toBe(CURRENT_THEME_VERSION);
expect(migrated.name).toBe('Untitled theme');
expect(migrated.config).toEqual({});
expect(typeof migrated.created).toBe('string');
expect(typeof migrated.modified).toBe('string');
});
it('keeps unknown fields written by a newer build', () => {
const migrated = migrateCustomTheme({
id: 1,
name: 'Next',
config: {},
futureField: 'kept',
});
expect((migrated as unknown as Record<string, unknown>).futureField).toBe('kept');
});
});
@@ -0,0 +1,25 @@
/**
* Read-time migration for CustomTheme records (docs/architecture/02 §4).
*
* Mirrors snippet/dataset migrations: every theme read from storage passes
* through `migrateCustomTheme`, which fills missing/invalid fields and stamps
* the current version. Unknown fields are tolerated (spread the original, only
* fill gaps) so a record written by a newer build round-trips without loss.
*/
import { CURRENT_THEME_VERSION, type CustomTheme } from '@core/custom-theme';
import { isJsonObject } from '@core/spec-config';
/** Upgrade a raw stored record to the current CustomTheme shape. */
export function migrateCustomTheme(raw: unknown): CustomTheme {
const r = { ...(raw as Record<string, unknown>) };
return {
...r,
id: typeof r.id === 'number' ? r.id : Number(r.id),
version: CURRENT_THEME_VERSION,
name: typeof r.name === 'string' ? r.name : 'Untitled theme',
config: isJsonObject(r.config) ? r.config : {},
created: typeof r.created === 'string' ? r.created : new Date(0).toISOString(),
modified: typeof r.modified === 'string' ? r.modified : new Date(0).toISOString(),
};
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Custom theme persistence adapter (docs/architecture/02; scope doc §4.4).
*
* The typed seam between the CustomThemeStore and IndexedDB's `themes` object
* store. Exposes plain async functions returning domain `CustomTheme` objects
* and migrates every record on read same contract as dataset-store.
*/
import { CURRENT_THEME_VERSION, type CustomTheme } from '@core/custom-theme';
import { THEMES_STORE, del, getAll, put } from './db';
import { migrateCustomTheme } from './theme-migrations';
/** Load every custom theme, upgrading each record to the current shape. */
export async function loadCustomThemes(): Promise<CustomTheme[]> {
const records = await getAll<unknown>(THEMES_STORE);
return records.map(migrateCustomTheme);
}
/** Persist a custom theme at the current schema version. Propagates failures. */
export async function saveCustomTheme(theme: CustomTheme): Promise<void> {
await put(THEMES_STORE, { ...theme, version: CURRENT_THEME_VERSION });
}
/** Permanently remove a custom theme by id. */
export async function deleteCustomTheme(id: number): Promise<void> {
await del(THEMES_STORE, id);
}
+30
View File
@@ -17,12 +17,16 @@
import type { ComponentType } from 'react'; import type { ComponentType } from 'react';
import type { ActiveModal, ModalName } from './types'; import type { ActiveModal, ModalName } from './types';
import { customThemeIdOf } from '@core/vega-themes';
import { AboutModal } from '../components/AboutModal'; import { AboutModal } from '../components/AboutModal';
import { ChartBuilderModal } from '../components/ChartBuilderModal'; import { ChartBuilderModal } from '../components/ChartBuilderModal';
import { DatasetsModal } from '../components/DatasetsModal'; import { DatasetsModal } from '../components/DatasetsModal';
import { DonateModal } from '../components/DonateModal'; import { DonateModal } from '../components/DonateModal';
import { ExtractModal } from '../components/ExtractModal'; import { ExtractModal } from '../components/ExtractModal';
import { ThemeBuilderModal } from '../components/ThemeBuilderModal';
import { useAppStore } from '../stores/AppStore';
import { useChartBuilderStore } from '../stores/ChartBuilderStore'; import { useChartBuilderStore } from '../stores/ChartBuilderStore';
import { selectIsDraftDirty, useCustomThemeStore } from '../stores/CustomThemeStore';
import { useDatasetStore } from '../stores/DatasetStore'; import { useDatasetStore } from '../stores/DatasetStore';
import { useExtractStore } from '../stores/ExtractStore'; import { useExtractStore } from '../stores/ExtractStore';
@@ -88,6 +92,32 @@ export const MODAL_REGISTRY: Partial<Record<ModalName, ModalConfig>> = {
init: (datasetId) => useChartBuilderStore.getState().init(datasetId ? Number(datasetId) : null), init: (datasetId) => useChartBuilderStore.getState().init(datasetId ? Number(datasetId) : null),
}, },
// Opened from the chart-theme picker's "Edit themes…" entry. Re-seeds the open
// draft from its saved record on open (clean baseline), keeping whichever theme
// was last edited — or the active custom selection — in view. The snapshot is
// the dirty draft only, so browsing themes never trips a false discard prompt.
// Backdrop dismissal is off: config edits are real in-progress work.
themeBuilder: {
name: 'themeBuilder',
title: 'Theme Builder',
component: ThemeBuilderModal,
dismissOnBackdrop: false,
init: () => {
const store = useCustomThemeStore.getState();
const activeCustomId = customThemeIdOf(useAppStore.getState().chartTheme);
const id =
store.selectedId ??
(activeCustomId !== null && store.themes.some((t) => t.id === activeCustomId)
? activeCustomId
: (store.themes[0]?.id ?? null));
store.select(id);
},
getState: () => {
const s = useCustomThemeStore.getState();
return selectIsDraftDirty(s) ? { selectedId: s.selectedId, draft: s.draft } : null;
},
},
// Pure info modals — no state, no validity check, no discard prompt on close. // Pure info modals — no state, no validity check, no discard prompt on close.
// `about` is URL-navigable (reload-restore); `donate` is not (spec §01E hash // `about` is URL-navigable (reload-restore); `donate` is not (spec §01E hash
// table omits both, but `about` is still a permanent, bookmark-worthy surface). // table omits both, but `about` is still a permanent, bookmark-worthy surface).
+2 -1
View File
@@ -12,7 +12,8 @@ export type ModalName =
| 'about' // About & Help (M6) | 'about' // About & Help (M6)
| 'donate' // Donate (M6) | 'donate' // Donate (M6)
| 'chartBuilder' // Visual no-JSON chart composition for a dataset (M4) | 'chartBuilder' // Visual no-JSON chart composition for a dataset (M4)
| 'extract'; // Extract inline spec data into a new dataset (M3) | 'extract' // Extract inline spec data into a new dataset (M3)
| 'themeBuilder'; // Custom chart theme editor with a live preview gallery (spec §04)
// Settings is NOT a modal — preferences are distributed to per-pane disclosure // Settings is NOT a modal — preferences are distributed to per-pane disclosure
// popovers (spec §07; see components/SettingsPopover). // popovers (spec §07; see components/SettingsPopover).
+16
View File
@@ -10,14 +10,18 @@
import type { Snippet } from '@core/snippet'; import type { Snippet } from '@core/snippet';
import type { Dataset } from '@core/dataset'; import type { Dataset } from '@core/dataset';
import type { CustomTheme } from '@core/custom-theme';
import { loadSnippets } from '../infrastructure/snippet-store'; import { loadSnippets } from '../infrastructure/snippet-store';
import { loadDatasets } from '../infrastructure/dataset-store'; import { loadDatasets } from '../infrastructure/dataset-store';
import { loadCustomThemes } from '../infrastructure/theme-store';
import { storageErrorNotification } from '../services/storage-errors'; import { storageErrorNotification } from '../services/storage-errors';
import { notify } from '../stores/NotificationStore'; import { notify } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore'; import { useSnippetStore } from '../stores/SnippetStore';
import { useDatasetStore } from '../stores/DatasetStore'; import { useDatasetStore } from '../stores/DatasetStore';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { wirePersistence } from './persistence'; import { wirePersistence } from './persistence';
import { wireDatasetPersistence } from './dataset-persistence'; import { wireDatasetPersistence } from './dataset-persistence';
import { wireThemePersistence } from './theme-persistence';
import { startRouting } from '../modals/UrlStateSync'; import { startRouting } from '../modals/UrlStateSync';
import { startEventRouter } from './EventRouter'; import { startEventRouter } from './EventRouter';
@@ -47,13 +51,25 @@ export async function initApp(): Promise<void> {
notify(storageErrorNotification('load', err)); notify(storageErrorNotification('load', err));
} }
// Custom chart themes (spec §04 → Chart theme). Same failure posture: the
// picker just shows no custom entries, and a persisted custom selection
// renders as the house style until its record is available.
let themes: CustomTheme[] = [];
try {
themes = await loadCustomThemes();
} catch (err) {
notify(storageErrorNotification('load', err));
}
useSnippetStore.getState().hydrate(snippets); useSnippetStore.getState().hydrate(snippets);
useDatasetStore.getState().hydrate(datasets); useDatasetStore.getState().hydrate(datasets);
useCustomThemeStore.getState().hydrate(themes);
// Wire persistence AFTER hydrate so write-through's baseline is the loaded set // Wire persistence AFTER hydrate so write-through's baseline is the loaded set
// — otherwise it would redundantly re-save every record on each startup. // — otherwise it would redundantly re-save every record on each startup.
wirePersistence(); wirePersistence();
wireDatasetPersistence(); wireDatasetPersistence();
wireThemePersistence();
// Routing starts AFTER hydrate so the on-load hash restore can resolve snippet // Routing starts AFTER hydrate so the on-load hash restore can resolve snippet
// / dataset ids against the loaded stores (spec §01E, docs/architecture/04). // / dataset ids against the loaded stores (spec §01E, docs/architecture/04).
@@ -0,0 +1,47 @@
/**
* Custom theme persistence wiring (docs/architecture/01 §5; scope doc §4.4).
*
* The theme sibling of `dataset-persistence.ts`: a startup subscriber that
* diffs the `themes` array against the previous snapshot and writes
* upserts/deletes through to the IndexedDB adapter. The store stays
* browser-free; failures surface as a toast rather than silent loss. Themes
* change on explicit save/delete, so there is no debounce.
*/
import { deleteCustomTheme, saveCustomTheme } from '../infrastructure/theme-store';
import { notify } from '../stores/NotificationStore';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
type Unsubscribe = () => void;
function themeError(op: 'save' | 'delete', err: unknown) {
notify({
kind: 'error',
title: op === 'delete' ? "Couldn't delete the theme" : "Couldn't save the theme",
message:
'A storage error stopped Astrolabe from completing the last theme change, so it may not ' +
'survive a reload. If this keeps happening, your browser may be blocking local storage.',
detail: err instanceof Error ? `Theme ${op} failed: ${err.name}: ${err.message}` : String(err),
});
}
/** Persist theme upserts and deletions whenever the array changes. */
export function wireThemePersistence(): Unsubscribe {
let prevThemes = useCustomThemeStore.getState().themes;
return useCustomThemeStore.subscribe((s) => {
const next = s.themes;
if (next === prevThemes) return;
const prev = prevThemes;
prevThemes = next;
for (const old of prev) {
if (!next.some((n) => n.id === old.id)) {
deleteCustomTheme(old.id).catch((err) => themeError('delete', err));
}
}
for (const n of next) {
const old = prev.find((p) => p.id === n.id);
if (old !== n) saveCustomTheme(n).catch((err) => themeError('save', err));
}
});
}
+3 -3
View File
@@ -1,7 +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 { ChartThemeSelection } from '@core/vega-themes';
import type { ModalName } from '../modals/types'; import type { ModalName } from '../modals/types';
/** /**
@@ -25,7 +25,7 @@ export interface AppState {
/** 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`. */ /** Chart theme selection (spec §04); persisted to Settings as `chartTheme`. */
chartTheme: ChartThemeId; chartTheme: ChartThemeSelection;
/** The currently open modal, or null. */ /** The currently open modal, or null. */
activeModal: ModalName | null; activeModal: ModalName | null;
@@ -35,7 +35,7 @@ export interface AppState {
/** 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. */ /** Set the chart theme — the Live Preview settings cluster's action. */
setChartTheme: (theme: ChartThemeId) => void; setChartTheme: (theme: ChartThemeSelection) => 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,
+163
View File
@@ -0,0 +1,163 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useAppStore } from './AppStore';
import { selectIsDraftDirty, selectSelectedTheme, useCustomThemeStore } from './CustomThemeStore';
const store = () => useCustomThemeStore.getState();
beforeEach(() => {
store().reset();
useAppStore.getState().setChartTheme('astrolabe');
});
describe('createTheme', () => {
it('creates, selects, and opens the draft seeded from the config', () => {
store().createTheme('My theme', { font: 'Georgia' });
const s = store();
expect(s.themes).toHaveLength(1);
expect(s.themes[0].name).toBe('My theme');
expect(s.selectedId).toBe(s.themes[0].id);
expect(s.draft).toEqual({
name: 'My theme',
configText: JSON.stringify({ font: 'Georgia' }, null, 2),
});
expect(s.draftConfig).toEqual({ font: 'Georgia' });
});
it('auto-uniquifies the name (non-interactive path)', () => {
store().createTheme('Brand', {});
store().createTheme('Brand', {});
expect(store().themes.map((t) => t.name)).toEqual(['Brand', 'Brand 2']);
});
it('assigns fresh monotonic ids', () => {
store().createTheme('A', {});
store().createTheme('B', {});
const [a, b] = store().themes;
expect(b.id).toBe(a.id + 1);
});
});
describe('draft editing', () => {
beforeEach(() => store().createTheme('Brand', { font: 'Helvetica' }));
it('a valid config-text edit reparses for the gallery', () => {
store().updateDraft({ configText: '{"font": "Georgia"}' });
expect(store().draftConfig).toEqual({ font: 'Georgia' });
expect(store().parseError).toBeNull();
});
it('an invalid edit keeps the last valid config and reports the error', () => {
store().updateDraft({ configText: '{"font": ' });
expect(store().draftConfig).toEqual({ font: 'Helvetica' });
expect(store().parseError).toContain('Invalid JSON');
});
it('a non-object config is rejected with a shape error', () => {
store().updateDraft({ configText: '[1, 2]' });
expect(store().parseError).toContain('must be a JSON object');
});
it('empty config text parses as an empty config', () => {
store().updateDraft({ configText: ' ' });
expect(store().draftConfig).toEqual({});
expect(store().parseError).toBeNull();
});
it('dirty tracking: untouched draft is clean, edits make it dirty', () => {
expect(selectIsDraftDirty(store())).toBe(false);
store().updateDraft({ name: 'Brand 2026' });
expect(selectIsDraftDirty(store())).toBe(true);
});
it('applyDraftFont rewrites the draft text and parsed config', () => {
store().updateDraft({
configText: JSON.stringify({ font: 'Helvetica', axis: { labelFont: 'Helvetica' } }),
});
store().applyDraftFont('Georgia, serif');
expect(store().draftConfig).toEqual({
font: 'Georgia, serif',
axis: { labelFont: 'Georgia, serif' },
});
expect(store().draft?.configText).toContain('"labelFont": "Georgia, serif"');
});
it('applyDraftFont refuses while the text is invalid JSON', () => {
store().updateDraft({ configText: '{oops' });
store().applyDraftFont('Georgia');
expect(store().parseError).toContain('Invalid JSON');
expect(store().draft?.configText).toBe('{oops');
});
});
describe('saveDraft', () => {
beforeEach(() => store().createTheme('Brand', { font: 'Helvetica' }));
it('commits name and config and advances modified', () => {
const created = store().themes[0].modified;
store().updateDraft({ name: 'Brand 2026', configText: '{"font": "Georgia"}' });
const ok = store().saveDraft(new Date('2026-06-13T00:00:00Z'));
expect(ok).toBe(true);
const theme = store().themes[0];
expect(theme.name).toBe('Brand 2026');
expect(theme.config).toEqual({ font: 'Georgia' });
expect(theme.modified).not.toBe(created);
expect(selectIsDraftDirty(store())).toBe(false);
});
it('rejects an empty name', () => {
store().updateDraft({ name: ' ' });
expect(store().saveDraft()).toBe(false);
expect(store().saveError).toBe('Enter a theme name.');
});
it('rejects a name taken by another theme (case-insensitive), allows own', () => {
store().createTheme('Other', {});
store().updateDraft({ name: 'BRAND' });
expect(store().saveDraft()).toBe(false);
expect(store().saveError).toContain('already exists');
store().select(store().themes[0].id);
store().updateDraft({ name: 'Brand' });
expect(store().saveDraft()).toBe(true);
});
it('rejects invalid config text', () => {
store().updateDraft({ configText: '{nope' });
expect(store().saveDraft()).toBe(false);
expect(store().parseError).toContain('Invalid JSON');
});
});
describe('remove', () => {
it('drops the record and clears the open draft', () => {
store().createTheme('Brand', {});
const id = store().themes[0].id;
store().remove(id);
expect(store().themes).toHaveLength(0);
expect(store().selectedId).toBeNull();
expect(store().draft).toBeNull();
});
it('falls the active chart-theme selection back to the house style', () => {
store().createTheme('Brand', {});
const id = store().themes[0].id;
useAppStore.getState().setChartTheme(`custom:${id}`);
store().remove(id);
expect(useAppStore.getState().chartTheme).toBe('astrolabe');
});
it('leaves an unrelated selection alone', () => {
store().createTheme('Brand', {});
useAppStore.getState().setChartTheme('fivethirtyeight');
store().remove(store().themes[0].id);
expect(useAppStore.getState().chartTheme).toBe('fivethirtyeight');
});
});
describe('selectors', () => {
it('selectSelectedTheme resolves the open record', () => {
expect(selectSelectedTheme(store())).toBeNull();
store().createTheme('Brand', {});
expect(selectSelectedTheme(store())?.name).toBe('Brand');
});
});
+256
View File
@@ -0,0 +1,256 @@
/**
* Custom chart theme library + Theme Builder state (scope doc §4.4; spec §04).
*
* Holds the durable collection of saved custom themes plus the Theme Builder's
* working draft: which theme is open, its in-progress name and config text, the
* last config that parsed (what the builder's gallery renders), and the current
* validation message.
*
* Same two-layer shape as DatasetStore:
* - Low-level mutators (`add`/`update`/`remove`) are the single place the
* `themes` array changes; the persistence subscriber writes them through to
* IndexedDB.
* - Draft orchestration (`select`/`createTheme`/`updateDraft`/`applyDraftFont`/
* `saveDraft`) keeps the modal component thin and the logic testable.
*
* Persistence is NOT done here a startup subscriber observes this store and
* writes through to the IndexedDB adapter, so the store stays browser-free.
*/
import { create } from 'zustand';
import { applyFontToConfig, createCustomTheme, type CustomTheme } from '@core/custom-theme';
import { isNameTaken, makeUniqueName } from '@core/naming';
import { isJsonObject, type JsonObject } from '@core/spec-config';
import { customThemeSelection } from '@core/vega-themes';
import { useAppStore } from './AppStore';
/** The Theme Builder's in-progress edit of the selected theme. */
export interface ThemeDraft {
name: string;
/** The config as editable JSON text (pretty-printed on load). */
configText: string;
}
export interface CustomThemeState {
themes: CustomTheme[];
/** The theme open in the builder, or null (empty builder state). */
selectedId: number | null;
/** The in-progress edit, or null when nothing is selected. */
draft: ThemeDraft | null;
/**
* The last draft config that parsed what the builder's gallery renders.
* Editing through invalid JSON keeps the previous valid config on screen.
*/
draftConfig: JsonObject | null;
/** Live JSON parse error for the draft config text, or null when it parses. */
parseError: string | null;
/** Save-time validation message (name missing/taken), or null. */
saveError: string | null;
/** Replace the library from storage. */
hydrate: (themes: CustomTheme[]) => void;
/** Open a theme in the builder (or clear with null). Resets the draft to the saved record. */
select: (id: number | null) => void;
/**
* Create a new theme seeded with `config` and open it. The name is made
* unique automatically (non-interactive path never blocks on a collision).
*/
createTheme: (baseName: string, config: JsonObject, now?: Date) => CustomTheme;
/** Patch the draft. A config-text change re-parses (gallery follows valid states). */
updateDraft: (patch: Partial<ThemeDraft>) => void;
/**
* Apply a font family across the draft config (top-level `font` + every
* explicit font slot) and reformat the text. No-op with a parse error when
* the current text is invalid JSON fix the JSON first.
*/
applyDraftFont: (family: string) => void;
/**
* Validate and commit the draft to the selected theme. On failure sets
* `saveError`/`parseError` and returns false.
*/
saveDraft: (now?: Date) => boolean;
/** Low-level: add a fully-formed theme and select it. Returns the record with its assigned id. */
add: (theme: CustomTheme) => CustomTheme;
/** Low-level: merge a patch into a theme, advancing `modified`. */
update: (id: number, patch: Partial<CustomTheme>, now?: Date) => void;
/**
* Remove a theme. If it was the active chart-theme selection, the selection
* falls back to the house style (the same fallback rendering applies).
*/
remove: (id: number) => void;
/** Reset to initial state (tests). */
reset: () => void;
}
/** Next free numeric id — one past the max (same contract as `nextDatasetId`). */
function nextThemeId(themes: ReadonlyArray<CustomTheme>): number {
return themes.reduce((max, t) => Math.max(max, t.id), 0) + 1;
}
/** A theme's config as the builder's editable text. */
function configToText(config: JsonObject): string {
return JSON.stringify(config, null, 2);
}
/** Parse draft text into a config object, or an error message. */
function parseConfigText(text: string): { config: JsonObject } | { error: string } {
const trimmed = text.trim();
if (trimmed === '') return { config: {} };
try {
const parsed: unknown = JSON.parse(trimmed);
if (!isJsonObject(parsed)) return { error: 'The config must be a JSON object, like {...}.' };
return { config: parsed };
} catch (e) {
return { error: `Invalid JSON: ${(e as Error).message}` };
}
}
export const useCustomThemeStore = create<CustomThemeState>((set, get) => ({
themes: [],
selectedId: null,
draft: null,
draftConfig: null,
parseError: null,
saveError: null,
hydrate: (themes) => set({ themes }),
select: (id) => {
if (id === null) {
set({ selectedId: null, draft: null, draftConfig: null, parseError: null, saveError: null });
return;
}
const theme = get().themes.find((t) => t.id === id);
if (!theme) return;
set({
selectedId: id,
draft: { name: theme.name, configText: configToText(theme.config) },
draftConfig: theme.config,
parseError: null,
saveError: null,
});
},
createTheme: (baseName, config, now) => {
const name = makeUniqueName(
baseName,
get().themes.map((t) => t.name),
);
const theme = get().add(createCustomTheme({ name, config, now }));
// `add` selected the new id; open it into the draft.
get().select(theme.id);
return theme;
},
updateDraft: (patch) => {
const draft = get().draft;
if (!draft) return;
const next = { ...draft, ...patch };
if (patch.configText !== undefined && patch.configText !== draft.configText) {
const parsed = parseConfigText(next.configText);
if ('error' in parsed) {
set({ draft: next, parseError: parsed.error, saveError: null });
} else {
set({ draft: next, draftConfig: parsed.config, parseError: null, saveError: null });
}
return;
}
set({ draft: next, saveError: null });
},
applyDraftFont: (family) => {
const draft = get().draft;
if (!draft) return;
const parsed = parseConfigText(draft.configText);
if ('error' in parsed) {
set({ parseError: parsed.error });
return;
}
const config = applyFontToConfig(parsed.config, family);
set({
draft: { ...draft, configText: configToText(config) },
draftConfig: config,
parseError: null,
saveError: null,
});
},
saveDraft: (now) => {
const { draft, selectedId, themes } = get();
if (!draft || selectedId === null) return false;
const name = draft.name.trim();
if (name === '') {
set({ saveError: 'Enter a theme name.' });
return false;
}
if (isNameTaken(name, themes, selectedId)) {
set({ saveError: `A theme named "${name}" already exists. Choose a different name.` });
return false;
}
const parsed = parseConfigText(draft.configText);
if ('error' in parsed) {
set({ parseError: parsed.error });
return false;
}
get().update(selectedId, { name, config: parsed.config }, now);
// Re-seed the draft from the committed record so the baseline is clean.
get().select(selectedId);
return true;
},
add: (theme) => {
const withId = { ...theme, id: nextThemeId(get().themes) };
set((s) => ({ themes: [...s.themes, withId], selectedId: withId.id }));
return withId;
},
update: (id, patch, now) => {
const modified = patch.modified ?? (now ?? new Date()).toISOString();
set((s) => ({
themes: s.themes.map((t) => (t.id === id ? { ...t, ...patch, modified } : t)),
}));
},
remove: (id) => {
set((s) => ({
themes: s.themes.filter((t) => t.id !== id),
...(s.selectedId === id
? { selectedId: null, draft: null, draftConfig: null, parseError: null, saveError: null }
: {}),
}));
// The picker selection can't point at a deleted record; fall back to the
// house style explicitly so the persisted preference stays meaningful.
const app = useAppStore.getState();
if (app.chartTheme === customThemeSelection(id)) app.setChartTheme('astrolabe');
},
reset: () =>
set({
themes: [],
selectedId: null,
draft: null,
draftConfig: null,
parseError: null,
saveError: null,
}),
}));
/** Selector: the saved record the builder has open, or null. Derive — never store. */
export const selectSelectedTheme = (s: CustomThemeState): CustomTheme | null =>
s.themes.find((t) => t.id === s.selectedId) ?? null;
/**
* Selector: whether the draft differs from its saved record drives the Save
* button and the modal's unsaved-change snapshot. Text-level comparison against
* the pretty-printed saved config: `select`/`saveDraft` seed the draft from
* exactly that text, so an untouched draft is never dirty.
*/
export const selectIsDraftDirty = (s: CustomThemeState): boolean => {
const theme = selectSelectedTheme(s);
if (!theme || !s.draft) return false;
return s.draft.name !== theme.name || s.draft.configText !== configToText(theme.config);
};
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest';
import {
CURRENT_THEME_VERSION,
THEME_FONT_OPTIONS,
applyFontToConfig,
createCustomTheme,
} from './custom-theme';
import { THEME_PREVIEW_SPECS } from './theme-preview-specs';
describe('createCustomTheme', () => {
it('stamps version, timestamps, and carries the config through', () => {
const now = new Date('2026-06-12T10:00:00Z');
const theme = createCustomTheme({ name: 'Brand', config: { font: 'Georgia' }, now });
expect(theme.version).toBe(CURRENT_THEME_VERSION);
expect(theme.name).toBe('Brand');
expect(theme.config).toEqual({ font: 'Georgia' });
expect(theme.created).toBe(now.toISOString());
expect(theme.modified).toBe(now.toISOString());
});
});
describe('applyFontToConfig', () => {
it('sets the top-level font on an empty config', () => {
expect(applyFontToConfig({}, 'Georgia, serif')).toEqual({ font: 'Georgia, serif' });
});
it('rewrites every explicit font slot at any depth', () => {
const config = {
font: 'Helvetica',
title: { font: 'Helvetica', subtitleFont: 'Helvetica', fontSize: 16 },
axis: { labelFont: 'Helvetica', titleFont: 'Helvetica', labelFontSize: 11 },
axisX: { labelFont: 'Helvetica' },
legend: { labelFont: 'Helvetica', titleFont: 'Helvetica' },
header: { labelFont: 'Helvetica', titleFont: 'Helvetica' },
text: { font: 'Helvetica' },
};
const out = applyFontToConfig(config, 'Georgia');
expect(out).toEqual({
font: 'Georgia',
title: { font: 'Georgia', subtitleFont: 'Georgia', fontSize: 16 },
axis: { labelFont: 'Georgia', titleFont: 'Georgia', labelFontSize: 11 },
axisX: { labelFont: 'Georgia' },
legend: { labelFont: 'Georgia', titleFont: 'Georgia' },
header: { labelFont: 'Georgia', titleFont: 'Georgia' },
text: { font: 'Georgia' },
});
});
it('leaves non-font properties (including fontSize/fontWeight) untouched', () => {
const config = {
background: '#fff',
title: { fontSize: 16, fontWeight: 600 },
range: { category: ['#111', '#222'] },
};
expect(applyFontToConfig(config, 'Georgia')).toEqual({ ...config, font: 'Georgia' });
});
it('does not mutate the input', () => {
const config = { title: { font: 'Helvetica' } };
applyFontToConfig(config, 'Georgia');
expect(config).toEqual({ title: { font: 'Helvetica' } });
});
});
describe('THEME_FONT_OPTIONS', () => {
it('offers distinct, non-empty CSS stacks', () => {
expect(THEME_FONT_OPTIONS.length).toBeGreaterThanOrEqual(4);
const values = THEME_FONT_OPTIONS.map((f) => f.value);
expect(new Set(values).size).toBe(values.length);
expect(values.every((v) => v.trim().length > 0)).toBe(true);
});
});
describe('THEME_PREVIEW_SPECS', () => {
it('every gallery card is a self-contained inline-data spec', () => {
expect(THEME_PREVIEW_SPECS.length).toBeGreaterThanOrEqual(6);
for (const card of THEME_PREVIEW_SPECS) {
expect(card.id).toBeTruthy();
expect(card.caption).toBeTruthy();
const data = card.spec.data as { values?: unknown[] };
expect(Array.isArray(data.values)).toBe(true);
expect(data.values!.length).toBeGreaterThan(0);
expect(card.spec.$schema).toContain('vega-lite');
}
});
it('card ids are unique', () => {
const ids = THEME_PREVIEW_SPECS.map((c) => c.id);
expect(new Set(ids).size).toBe(ids.length);
});
});
+101
View File
@@ -0,0 +1,101 @@
/**
* Custom chart theme a user-named Vega-Lite config saved in the library
* (docs/chart-theming-scope.md §4.4; spec §04 Chart theme).
*
* Portable core: record shape, factory, and the pure config transforms the
* Theme Builder runs. A custom theme is "whatever config the user saved" it
* is injected at embed time exactly like a preset (vega-embed `opt.config`),
* so a snippet's own `config` still overrides it property by property.
*/
import { isJsonObject, type JsonObject } from './spec-config';
/** Current schema version for a CustomTheme record (read-time migration target). */
export const CURRENT_THEME_VERSION = 1;
export interface CustomTheme {
/** Unique numeric identifier (IndexedDB key). */
id: number;
/** Record schema version, for read-time migration. */
version: number;
/** Unique, human-readable name shown in the chart-theme picker. */
name: string;
/** The Vega-Lite config injected when this theme is selected. */
config: JsonObject;
/** ISO timestamp — when first created. */
created: string;
/** ISO timestamp — when last changed. */
modified: string;
}
/**
* Build a new CustomTheme. The default id is provisional the store's id
* authority reassigns it on insert (same contract as `createDataset`).
*/
export function createCustomTheme(opts: {
name: string;
config: JsonObject;
now?: Date;
}): CustomTheme {
const iso = (opts.now ?? new Date()).toISOString();
return {
id: Date.now(),
version: CURRENT_THEME_VERSION,
name: opts.name,
config: opts.config,
created: iso,
modified: iso,
};
}
/**
* Apply one font family across a config: sets the top-level `font` (Vega-Lite's
* default for every text mark, label, and title) AND rewrites every explicit
* font slot already present anywhere in the config `font`, `labelFont`,
* `titleFont`, `subtitleFont`, at any nesting depth (`axis`, `axisX`,
* `legend`, `header`, `title`, mark configs). The explicit slots must be
* rewritten because they would otherwise keep overriding the new top-level
* default this is exactly the "populate the font in many places" job the
* Theme Builder's font control does. Returns a new object; input not mutated.
*/
export function applyFontToConfig(config: JsonObject, family: string): JsonObject {
const walk = (obj: JsonObject): JsonObject => {
const out: JsonObject = {};
for (const [key, value] of Object.entries(obj)) {
if ((key === 'font' || key.endsWith('Font')) && typeof value === 'string') {
out[key] = family;
} else if (isJsonObject(value)) {
out[key] = walk(value);
} else {
out[key] = value;
}
}
return out;
};
return { ...walk(config), font: family };
}
/** A font choice the Theme Builder's font control offers. */
export interface ThemeFontOption {
/** The CSS family stack written into the config. */
value: string;
/** Display name. */
label: string;
}
/**
* Fonts the builder can apply today: the two self-hosted Plex faces the app
* already loads, plus web-safe/system stacks that need no loading at all. Every
* entry is render-safe without a `document.fonts.load` gate Plex is loaded by
* the UI before any chart renders, the rest resolve to locally installed faces.
* The self-hosted roster (scope doc §4.5) extends this list and brings the
* pre-render loading gate with it.
*/
export const THEME_FONT_OPTIONS: ReadonlyArray<ThemeFontOption> = [
{ value: '"IBM Plex Sans", system-ui, -apple-system, sans-serif', label: 'IBM Plex Sans' },
{ value: '"IBM Plex Mono", ui-monospace, monospace', label: 'IBM Plex Mono' },
{ value: 'system-ui, -apple-system, sans-serif', label: 'System UI' },
{ value: 'Helvetica, Arial, sans-serif', label: 'Helvetica / Arial' },
{ value: 'Georgia, "Times New Roman", serif', label: 'Georgia' },
{ value: '"Courier New", Courier, monospace', label: 'Courier' },
];
+224
View File
@@ -0,0 +1,224 @@
/**
* Theme Builder gallery specs (docs/chart-theming-scope.md §4.4).
*
* A fixed set of small, self-contained Vega-Lite specs the Theme Builder
* renders side by side with the draft config, so an edit is previewed across
* every chart surface a config styles: titles and subtitles, axes and grids,
* categorical and gradient legends, facet headers, and the major mark types.
* Inline data only, compact fixed sizes these are swatches, not analyses.
*/
import type { JsonObject } from './spec-config';
const SCHEMA = 'https://vega.github.io/schema/vega-lite/v5.json';
export interface ThemePreviewSpec {
/** Stable key for React lists and test assertions. */
id: string;
/** What surface this card exercises (shown as the card caption). */
caption: string;
spec: JsonObject;
}
const bar: ThemePreviewSpec = {
id: 'bar',
caption: 'Bar — title, axes, grid',
spec: {
$schema: SCHEMA,
title: 'Revenue by region',
width: 200,
height: 140,
data: {
values: [
{ region: 'North', revenue: 42 },
{ region: 'South', revenue: 61 },
{ region: 'East', revenue: 28 },
{ region: 'West', revenue: 55 },
{ region: 'Central', revenue: 47 },
],
},
mark: 'bar',
encoding: {
x: { field: 'region', type: 'nominal', axis: { labelAngle: 0 } },
y: { field: 'revenue', type: 'quantitative' },
},
},
};
const line: ThemePreviewSpec = {
id: 'line',
caption: 'Line — subtitle, series legend',
spec: {
$schema: SCHEMA,
title: { text: 'Signups over time', subtitle: 'Weekly, by plan' },
width: 200,
height: 140,
data: {
values: [
{ week: 1, plan: 'Free', n: 20 },
{ week: 2, plan: 'Free', n: 28 },
{ week: 3, plan: 'Free', n: 26 },
{ week: 4, plan: 'Free', n: 34 },
{ week: 1, plan: 'Pro', n: 8 },
{ week: 2, plan: 'Pro', n: 11 },
{ week: 3, plan: 'Pro', n: 17 },
{ week: 4, plan: 'Pro', n: 21 },
{ week: 1, plan: 'Team', n: 3 },
{ week: 2, plan: 'Team', n: 4 },
{ week: 3, plan: 'Team', n: 9 },
{ week: 4, plan: 'Team', n: 12 },
],
},
mark: { type: 'line', point: true },
encoding: {
x: { field: 'week', type: 'quantitative', axis: { tickCount: 4 } },
y: { field: 'n', type: 'quantitative' },
color: { field: 'plan', type: 'nominal' },
},
},
};
const area: ThemePreviewSpec = {
id: 'area',
caption: 'Stacked area — categorical palette',
spec: {
$schema: SCHEMA,
width: 200,
height: 140,
data: {
values: [
{ q: 1, channel: 'Web', v: 30 },
{ q: 2, channel: 'Web', v: 36 },
{ q: 3, channel: 'Web', v: 41 },
{ q: 4, channel: 'Web', v: 38 },
{ q: 1, channel: 'Store', v: 22 },
{ q: 2, channel: 'Store', v: 19 },
{ q: 3, channel: 'Store', v: 24 },
{ q: 4, channel: 'Store', v: 27 },
{ q: 1, channel: 'Partner', v: 12 },
{ q: 2, channel: 'Partner', v: 16 },
{ q: 3, channel: 'Partner', v: 14 },
{ q: 4, channel: 'Partner', v: 18 },
],
},
mark: 'area',
encoding: {
x: { field: 'q', type: 'quantitative', axis: { tickCount: 4 } },
y: { field: 'v', type: 'quantitative' },
color: { field: 'channel', type: 'nominal' },
},
},
};
const scatter: ThemePreviewSpec = {
id: 'scatter',
caption: 'Scatter — gradient legend',
spec: {
$schema: SCHEMA,
width: 200,
height: 140,
data: {
values: [
{ x: 4, y: 7, z: 12 },
{ x: 8, y: 3, z: 31 },
{ x: 12, y: 11, z: 45 },
{ x: 16, y: 6, z: 22 },
{ x: 20, y: 14, z: 60 },
{ x: 24, y: 9, z: 38 },
{ x: 28, y: 17, z: 74 },
{ x: 32, y: 12, z: 51 },
{ x: 36, y: 20, z: 88 },
],
},
mark: { type: 'point', filled: true, size: 80 },
encoding: {
x: { field: 'x', type: 'quantitative' },
y: { field: 'y', type: 'quantitative' },
color: { field: 'z', type: 'quantitative' },
},
},
};
const heatmap: ThemePreviewSpec = {
id: 'heatmap',
caption: 'Heatmap — sequential scale',
spec: {
$schema: SCHEMA,
width: 200,
height: 140,
data: {
values: ['Mon', 'Tue', 'Wed', 'Thu'].flatMap((day, d) =>
['AM', 'Noon', 'PM'].map((slot, s) => ({ day, slot, v: (d + 1) * (s + 2) * 3 })),
),
},
mark: 'rect',
encoding: {
x: { field: 'day', type: 'nominal', axis: { labelAngle: 0 } },
y: { field: 'slot', type: 'nominal' },
color: { field: 'v', type: 'quantitative' },
},
},
};
const donut: ThemePreviewSpec = {
id: 'donut',
caption: 'Donut — palette, symbol legend',
spec: {
$schema: SCHEMA,
width: 200,
height: 140,
data: {
values: [
{ browser: 'Firefox', share: 32 },
{ browser: 'Chrome', share: 41 },
{ browser: 'Safari', share: 18 },
{ browser: 'Other', share: 9 },
],
},
mark: { type: 'arc', innerRadius: 32 },
encoding: {
theta: { field: 'share', type: 'quantitative' },
color: { field: 'browser', type: 'nominal' },
},
},
};
const facet: ThemePreviewSpec = {
id: 'facet',
caption: 'Facets — header labels',
spec: {
$schema: SCHEMA,
width: 70,
height: 110,
data: {
values: [
{ team: 'Alpha', month: 'Jan', v: 14 },
{ team: 'Alpha', month: 'Feb', v: 21 },
{ team: 'Alpha', month: 'Mar', v: 17 },
{ team: 'Beta', month: 'Jan', v: 9 },
{ team: 'Beta', month: 'Feb', v: 13 },
{ team: 'Beta', month: 'Mar', v: 19 },
{ team: 'Gamma', month: 'Jan', v: 11 },
{ team: 'Gamma', month: 'Feb', v: 8 },
{ team: 'Gamma', month: 'Mar', v: 15 },
],
},
mark: 'bar',
encoding: {
x: { field: 'month', type: 'nominal', axis: { labelAngle: 0 } },
y: { field: 'v', type: 'quantitative' },
column: { field: 'team', type: 'nominal' },
},
},
};
/** The gallery, in display order. */
export const THEME_PREVIEW_SPECS: ReadonlyArray<ThemePreviewSpec> = [
bar,
line,
area,
scatter,
heatmap,
donut,
facet,
];
+52
View File
@@ -3,7 +3,11 @@ import {
CHART_THEME_OPTIONS, CHART_THEME_OPTIONS,
chartConfigFor, chartConfigFor,
chartConfigForSelection, chartConfigForSelection,
chartThemeOptions,
customThemeIdOf,
customThemeSelection,
isChartThemeId, isChartThemeId,
isChartThemeSelection,
darkBaseConfig, darkBaseConfig,
darkChartConfig, darkChartConfig,
darkExpressiveConfig, darkExpressiveConfig,
@@ -123,3 +127,51 @@ describe('isChartThemeId', () => {
expect(isChartThemeId(7)).toBe(false); expect(isChartThemeId(7)).toBe(false);
}); });
}); });
describe('custom theme selections', () => {
const themes = [
{ id: 3, name: 'Brand', config: { font: 'Georgia', background: '#fff8f0' } },
{ id: 9, name: 'Mono', config: { font: 'Courier' } },
];
it('round-trips an id through the selection string', () => {
expect(customThemeSelection(3)).toBe('custom:3');
expect(customThemeIdOf('custom:3')).toBe(3);
});
it('customThemeIdOf rejects non-custom and malformed values', () => {
expect(customThemeIdOf('astrolabe')).toBeNull();
expect(customThemeIdOf('custom:')).toBeNull();
expect(customThemeIdOf('custom:abc')).toBeNull();
expect(customThemeIdOf('custom:1.5')).toBeNull();
});
it('isChartThemeSelection accepts built-ins, presets, and custom ids', () => {
expect(isChartThemeSelection('astrolabe')).toBe(true);
expect(isChartThemeSelection('fivethirtyeight')).toBe(true);
expect(isChartThemeSelection('custom:42')).toBe(true);
expect(isChartThemeSelection('custom:nope')).toBe(false);
expect(isChartThemeSelection('comic-sans')).toBe(false);
});
it('resolves a custom selection to its saved config', () => {
expect(chartConfigForSelection('custom:3', 'light', themes)).toBe(themes[0].config);
});
it('falls back to the house config when the record is missing (deleted / not hydrated)', () => {
expect(chartConfigForSelection('custom:404', 'light', themes)).toBe(lightChartConfig);
expect(chartConfigForSelection('custom:404', 'dark', themes)).toBe(darkChartConfig);
});
it('lists custom themes after the built-ins and before the presets', () => {
const options = chartThemeOptions(themes);
const values = options.map((o) => o.value);
expect(values.slice(0, 4)).toEqual(['astrolabe', 'stock', 'custom:3', 'custom:9']);
expect(values.length).toBe(CHART_THEME_OPTIONS.length + themes.length);
expect(options[2].label).toBe('Brand');
});
it('lists no custom entries when the library has none', () => {
expect(chartThemeOptions([])).toEqual([...CHART_THEME_OPTIONS]);
});
});
+63 -4
View File
@@ -28,6 +28,7 @@ import type { Config } from 'vega-lite';
// Preset chart styles from the vega-themes package (already in the dependency // 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. // tree via vega-embed). Pure data — config objects only — so portable for core.
import * as presets from 'vega-themes'; import * as presets from 'vega-themes';
import type { CustomTheme } from './custom-theme';
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';
@@ -186,7 +187,7 @@ export type ChartThemePresetId = (typeof PRESET_IDS)[number];
const STOCK_CONFIG: Config = {}; const STOCK_CONFIG: Config = {};
export interface ChartThemeOption { export interface ChartThemeOption {
value: ChartThemeId; value: ChartThemeSelection;
label: string; label: string;
/** Secondary line for pickers (what the choice means). */ /** Secondary line for pickers (what the choice means). */
detail?: string; detail?: string;
@@ -219,13 +220,71 @@ export function isChartThemeId(value: unknown): value is ChartThemeId {
return typeof value === 'string' && CHART_THEME_IDS.has(value); return typeof value === 'string' && CHART_THEME_IDS.has(value);
} }
/**
* A saved custom theme as a selection `custom:<record id>`. The numeric id
* (not the name) keys the selection so a rename never invalidates it.
*/
export type CustomThemeSelection = `custom:${number}`;
/** Everything the chart-theme picker can hold: built-ins, presets, or a custom theme. */
export type ChartThemeSelection = ChartThemeId | CustomThemeSelection;
/** The picker/persistence id for a custom theme record. */
export function customThemeSelection(id: number): CustomThemeSelection {
return `custom:${id}`;
}
/** The record id inside a `custom:<id>` selection, or null for any other value. */
export function customThemeIdOf(selection: string): number | null {
const match = /^custom:(\d+)$/.exec(selection);
return match ? Number(match[1]) : null;
}
/**
* Type guard for persisted selections (load-with-fallback). A `custom:<id>`
* passes on shape alone whether the record still exists is only knowable
* after the async theme hydration, so resolution (not validation) handles a
* deleted id by falling back to the house config.
*/
export function isChartThemeSelection(value: unknown): value is ChartThemeSelection {
return isChartThemeId(value) || (typeof value === 'string' && customThemeIdOf(value) !== null);
}
/**
* The full picker option list: built-ins, the user's saved themes (by name, in
* library order), then the presets. Pure derivation callers memoize.
*/
export function chartThemeOptions(
customThemes: ReadonlyArray<Pick<CustomTheme, 'id' | 'name'>>,
): ChartThemeOption[] {
const custom: ChartThemeOption[] = customThemes.map((t) => ({
value: customThemeSelection(t.id),
label: t.name,
detail: 'Custom theme',
}));
// Built-ins first, the user's own themes next, the preset roster last.
return [...CHART_THEME_OPTIONS.slice(0, 2), ...custom, ...CHART_THEME_OPTIONS.slice(2)];
}
/** /**
* Resolve the user's chart-theme selection to the config to inject at embed * 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 * time. `'astrolabe'` follows the UI theme; presets ignore it (their look is
* fixed that's the point of previewing a destination style). * fixed that's the point of previewing a destination style). A `custom:<id>`
* resolves to that saved theme's config; an id with no record (not yet
* hydrated, or deleted elsewhere) falls back to the house config rather than
* rendering unstyled.
*/ */
export function chartConfigForSelection(selection: ChartThemeId, uiTheme: UiTheme): Config { export function chartConfigForSelection(
selection: ChartThemeSelection,
uiTheme: UiTheme,
customThemes: ReadonlyArray<Pick<CustomTheme, 'id' | 'config'>> = [],
): Config {
const customId = customThemeIdOf(selection);
if (customId !== null) {
const theme = customThemes.find((t) => t.id === customId);
return theme ? theme.config : CHART_CONFIG[uiTheme];
}
if (selection === 'astrolabe') return CHART_CONFIG[uiTheme]; if (selection === 'astrolabe') return CHART_CONFIG[uiTheme];
if (selection === 'stock') return STOCK_CONFIG; if (selection === 'stock') return STOCK_CONFIG;
return presets[selection] as Config; return presets[selection as ChartThemePresetId] as Config;
} }