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
|
### 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
|
||||||
|
|||||||
@@ -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 2–3)** — **theme selector + merge/extract shipped.**
|
- **2026-06-12 (slices 2–3)** — **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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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`).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Generated
+11
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 old→new 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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' },
|
||||||
|
];
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user