mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Chart theming: custom named themes + Theme Builder
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user