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,