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