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
|
||||
|
||||
@@ -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 2–3)** — **theme selector + merge/extract shipped.**
|
||||
`ChartThemeId`/`chartConfigForSelection` in core; `ui.chartTheme` persisted via the
|
||||
`previewFitMode` orchestration pattern; `SelectControl` picker in the preview header;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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`).
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user