Chart theming: custom named themes + Theme Builder

This commit is contained in:
2026-06-12 18:15:54 +03:00
parent 44a601affd
commit b193464f55
32 changed files with 2220 additions and 70 deletions
+42 -6
View File
@@ -15,21 +15,27 @@
* (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 type { VisualizationSpec } from 'vega-embed';
import type { FitMode } 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 { useAppStore } from '../stores/AppStore';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { usePreviewStore } from '../stores/PreviewStore';
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { useUserSettingsStore } from '../stores/UserSettingsStore';
import { ChartExport } from './ChartExport';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { SelectControl } from './SelectControl';
import { SelectControl, type SelectControlOption } from './SelectControl';
import { RangeControl, SettingRow, SettingsPopover } from './SettingsPopover';
import styles from './LivePreview.module.css';
@@ -78,16 +84,42 @@ function FitControl() {
* (and unmount) its own parent on open.
*/
// 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() {
const chartTheme = useAppStore((s) => s.chartTheme);
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 (
<SelectControl
id="preview-chart-theme"
label="Chart theme"
options={CHART_THEME_OPTIONS}
options={options}
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"
/>
);
@@ -126,6 +158,9 @@ export function LivePreview() {
const fitMode = useAppStore((s) => s.previewFitMode);
const uiTheme = useAppStore((s) => s.uiTheme);
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
// dataset change keeps a referencing chart live as its data is edited.
const datasets = useDatasetStore(useShallow((s) => s.datasets));
@@ -238,7 +273,7 @@ export function LivePreview() {
try {
const prepared = prepareSpecForRender(parsed, { fitMode, datasets });
const config = chartConfigForSelection(chartTheme, uiTheme);
const config = chartConfigForSelection(chartTheme, uiTheme, customThemes);
handleRef.current?.destroy();
handleRef.current = null;
const handle = await renderSpec(node, prepared as VisualizationSpec, config);
@@ -286,6 +321,7 @@ export function LivePreview() {
fitMode,
uiTheme,
chartTheme,
customThemes,
datasets,
setError,
setBusy,
+5 -4
View File
@@ -23,10 +23,11 @@ export function ModalShell() {
const name = useAppStore((s) => s.activeModal);
const config = getModalConfig(name);
// The Chart Builder is a near-fullscreen work surface; the Datasets manager is the
// standard large two-pane modal; everything else is a small form. Both large kinds
// get the static-title initial focus (APG dialog-modal) so content isn't skipped.
const isXLarge = name === 'chartBuilder';
// The Chart Builder and Theme Builder are near-fullscreen work surfaces; the
// Datasets manager is the standard large two-pane modal; everything else is a
// small form. Both large kinds get the static-title initial focus (APG
// dialog-modal) so content isn't skipped.
const isXLarge = name === 'chartBuilder' || name === 'themeBuilder';
const isLarge = name === 'datasets' || isXLarge;
// 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');
});
});
+264
View File
@@ -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>
);
}
+116
View File
@@ -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' }]);
});
});
+49 -13
View File
@@ -11,35 +11,71 @@ const DB_NAME = 'astrolabe';
/**
* Store-layout version. Bump only when the set of object stores / indexes
* 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 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;
/** Open (and memoize) the database, creating object stores on first run. */
export function openDB(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
/**
* One `indexedDB.open` as a promise. Omitting `version` opens at whatever
* version the database already has (never an upgrade). The upgrade handler
* creates every missing store — guarded per store, so it is idempotent across
* any old→new version jump.
*/
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 = () => {
const db = req.result;
// Guard every create so upgrades stay idempotent.
if (!db.objectStoreNames.contains(SNIPPETS_STORE)) {
db.createObjectStore(SNIPPETS_STORE, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains(DATASETS_STORE)) {
db.createObjectStore(DATASETS_STORE, { keyPath: 'id' });
for (const store of EXPECTED_STORES) {
if (!db.objectStoreNames.contains(store)) {
db.createObjectStore(store, { keyPath: 'id' });
}
}
};
req.onsuccess = () => resolve(req.result);
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;
}
+10 -6
View File
@@ -19,7 +19,7 @@
import type { FitMode } from '@core/rendering';
import { loadSettings, type UserSettings } from '@core/settings';
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';
@@ -30,7 +30,7 @@ const DEFAULT_THEME: UiTheme = 'light';
const DEFAULT_FIT_MODE: FitMode = 'default';
/** 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). */
export type ManagedSettings = Pick<UserSettings, 'editor' | 'performance' | 'formatting'>;
@@ -122,14 +122,18 @@ export function loadPreviewFitMode(): FitMode {
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;
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. */
export function saveChartTheme(chartTheme: ChartThemeId): void {
export function saveChartTheme(chartTheme: ChartThemeSelection): void {
const current = readRaw();
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(),
};
}
+27
View File
@@ -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);
}
+30
View File
@@ -17,12 +17,16 @@
import type { ComponentType } from 'react';
import type { ActiveModal, ModalName } from './types';
import { customThemeIdOf } from '@core/vega-themes';
import { AboutModal } from '../components/AboutModal';
import { ChartBuilderModal } from '../components/ChartBuilderModal';
import { DatasetsModal } from '../components/DatasetsModal';
import { DonateModal } from '../components/DonateModal';
import { ExtractModal } from '../components/ExtractModal';
import { ThemeBuilderModal } from '../components/ThemeBuilderModal';
import { useAppStore } from '../stores/AppStore';
import { useChartBuilderStore } from '../stores/ChartBuilderStore';
import { selectIsDraftDirty, useCustomThemeStore } from '../stores/CustomThemeStore';
import { useDatasetStore } from '../stores/DatasetStore';
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),
},
// 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.
// `about` is URL-navigable (reload-restore); `donate` is not (spec §01E hash
// table omits both, but `about` is still a permanent, bookmark-worthy surface).
+2 -1
View File
@@ -12,7 +12,8 @@ export type ModalName =
| 'about' // About & Help (M6)
| 'donate' // Donate (M6)
| '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
// popovers (spec §07; see components/SettingsPopover).
+16
View File
@@ -10,14 +10,18 @@
import type { Snippet } from '@core/snippet';
import type { Dataset } from '@core/dataset';
import type { CustomTheme } from '@core/custom-theme';
import { loadSnippets } from '../infrastructure/snippet-store';
import { loadDatasets } from '../infrastructure/dataset-store';
import { loadCustomThemes } from '../infrastructure/theme-store';
import { storageErrorNotification } from '../services/storage-errors';
import { notify } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { wirePersistence } from './persistence';
import { wireDatasetPersistence } from './dataset-persistence';
import { wireThemePersistence } from './theme-persistence';
import { startRouting } from '../modals/UrlStateSync';
import { startEventRouter } from './EventRouter';
@@ -47,13 +51,25 @@ export async function initApp(): Promise<void> {
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);
useDatasetStore.getState().hydrate(datasets);
useCustomThemeStore.getState().hydrate(themes);
// Wire persistence AFTER hydrate so write-through's baseline is the loaded set
// — otherwise it would redundantly re-save every record on each startup.
wirePersistence();
wireDatasetPersistence();
wireThemePersistence();
// Routing starts AFTER hydrate so the on-load hash restore can resolve snippet
// / 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));
}
});
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import type { FitMode } from '@core/rendering';
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';
/**
@@ -25,7 +25,7 @@ export interface AppState {
/** Preview sizing mode (spec §04); persisted to Settings as `previewFitMode`. */
previewFitMode: FitMode;
/** Chart theme selection (spec §04); persisted to Settings as `chartTheme`. */
chartTheme: ChartThemeId;
chartTheme: ChartThemeSelection;
/** The currently open modal, or null. */
activeModal: ModalName | null;
@@ -35,7 +35,7 @@ export interface AppState {
/** Set the preview fit mode — the Live Preview Fit control's action. */
setPreviewFitMode: (mode: FitMode) => void;
/** 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`.
* High-level open/close (snapshot for unsaved-change detection, URL sync,
+163
View File
@@ -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');
});
});
+256
View File
@@ -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);
};
+91
View File
@@ -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);
});
});
+101
View File
@@ -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' },
];
+224
View File
@@ -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,
];
+52
View File
@@ -3,7 +3,11 @@ import {
CHART_THEME_OPTIONS,
chartConfigFor,
chartConfigForSelection,
chartThemeOptions,
customThemeIdOf,
customThemeSelection,
isChartThemeId,
isChartThemeSelection,
darkBaseConfig,
darkChartConfig,
darkExpressiveConfig,
@@ -123,3 +127,51 @@ describe('isChartThemeId', () => {
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
View File
@@ -28,6 +28,7 @@ import type { Config } from 'vega-lite';
// 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.
import * as presets from 'vega-themes';
import type { CustomTheme } from './custom-theme';
import type { UiTheme } from './theme';
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 = {};
export interface ChartThemeOption {
value: ChartThemeId;
value: ChartThemeSelection;
label: string;
/** Secondary line for pickers (what the choice means). */
detail?: string;
@@ -219,13 +220,71 @@ export function isChartThemeId(value: unknown): value is ChartThemeId {
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
* 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 === 'stock') return STOCK_CONFIG;
return presets[selection] as Config;
return presets[selection as ChartThemePresetId] as Config;
}