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
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_.
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.
6. If the tier has a budget, hook it into the storage monitor and propagate `QuotaExceededError`.
7. Test the adapter against `fake-indexeddb` / a localStorage stub; test the migration with fixtures from each historical version.
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. 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
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);
- `'stock'` resolves to `{}` — nothing injected, pure Vega-Lite defaults;
- preset ids resolve to the `vega-themes` package's configs verbatim (the same
presets as the Vega editor's theme dropdown; the package is already in the
tree as a vega-embed dependency).
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
lives in `AppStore.chartTheme`, persisted as `ui.chartTheme` by
`orchestration/preferences.ts` (the `previewFitMode` pattern), and is surfaced
by a `SelectControl` in the LivePreview header — **not** inside the
PreviewSettings popover: `SelectControl` and `SettingsPopover` share the
one-open-popover registry, so a select nested in the popover would close (and
unmount) its own parent on open.
`chartConfigForSelection(selection, uiTheme, customThemes)` is the only
resolver; `chartThemeOptions(customThemes)` derives the full picker list
(built-ins, customs, presets — memoize the call: it returns a fresh array). The
choice lives in `AppStore.chartTheme`, persisted as `ui.chartTheme` by
`orchestration/preferences.ts` (the `previewFitMode` pattern; persistence
validates with `isChartThemeSelection`, which accepts `custom:<id>` on shape
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
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,
render-identical), or lift `spec.config` out to the clipboard (copy before remove —
a failed copy aborts).
4. **Custom named themes** — new IndexedDB entity `{ name, config, fonts? }` + list UI;
created by duplicating a preset or extract-from-spec; appears in the slice-2
selector. Export/import as JSON alongside the library.
4. **Custom named themes** ✅ (2026-06-12, except export/import) — IndexedDB entity
`{ id, name, config }` (`core/custom-theme.ts`, themes store @ DB v2) + the **Theme
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
metadata (which themes/fonts pair), `document.fonts.load` gate in the render path,
precache strategy above. Roster finalized via visual specimen.
@@ -158,6 +166,22 @@ it without a second mechanism).
## 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.**
`ChartThemeId`/`chartConfigForSelection` in core; `ui.chartTheme` persisted via the
`previewFitMode` orchestration pattern; `SelectControl` picker in the preview header;
+3 -3
View File
@@ -46,12 +46,12 @@ Notes:
## 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.
- 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.
- 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.
## 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.
- **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.
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_).
- 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.
- 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
+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.
| Field | Type | Meaning |
| ----------------------------- | ------- | ---------------------------------------------------------- |
| `version` | number | Schema version of the settings record, used for migration. |
| `editor.fontSize` | number | Editor font size. |
| `editor.theme` | string | Editor color theme identifier. |
| `editor.minimap` | boolean | Whether the editor minimap is shown. |
| `editor.wordWrap` | string | `on` or `off`. |
| `editor.lineNumbers` | string | `on` or `off`. |
| `editor.tabSize` | number | Spaces per indentation level. |
| `performance.renderDebounce` | number | Delay (ms) before re-rendering the preview after edits. |
| `ui.theme` | string | App theme: `light` or `dark`. |
| `ui.previewFitMode` | string | Preview sizing: `default`, `width`, `height`, or `full`. |
| `ui.chartTheme` | string | Chart theme: `astrolabe`, `stock`, or a preset id. |
| `formatting.dateFormat` | string | Date display mode: `smart`, `iso`, or `custom`. |
| `formatting.customDateFormat` | string | Pattern used when `dateFormat = custom`. |
| Field | Type | Meaning |
| ----------------------------- | ------- | -------------------------------------------------------------------------------------------- |
| `version` | number | Schema version of the settings record, used for migration. |
| `editor.fontSize` | number | Editor font size. |
| `editor.theme` | string | Editor color theme identifier. |
| `editor.minimap` | boolean | Whether the editor minimap is shown. |
| `editor.wordWrap` | string | `on` or `off`. |
| `editor.lineNumbers` | string | `on` or `off`. |
| `editor.tabSize` | number | Spaces per indentation level. |
| `performance.renderDebounce` | number | Delay (ms) before re-rendering the preview after edits. |
| `ui.theme` | string | App theme: `light` or `dark`. |
| `ui.previewFitMode` | string | Preview sizing: `default`, `width`, `height`, or `full`. |
| `ui.chartTheme` | string | Chart theme: `astrolabe`, `stock`, a preset id, or `custom:<id>` naming a _CustomTheme_ (G). |
| `formatting.dateFormat` | string | Date display mode: `smart`, `iso`, or `custom`. |
| `formatting.customDateFormat` | string | Pattern used when `dateFormat = custom`. |
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_). |
| 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. |
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.
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
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)
- **Drag-and-drop field assignment** — chips are click/keyboard-first by design; drag would