Chart theming: selectable chart theme + spec↔config merge/extract

This commit is contained in:
2026-06-12 16:48:48 +03:00
parent fe9d588103
commit 44a601affd
27 changed files with 1103 additions and 121 deletions
+48 -8
View File
@@ -10,6 +10,8 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { chartConfigForSelection } from '@core/vega-themes';
import { useAppStore } from '../stores/AppStore';
import { usePreviewStore } from '../stores/PreviewStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { useDatasetStore } from '../stores/DatasetStore';
@@ -24,10 +26,12 @@ const H = vi.hoisted(() => ({
calls: 0,
pending: [] as Array<() => void>,
destroyed: [] as number[],
configs: [] as unknown[],
}));
vi.mock('../services/chart-renderer', () => ({
renderSpec: (node: HTMLElement) => {
renderSpec: (node: HTMLElement, _spec: unknown, config: unknown) => {
const id = ++H.calls;
H.configs.push(config);
return new Promise((resolve) => {
H.pending.push(() => {
node.replaceChildren(); // a real embed wipes then rebuilds the host
@@ -64,6 +68,7 @@ beforeEach(() => {
H.calls = 0;
H.pending.length = 0;
H.destroyed.length = 0;
H.configs.length = 0;
usePreviewStore.setState({ error: null, busy: false });
useSnippetStore.getState().reset();
useDatasetStore.getState().reset();
@@ -82,21 +87,27 @@ afterEach(() => {
});
describe('LivePreview busy overlay', () => {
// The overlay is the aria-hidden element carrying the "Rendering…" label — a
// bare [aria-hidden] query would also match decorative bits of the header
// controls (e.g. the chart-theme select's caret).
const overlay = () =>
[...container.querySelectorAll('[aria-hidden="true"]')].find((el) =>
/rendering/i.test(el.textContent ?? ''),
) ?? null;
test('does not render the busy overlay when busy=false', () => {
// The overlay element should not be in the DOM at all during normal operation.
expect(container.querySelector('[aria-hidden="true"]')).toBeNull();
expect(overlay()).toBeNull();
});
test('renders the busy overlay when PreviewStore.busy=true', () => {
act(() => usePreviewStore.setState({ busy: true }));
const overlay = container.querySelector('[aria-hidden="true"]');
expect(overlay).not.toBeNull();
expect(overlay()).not.toBeNull();
});
test('overlay carries a visible label for sighted users', () => {
act(() => usePreviewStore.setState({ busy: true }));
const label = container.querySelector('[aria-hidden="true"]')?.textContent;
expect(label).toMatch(/rendering/i);
expect(overlay()?.textContent).toMatch(/rendering/i);
});
test('the preview body carries aria-busy=true when busy', () => {
@@ -113,9 +124,9 @@ describe('LivePreview busy overlay', () => {
test('overlay disappears when busy returns to false', () => {
act(() => usePreviewStore.setState({ busy: true }));
expect(container.querySelector('[aria-hidden="true"]')).not.toBeNull();
expect(overlay()).not.toBeNull();
act(() => usePreviewStore.setState({ busy: false }));
expect(container.querySelector('[aria-hidden="true"]')).toBeNull();
expect(overlay()).toBeNull();
});
});
@@ -186,3 +197,32 @@ describe('LivePreview render serialization', () => {
}
});
});
describe('LivePreview chart theme', () => {
const tick = (ms = 6000) => act(async () => void (await vi.advanceTimersByTimeAsync(ms)));
test('the selected chart theme decides the config passed to renderSpec', async () => {
vi.useFakeTimers();
try {
act(() => {
useAppStore.setState({ chartTheme: 'stock', uiTheme: 'dark' });
useSnippetStore.setState({ draftText: '{"data":{"values":[]},"mark":"point"}' });
});
await tick();
act(() => H.pending[0]());
await tick(0);
// Stock = inject nothing; vega-lite's own defaults apply.
expect(H.configs[0]).toEqual({});
// Switching the theme re-renders with the new config (no text change needed).
act(() => useAppStore.setState({ chartTheme: 'astrolabe' }));
await tick();
act(() => H.pending[1]());
await tick(0);
expect(H.configs[1]).toEqual(chartConfigForSelection('astrolabe', 'dark'));
} finally {
vi.useRealTimers();
act(() => useAppStore.setState({ chartTheme: 'astrolabe', uiTheme: 'light' }));
}
});
});
+31 -3
View File
@@ -20,7 +20,7 @@ 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 { chartConfigFor } from '@core/vega-themes';
import { CHART_THEME_OPTIONS, chartConfigForSelection } from '@core/vega-themes';
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
import { useAppStore } from '../stores/AppStore';
import { useDatasetStore } from '../stores/DatasetStore';
@@ -29,6 +29,7 @@ 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 { RangeControl, SettingRow, SettingsPopover } from './SettingsPopover';
import styles from './LivePreview.module.css';
@@ -68,6 +69,30 @@ function FitControl() {
);
}
/**
* Chart theme picker (spec §04; docs/chart-theming-scope.md §4.2) — which config
* charts render (and export) with. A global preference, not per-snippet: a
* snippet's own `config` still overrides it property by property. Lives in the
* header, not inside PreviewSettings: SelectControl and SettingsPopover share
* the one-open-popover registry, so a select nested in the popover would close
* (and unmount) its own parent on open.
*/
// TODO: header placement/crowding parked for the batched council pass (docs/ux-second-pass.md).
function ChartThemeControl() {
const chartTheme = useAppStore((s) => s.chartTheme);
const setChartTheme = useAppStore((s) => s.setChartTheme);
return (
<SelectControl
id="preview-chart-theme"
label="Chart theme"
options={CHART_THEME_OPTIONS}
value={chartTheme}
onSelect={setChartTheme}
triggerTitle="Chart theme — how charts are styled when rendered and exported"
/>
);
}
/** Preview settings cluster (spec §07 → Performance), disclosed beside Fit. */
function PreviewSettings() {
const renderDebounce = useUserSettingsStore((s) => s.saved.performance.renderDebounce);
@@ -100,6 +125,7 @@ export function LivePreview() {
const shownText = useSnippetStore(selectShownText);
const fitMode = useAppStore((s) => s.previewFitMode);
const uiTheme = useAppStore((s) => s.uiTheme);
const chartTheme = useAppStore((s) => s.chartTheme);
// 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));
@@ -212,7 +238,7 @@ export function LivePreview() {
try {
const prepared = prepareSpecForRender(parsed, { fitMode, datasets });
const config = chartConfigFor(uiTheme);
const config = chartConfigForSelection(chartTheme, uiTheme);
handleRef.current?.destroy();
handleRef.current = null;
const handle = await renderSpec(node, prepared as VisualizationSpec, config);
@@ -259,6 +285,7 @@ export function LivePreview() {
shownText,
fitMode,
uiTheme,
chartTheme,
datasets,
setError,
setBusy,
@@ -321,8 +348,9 @@ export function LivePreview() {
<div className={styles.preview}>
<div className={styles.header}>
<FitControl />
{/* Right cluster: export this chart, then the preview settings gear. */}
{/* Right cluster: chart theme, export this chart, then the settings gear. */}
<div className={styles.headerEnd}>
<ChartThemeControl />
<ChartExport chartReady={chartReady} getImageUrl={getImageUrl} />
<PreviewSettings />
</div>
+56 -3
View File
@@ -13,7 +13,7 @@
* inline near the editor (spec §03E), mirroring the preview via PreviewStore.
*/
import { useEffect, useRef } from 'react';
import { useEffect, useRef, type RefObject } from 'react';
// `edcore.main` is the full standalone editor — every feature contribution
// (folding, suggest widget, word operations like Cmd+Backspace, find, bracket
// colorization, multi-cursor, …) — but WITHOUT the `monaco-editor` barrel's
@@ -25,6 +25,11 @@ import '../infrastructure/monaco-env'; // side-effect: wire workers before creat
import { configureVegaLiteJson } from '../infrastructure/monaco-schema';
import { configureJsonFormatter, installFormatOnPaste } from '../infrastructure/monaco-format';
import { openModal } from '../modals/ModalCoordinator';
import {
installSpecConfigActions,
runExtractConfig,
runMergeChartTheme,
} from '../services/spec-config-actions';
import { useAppStore } from '../stores/AppStore';
import { confirm } from '../stores/ConfirmStore';
import { hasInlineData } from '../stores/ExtractStore';
@@ -35,6 +40,7 @@ import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores
import { useUserSettingsStore } from '../stores/UserSettingsStore';
import { Icon } from './Icon';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { SelectControl } from './SelectControl';
import {
NumberControl,
RangeControl,
@@ -139,7 +145,30 @@ configureVegaLiteJson();
// Register the compact JSON formatter once (Format Document + format-on-paste, §03A).
configureJsonFormatter();
function EditorToolbar() {
/** The two spec↔config operations, surfaced as an overflow menu (council:
* Carbon menu-buttons — overflow for additional options under space
* constraint; NN/g #6 — a visible home, with the Monaco context menu and F1
* palette as the #7 accelerators on the same code paths). */
const CONFIG_ACTIONS = [
{
value: 'merge',
label: 'Merge chart theme into spec',
detail: 'Write the active chart theme into the config block',
},
{
value: 'extract',
label: 'Extract config from spec',
detail: 'Remove the config block and copy it to the clipboard',
},
] as const;
type ConfigActionId = (typeof CONFIG_ACTIONS)[number]['value'];
function EditorToolbar({
editorRef,
}: {
editorRef: RefObject<monaco.editor.IStandaloneCodeEditor | null>;
}) {
const activeId = useSnippetStore((s) => s.activeSnippetId);
const editorView = useSnippetStore((s) => s.editorView);
const setEditorView = useSnippetStore((s) => s.setEditorView);
@@ -161,6 +190,13 @@ function EditorToolbar() {
// the button and the Cmd/Ctrl+S shortcut (EventRouter) behave identically.
const handlePublish = publishActiveSnippet;
const handleConfigAction = (action: ConfigActionId) => {
const editor = editorRef.current;
if (!editor) return;
if (action === 'merge') runMergeChartTheme(editor);
else void runExtractConfig(editor);
};
const handleRevert = async () => {
const ok = await confirm({
title: 'Revert draft',
@@ -207,6 +243,17 @@ function EditorToolbar() {
<span className={styles.actionLabel}>Extract to Dataset</span>
</button>
)}
<SelectControl
id="editor-config-actions"
label="Spec config actions"
heading="Spec config"
options={CONFIG_ACTIONS}
onSelect={handleConfigAction}
triggerClassName={styles.action}
triggerContent="Config"
triggerTitle="Spec config actions — merge the chart theme in, or extract the config out"
disabled={activeId === null || editorView === 'published'}
/>
<button
type="button"
className={`${styles.action} ${styles.collapsible}`}
@@ -286,6 +333,11 @@ export function SpecEditor() {
// up the formatted text. No-op on the read-only published view / invalid JSON.
const pasteSub = installFormatOnPaste(editor);
// Merge-chart-theme / extract-config actions (context menu + F1 palette,
// docs/chart-theming-scope.md §4.3). Edits land via onDidChangeModelContent
// above, so the draft buffer stays in sync like any other edit.
const configActionsSub = installSpecConfigActions(editor);
// Cmd/Ctrl+S is owned globally by the EventRouter (docs/architecture/04 →
// "bind listeners in exactly one place"), which publishes before the
// interactive-context gate so it works while the editor has focus. Monaco
@@ -294,6 +346,7 @@ export function SpecEditor() {
return () => {
sub.dispose();
pasteSub.dispose();
configActionsSub.dispose();
editor.dispose();
editorRef.current = null;
};
@@ -336,7 +389,7 @@ export function SpecEditor() {
return (
<div className={styles.editorPane}>
<EditorToolbar />
<EditorToolbar editorRef={editorRef} />
<div className={styles.editorWrap}>
{activeId === null && <div className={styles.placeholder}>Select or create a snippet</div>}
<div className={styles.editor} ref={hostRef} />