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>
);
}