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} />
@@ -1,9 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { defaultSettings } from '@core/settings';
import {
loadChartTheme,
loadPreviewFitMode,
loadUiTheme,
loadUserSettings,
saveChartTheme,
saveManagedSettings,
savePreviewFitMode,
saveUiTheme,
@@ -119,6 +121,34 @@ describe('settings-store · ui.previewFitMode', () => {
});
});
describe('settings-store · ui.chartTheme', () => {
beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub()));
afterEach(() => vi.unstubAllGlobals());
it('defaults to astrolabe when nothing is stored', () => {
expect(loadChartTheme()).toBe('astrolabe');
});
it('returns a stored valid chart theme', () => {
localStorage.setItem(KEY, JSON.stringify({ ui: { chartTheme: 'stock' } }));
expect(loadChartTheme()).toBe('stock');
});
it('falls back to astrolabe for an unrecognized id', () => {
localStorage.setItem(KEY, JSON.stringify({ ui: { chartTheme: 'neon' } }));
expect(loadChartTheme()).toBe('astrolabe');
});
it('round-trips and preserves the other ui slices', () => {
saveUiTheme('dark');
savePreviewFitMode('height');
saveChartTheme('powerbi');
expect(loadChartTheme()).toBe('powerbi');
expect(loadUiTheme()).toBe('dark');
expect(loadPreviewFitMode()).toBe('height');
});
});
describe('settings-store · full UserSettings record', () => {
beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub()));
afterEach(() => vi.unstubAllGlobals());
+17 -1
View File
@@ -19,6 +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';
const KEY = 'astrolabe:settings';
@@ -28,12 +29,15 @@ const DEFAULT_THEME: UiTheme = 'light';
/** Spec §04 — the Fit control defaults to Original. */
const DEFAULT_FIT_MODE: FitMode = 'default';
/** Spec §04 — the Chart theme picker defaults to the house style. */
const DEFAULT_CHART_THEME: ChartThemeId = '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'>;
/** Loose view of the stored record for the per-slice write-through merges. */
interface StoredSettings {
ui?: { theme?: unknown; previewFitMode?: unknown; [k: string]: unknown };
ui?: { theme?: unknown; previewFitMode?: unknown; chartTheme?: unknown; [k: string]: unknown };
[k: string]: unknown;
}
@@ -118,6 +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 {
const stored = readRaw().ui?.chartTheme;
return isChartThemeId(stored) ? stored : DEFAULT_CHART_THEME;
}
/** Persist the chart theme, preserving every other key already in the record. */
export function saveChartTheme(chartTheme: ChartThemeId): void {
const current = readRaw();
writeRaw({ ...current, ui: { ...current.ui, chartTheme } });
}
/** Persist the preview fit mode, preserving every other key already in the record. */
export function savePreviewFitMode(fitMode: FitMode): void {
const current = readRaw();
+21 -3
View File
@@ -1,15 +1,20 @@
/**
* Preference orchestration — bridges the (browser-free) AppStore to the settings
* adapter for the small UI preferences pulled forward ahead of the M5 Settings
* modal. Same store↔adapter pattern as theme orchestration; currently the only
* such preference is the Live Preview fit mode (spec §04, `previewFitMode`).
* modal. Same store↔adapter pattern as theme orchestration: the Live Preview
* fit mode (spec §04, `previewFitMode`) and the chart theme (`chartTheme`).
*
* Unlike theme there is no FOUC concern (the preview renders after hydration
* anyway), but hydrating early keeps the store the single source of truth from
* the first render.
*/
import { loadPreviewFitMode, savePreviewFitMode } from '../infrastructure/settings-store';
import {
loadChartTheme,
loadPreviewFitMode,
saveChartTheme,
savePreviewFitMode,
} from '../infrastructure/settings-store';
import { useAppStore } from '../stores/AppStore';
/** Hydrate the persisted fit mode into the store. Call before render. */
@@ -24,3 +29,16 @@ export function wirePreviewFitMode(): () => void {
savePreviewFitMode(state.previewFitMode);
});
}
/** Hydrate the persisted chart theme into the store. Call before render. */
export function initChartTheme(): void {
useAppStore.getState().setChartTheme(loadChartTheme());
}
/** Persist the chart theme on change. Returns a teardown that detaches the subscriber. */
export function wireChartTheme(): () => void {
return useAppStore.subscribe((state, prev) => {
if (state.chartTheme === prev.chartTheme) return;
saveChartTheme(state.chartTheme);
});
}
+175
View File
@@ -0,0 +1,175 @@
/**
* Spec ↔ config editor actions (docs/chart-theming-scope.md §4.3) — the Monaco
* command-palette / context-menu pair over the portable core operations:
*
* - **Merge chart theme into spec** bakes the *currently selected* chart theme
* (the preview's Chart theme control) into the draft's `config` block, so the
* styling travels when the spec is published outside Astrolabe. The spec's
* existing `config` keys win — rendering is unchanged.
* - **Extract config from spec** removes the draft's `config` block and copies
* it to the clipboard — for cleaning baked-in styling out of a pasted spec.
* The clipboard write happens *before* the edit, so a clipboard failure
* never destroys the only copy.
*
* Both replace the document via `executeEdits`, so ⌘Z restores the previous
* text. Both no-op (with a toast naming the reason) on invalid JSON; Monaco's
* `!editorReadonly` precondition hides them on the published view.
*
* Surfacing (council: NN/g #6 recognition-over-recall, #7 accelerators; Carbon
* menu-buttons "use an overflow menu when additional options are available and
* there is a space constraint"): the visible home is the editor toolbar's
* **Config menu** (SpecEditor), which calls `runMergeChartTheme` /
* `runExtractConfig` directly; the context-menu/palette registrations here are
* the expert accelerators on the same code paths.
*/
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
import { formatJson } from '@core/json-format';
import { extractConfigFromSpec, isJsonObject, mergeConfigIntoSpec } from '@core/spec-config';
import { CHART_THEME_OPTIONS, chartConfigForSelection } from '@core/vega-themes';
import { copyText } from '../infrastructure/file-transfer';
import { useAppStore } from '../stores/AppStore';
import { notify } from '../stores/NotificationStore';
/** Parse the model's JSON, or toast (and return null) when it isn't a JSON object. */
function parseSpecObject(model: monaco.editor.ITextModel): Record<string, unknown> | null {
let parsed: unknown;
try {
parsed = JSON.parse(model.getValue());
} catch {
notify({
kind: 'error',
title: 'Spec is not valid JSON',
message: 'Fix the JSON syntax first, then try again.',
});
return null;
}
if (!isJsonObject(parsed)) {
notify({
kind: 'error',
title: 'Spec is not a JSON object',
message: 'Config actions need a top-level { … } Vega-Lite spec.',
});
return null;
}
return parsed;
}
/** Replace the whole document as one undoable edit, in the app's JSON style. */
function replaceDocument(
editor: monaco.editor.IStandaloneCodeEditor,
model: monaco.editor.ITextModel,
source: string,
next: Record<string, unknown>,
): void {
const raw = JSON.stringify(next);
const text = formatJson(raw, { indent: model.getOptions().tabSize }) ?? raw;
// Undo stops on both sides keep the replacement its own undo step — without
// the leading stop it can coalesce with the user's preceding typing, and ⌘Z
// would revert that too (Monaco's built-in actions bracket the same way).
editor.pushUndoStop();
editor.executeEdits(source, [{ range: model.getFullModelRange(), text }]);
editor.pushUndoStop();
}
/** Bake the currently selected chart theme into the draft's `config` block. */
export function runMergeChartTheme(editor: monaco.editor.IStandaloneCodeEditor): void {
const model = editor.getModel();
if (!model) return;
const spec = parseSpecObject(model);
if (!spec) return;
const { chartTheme, uiTheme } = useAppStore.getState();
// A Config is plain JSON data; the cast bridges its closed vega-lite type
// to the JsonObject the portable merge operates on.
const themeConfig = chartConfigForSelection(chartTheme, uiTheme) as Record<string, unknown>;
if (Object.keys(themeConfig).length === 0) {
notify({
kind: 'info',
title: 'Nothing to merge',
message: 'The Stock Vega-Lite chart theme injects no config.',
});
return;
}
const themeLabel = CHART_THEME_OPTIONS.find((o) => o.value === chartTheme)?.label ?? chartTheme;
replaceDocument(editor, model, 'merge-chart-theme', mergeConfigIntoSpec(spec, themeConfig));
notify({
kind: 'success',
title: 'Chart theme merged',
message: `The ${themeLabel} theme now travels in the specs config; existing keys were kept. Undo with ⌘/Ctrl+Z.`,
});
}
/** Remove the draft's `config` block, copying it to the clipboard first. */
export async function runExtractConfig(editor: monaco.editor.IStandaloneCodeEditor): Promise<void> {
const model = editor.getModel();
if (!model) return;
const spec = parseSpecObject(model);
if (!spec) return;
const { spec: rest, config } = extractConfigFromSpec(spec);
if (config === null) {
notify({
kind: 'info',
title: 'No config to extract',
message: 'This spec has no config block.',
});
return;
}
// Copy before removing — if the clipboard write fails, the spec keeps its
// config and nothing is lost.
try {
await copyText(JSON.stringify(config, null, 2));
} catch {
notify({
kind: 'error',
title: 'Could not copy the config',
message: 'Clipboard access failed, so the spec was left unchanged.',
});
return;
}
replaceDocument(editor, model, 'extract-config', rest);
notify({
kind: 'success',
title: 'Config extracted',
message: 'The config block was removed and copied to the clipboard. Undo with ⌘/Ctrl+Z.',
});
}
/**
* Register both actions on the editor (context menu + F1 palette — the expert
* accelerators; the toolbar Config menu is the discoverable home). Returns a
* disposable that detaches them (dispose on editor unmount, like the
* format-on-paste hook).
*/
export function installSpecConfigActions(
editor: monaco.editor.IStandaloneCodeEditor,
): monaco.IDisposable {
const merge = editor.addAction({
id: 'astrolabe.merge-chart-theme',
label: 'Merge Chart Theme into Spec',
contextMenuGroupId: 'astrolabe',
contextMenuOrder: 1,
precondition: '!editorReadonly',
run: () => runMergeChartTheme(editor),
});
const extract = editor.addAction({
id: 'astrolabe.extract-config',
label: 'Extract Config from Spec',
contextMenuGroupId: 'astrolabe',
contextMenuOrder: 2,
precondition: '!editorReadonly',
run: () => runExtractConfig(editor),
});
return {
dispose() {
merge.dispose();
extract.dispose();
},
};
}
+7
View File
@@ -1,6 +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 { ModalName } from '../modals/types';
/**
@@ -23,6 +24,8 @@ export interface AppState {
uiTheme: UiTheme;
/** Preview sizing mode (spec §04); persisted to Settings as `previewFitMode`. */
previewFitMode: FitMode;
/** Chart theme selection (spec §04); persisted to Settings as `chartTheme`. */
chartTheme: ChartThemeId;
/** The currently open modal, or null. */
activeModal: ModalName | null;
@@ -31,6 +34,8 @@ export interface AppState {
toggleTheme: () => void;
/** 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;
/**
* Low-level modal setter — the single primitive that mutates `activeModal`.
* High-level open/close (snapshot for unsaved-change detection, URL sync,
@@ -43,10 +48,12 @@ export interface AppState {
export const useAppStore = create<AppState>((set) => ({
uiTheme: 'light',
previewFitMode: 'default',
chartTheme: 'astrolabe',
activeModal: null,
setTheme: (uiTheme) => set({ uiTheme }),
toggleTheme: () => set((s) => ({ uiTheme: s.uiTheme === 'dark' ? 'light' : 'dark' })),
setPreviewFitMode: (previewFitMode) => set({ previewFitMode }),
setChartTheme: (chartTheme) => set({ chartTheme }),
setActiveModal: (activeModal) => set({ activeModal }),
}));
+5 -2
View File
@@ -20,7 +20,7 @@ describe('defaultSettings', () => {
tabSize: 2,
},
performance: { renderDebounce: 1500 },
ui: { theme: 'light', previewFitMode: 'default' },
ui: { theme: 'light', previewFitMode: 'default', chartTheme: 'astrolabe' },
formatting: { dateFormat: 'smart', customDateFormat: '' },
});
});
@@ -64,7 +64,7 @@ describe('loadSettings — valid records', () => {
tabSize: 4,
},
performance: { renderDebounce: 2500 },
ui: { theme: 'dark', previewFitMode: 'full' },
ui: { theme: 'dark', previewFitMode: 'full', chartTheme: 'fivethirtyeight' },
formatting: { dateFormat: 'custom', customDateFormat: 'yyyy-MM-dd' },
};
expect(loadSettings(record)).toEqual(record);
@@ -160,6 +160,7 @@ describe('loadSettings — enum validation', () => {
expect(loadSettings({ ui: { previewFitMode: 'tall' } }).ui.previewFitMode).toBe(
d.ui.previewFitMode,
);
expect(loadSettings({ ui: { chartTheme: 'comic-sans' } }).ui.chartTheme).toBe(d.ui.chartTheme);
expect(loadSettings({ formatting: { dateFormat: 'relative' } }).formatting.dateFormat).toBe(
d.formatting.dateFormat,
);
@@ -171,6 +172,8 @@ describe('loadSettings — enum validation', () => {
expect(loadSettings({ ui: { theme: 'dark' } }).ui.theme).toBe('dark');
expect(loadSettings({ ui: { previewFitMode: 'width' } }).ui.previewFitMode).toBe('width');
expect(loadSettings({ ui: { previewFitMode: 'height' } }).ui.previewFitMode).toBe('height');
expect(loadSettings({ ui: { chartTheme: 'stock' } }).ui.chartTheme).toBe('stock');
expect(loadSettings({ ui: { chartTheme: 'latimes' } }).ui.chartTheme).toBe('latimes');
expect(loadSettings({ formatting: { dateFormat: 'iso' } }).formatting.dateFormat).toBe('iso');
});
+11
View File
@@ -13,6 +13,8 @@
* guarantees that (the read-time migration, mirroring `migrateSnippet`).
*/
import { isChartThemeId, type ChartThemeId } from './vega-themes';
/** Current schema version for a UserSettings record (read-time migration target). */
export const CURRENT_SETTINGS_VERSION = 1;
@@ -57,6 +59,13 @@ export interface UserSettings {
* Preview_). Default `'default'`.
*/
previewFitMode: 'default' | 'width' | 'height' | 'full';
/**
* Chart theme — which config is injected when charts render (spec §04;
* docs/chart-theming-scope.md §4.2). `'astrolabe'` (default) is the house
* style following the UI theme; `'stock'` injects nothing; other ids are
* vega-themes presets. Set by the preview's settings cluster.
*/
chartTheme: ChartThemeId;
};
/** Date-rendering preferences (spec §07 → Formatting). */
formatting: {
@@ -97,6 +106,7 @@ export function defaultSettings(): UserSettings {
ui: {
theme: 'light',
previewFitMode: 'default',
chartTheme: 'astrolabe',
},
formatting: {
dateFormat: 'smart',
@@ -194,6 +204,7 @@ export function loadSettings(raw: unknown): UserSettings {
['default', 'width', 'height', 'full'] as const,
d.ui.previewFitMode,
),
chartTheme: isChartThemeId(ui.chartTheme) ? ui.chartTheme : d.ui.chartTheme,
},
formatting: {
dateFormat: asEnum(
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import { extractConfigFromSpec, isJsonObject, mergeConfigIntoSpec } from './spec-config';
describe('mergeConfigIntoSpec', () => {
const theme = {
background: 'transparent',
font: 'IBM Plex Sans',
axis: { labelColor: '#525252', gridDash: [2, 2] },
};
it('adds the config block to a spec without one', () => {
const spec = { mark: 'bar', data: { values: [] } };
const out = mergeConfigIntoSpec(spec, theme);
expect(out.config).toEqual(theme);
expect(out.mark).toBe('bar');
expect(spec).not.toHaveProperty('config'); // input untouched
});
it('the specs existing config wins key-by-key, deep', () => {
const spec = {
mark: 'bar',
config: { font: 'Georgia', axis: { labelColor: 'red' } },
};
const out = mergeConfigIntoSpec(spec, theme);
expect(out.config).toEqual({
background: 'transparent', // from the theme
font: 'Georgia', // spec wins
axis: { labelColor: 'red', gridDash: [2, 2] }, // merged: spec wins inside
});
});
it('arrays are replaced, not merged', () => {
const spec = { config: { axis: { gridDash: [8] } } };
const out = mergeConfigIntoSpec(spec, theme) as { config: { axis: { gridDash: number[] } } };
expect(out.config.axis.gridDash).toEqual([8]);
});
it('an empty config into a config-less spec stays config-less', () => {
expect(mergeConfigIntoSpec({ mark: 'bar' }, {})).toEqual({ mark: 'bar' });
});
it('a non-object spec config is replaced by the merge', () => {
const out = mergeConfigIntoSpec({ config: 'junk' }, theme);
expect(out.config).toEqual(theme);
});
});
describe('extractConfigFromSpec', () => {
it('removes and returns the config block', () => {
const spec = { mark: 'bar', config: { font: 'Georgia' } };
const out = extractConfigFromSpec(spec);
expect(out.config).toEqual({ font: 'Georgia' });
expect(out.spec).toEqual({ mark: 'bar' });
expect(spec).toHaveProperty('config'); // input untouched
});
it('returns null config when the spec has none', () => {
const out = extractConfigFromSpec({ mark: 'bar' });
expect(out.config).toBeNull();
expect(out.spec).toEqual({ mark: 'bar' });
});
it('an empty or non-object config extracts as null but is still removed', () => {
expect(extractConfigFromSpec({ mark: 'bar', config: {} })).toEqual({
spec: { mark: 'bar' },
config: null,
});
expect(extractConfigFromSpec({ mark: 'bar', config: 7 })).toEqual({
spec: { mark: 'bar' },
config: null,
});
});
});
describe('isJsonObject', () => {
it('accepts plain objects only', () => {
expect(isJsonObject({})).toBe(true);
expect(isJsonObject([])).toBe(false);
expect(isJsonObject(null)).toBe(false);
expect(isJsonObject('x')).toBe(false);
});
});
+78
View File
@@ -0,0 +1,78 @@
/**
* Spec ↔ config operations (docs/chart-theming-scope.md §4.3).
*
* The two halves of making the injected chart theme portable, mirroring the
* Vega editor's "Merge Config Into Spec" / "Extract Config From Spec" pair:
*
* - **Merge** bakes a config into the spec's own `config` block — for
* publishing a snippet somewhere the app's theme won't follow it. The spec's
* existing `config` wins on conflicts, matching the render-time precedence
* (vega-lite layers `spec.config` over the injected config), so baking never
* changes how the chart looks.
* - **Extract** lifts the `config` block out of a spec — for cleaning styling
* out of a pasted-in spec (the caller decides where the extracted config
* goes: clipboard today, a saved theme later).
*
* Pure object-in/object-out; JSON text handling (parse, format, undo) is the
* editor integration's job.
*/
/** A parsed JSON object (the only spec shape these operations accept). */
export type JsonObject = Record<string, unknown>;
/** Is the value a plain JSON object (not an array, not null)? */
export function isJsonObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Deep-merge `upper` over `lower`: plain objects merge recursively, everything
* else (arrays, scalars) is replaced by the upper value. The same shape of
* merge vega-lite applies between the embed-time config and `spec.config`.
*/
function deepMerge(lower: JsonObject, upper: JsonObject): JsonObject {
const out: JsonObject = { ...lower };
for (const [key, upperValue] of Object.entries(upper)) {
const lowerValue = out[key];
out[key] =
isJsonObject(lowerValue) && isJsonObject(upperValue)
? deepMerge(lowerValue, upperValue)
: upperValue;
}
return out;
}
/**
* Bake `config` into the spec's `config` block. The spec's existing `config`
* takes precedence key-by-key (deep), so the rendered result is unchanged —
* the theme just travels with the spec now. Returns a new object; the input
* is not mutated. An empty merge result still writes `config: {}` only when
* the spec already had one; baking an empty config into a config-less spec is
* a no-op.
*/
export function mergeConfigIntoSpec(spec: JsonObject, config: JsonObject): JsonObject {
const specConfig = isJsonObject(spec.config) ? spec.config : {};
const merged = deepMerge(config, specConfig);
if (Object.keys(merged).length === 0 && !('config' in spec)) return { ...spec };
return { ...spec, config: merged };
}
/** Result of `extractConfigFromSpec`. */
export interface ExtractedConfig {
/** The spec without its `config` block (new object; input not mutated). */
spec: JsonObject;
/** The removed `config`, or null when the spec had none worth extracting. */
config: JsonObject | null;
}
/**
* Remove the spec's `config` block and hand it back separately. A missing,
* empty, or non-object `config` extracts as `null` (an empty/junk block is
* still removed from the spec — there is nothing to keep).
*/
export function extractConfigFromSpec(spec: JsonObject): ExtractedConfig {
if (!('config' in spec)) return { spec: { ...spec }, config: null };
const { config, ...rest } = spec;
const extracted = isJsonObject(config) && Object.keys(config).length > 0 ? config : null;
return { spec: rest, config: extracted };
}
+87 -1
View File
@@ -1,5 +1,17 @@
import { describe, expect, it } from 'vitest';
import { chartConfigFor, darkChartConfig, lightChartConfig } from './vega-themes';
import {
CHART_THEME_OPTIONS,
chartConfigFor,
chartConfigForSelection,
isChartThemeId,
darkBaseConfig,
darkChartConfig,
darkExpressiveConfig,
lightBaseConfig,
lightChartConfig,
lightExpressiveConfig,
mergeChartLayers,
} from './vega-themes';
import type { UiTheme } from './theme';
/**
@@ -37,3 +49,77 @@ describe('chartConfigFor', () => {
expect(lightChartConfig.range?.category).not.toEqual(darkChartConfig.range?.category);
});
});
/**
* The layer split (docs/chart-theming-scope.md §1): base carries only the
* legibility minimum (background + guide colors); everything brand-flavored
* (font, palette, grid dash, sizes/weights, view stroke) lives in expressive.
* A future stock/custom chart style keeps base and swaps expressive.
*/
describe('chart config layers', () => {
const layers = [
{ base: lightBaseConfig, expressive: lightExpressiveConfig, full: lightChartConfig },
{ base: darkBaseConfig, expressive: darkExpressiveConfig, full: darkChartConfig },
];
it.each(layers)('base stays free of house style', ({ base }) => {
expect(base.font).toBeUndefined();
expect(base.range).toBeUndefined();
expect(base.view).toBeUndefined();
expect(base.axis?.gridDash).toBeUndefined();
expect(base.title).not.toHaveProperty('fontSize');
});
it.each(layers)('expressive stays free of legibility colors', ({ expressive }) => {
expect(expressive.background).toBeUndefined();
expect(expressive.axis?.labelColor).toBeUndefined();
expect(expressive.title).not.toHaveProperty('color');
});
it.each(layers)('layers merge into the full theme config', ({ base, expressive, full }) => {
expect(mergeChartLayers(base, expressive)).toEqual(full);
});
it('merges the nested title and axis groups instead of replacing them', () => {
const merged = mergeChartLayers(lightBaseConfig, lightExpressiveConfig);
// One property from each layer survives in the same nested object.
expect(merged.title).toMatchObject({ color: '#161616', fontSize: 16 });
expect(merged.axis).toMatchObject({ labelColor: '#525252', gridDash: [2, 2] });
});
});
describe('chartConfigForSelection', () => {
it('astrolabe follows the UI theme', () => {
expect(chartConfigForSelection('astrolabe', 'light')).toBe(lightChartConfig);
expect(chartConfigForSelection('astrolabe', 'dark')).toBe(darkChartConfig);
});
it('stock injects nothing (vega-lite defaults apply)', () => {
expect(chartConfigForSelection('stock', 'light')).toEqual({});
expect(chartConfigForSelection('stock', 'dark')).toEqual({});
});
it('presets resolve to a non-empty config independent of UI theme', () => {
const light = chartConfigForSelection('fivethirtyeight', 'light');
expect(Object.keys(light).length).toBeGreaterThan(0);
expect(chartConfigForSelection('fivethirtyeight', 'dark')).toBe(light);
});
it('every option id resolves to a config', () => {
for (const { value } of CHART_THEME_OPTIONS) {
expect(chartConfigForSelection(value, 'light')).toBeTruthy();
}
});
});
describe('isChartThemeId', () => {
it('accepts every option id', () => {
for (const { value } of CHART_THEME_OPTIONS) expect(isChartThemeId(value)).toBe(true);
});
it('rejects unknown and non-string values', () => {
expect(isChartThemeId('comic-sans')).toBe(false);
expect(isChartThemeId(undefined)).toBe(false);
expect(isChartThemeId(7)).toBe(false);
});
});
+149 -21
View File
@@ -5,17 +5,29 @@
* visually belong to the app rather than looking like stock Vega-Lite. This is
* the single source of truth mapping a `UiTheme` to a config; it is applied at
* embed time (never baked into the user's stored spec). Adding a UI theme = one
* config object plus one map entry here.
* base + expressive pair plus one map entry here.
*
* Values track the design language: IBM Plex font, axis/grid colors from the
* Carbon neutral ramp (matching `--text-secondary` / `--border`), and a
* categorical `range.category` palette transcribed from Carbon's data-viz
* 14-color pairing (white theme for light, g100 for dark — see
* carbon-charts `packages/core/scss/_color-palette.scss`). This is the
* expressive "free color" layer (doc §3.5, §5).
* Each theme's config is two layers (docs/chart-theming-scope.md §1):
*
* - **Base** — the legibility/integration minimum: transparent background (the
* pane color shows through) and guide colors readable on the app's surfaces.
* Without this layer, stock black-on-white chart text is illegible on the
* dark pane. Colors match the app tokens (`--text`, `--text-secondary`,
* `--border`, `--border-strong`).
* - **Expressive** — the house style: IBM Plex, the Carbon data-viz categorical
* palette (white theme for light, g100 for dark — see carbon-charts
* `packages/core/scss/_color-palette.scss`), dotted grid, bumped guide
* sizes/weights, no plot border. This is the "free color" layer (doc §3.5,
* §5); charts render fine without it, just stock-looking.
*
* The split exists so a non-house chart style (stock preview, future custom
* themes) can keep the base layer while replacing the expressive one.
*/
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 { UiTheme } from './theme';
const PLEX = '"IBM Plex Sans", system-ui, -apple-system, sans-serif';
@@ -56,42 +68,74 @@ const darkCategory = [
'#d4bbff', // purple 30
];
export const lightChartConfig: Config = {
/** Base layer — light: transparent background + guide colors on app tokens. */
export const lightBaseConfig: Config = {
background: 'transparent',
font: PLEX,
title: { fontSize: 16, fontWeight: 600, color: '#161616' },
title: { color: '#161616' }, // --text (light)
axis: {
domainColor: '#c6c6c6', // --border-strong (light)
gridColor: '#e0e0e0', // --border (light)
gridDash: [2, 2],
labelColor: '#525252', // --text-secondary (light)
titleColor: '#161616', // --text (light)
labelFontSize: 11,
titleFontSize: 12,
titleFontWeight: 600,
},
range: { category: lightCategory },
view: { stroke: 'transparent' },
};
export const darkChartConfig: Config = {
/** Base layer — dark: transparent background + guide colors on app tokens. */
export const darkBaseConfig: Config = {
background: 'transparent',
font: PLEX,
title: { fontSize: 16, fontWeight: 600, color: '#f4f4f4' },
title: { color: '#f4f4f4' }, // --text (dark)
axis: {
domainColor: '#525252', // --border-strong (dark)
gridColor: '#393939', // --border (dark)
gridDash: [2, 2],
labelColor: '#a8a8a8', // --text-secondary (dark)
titleColor: '#f4f4f4', // --text (dark)
},
};
/** Expressive layer parts shared by both themes (everything but the palette). */
const sharedExpressive: Config = {
font: PLEX,
title: { fontSize: 16, fontWeight: 600 },
axis: {
gridDash: [2, 2],
labelFontSize: 11,
titleFontSize: 12,
titleFontWeight: 600,
},
range: { category: darkCategory },
view: { stroke: 'transparent' },
};
/** Expressive layer — light: house style + the light categorical palette. */
export const lightExpressiveConfig: Config = {
...sharedExpressive,
range: { category: lightCategory },
};
/** Expressive layer — dark: house style + the dark categorical palette. */
export const darkExpressiveConfig: Config = {
...sharedExpressive,
range: { category: darkCategory },
};
/**
* Merge a base and an expressive layer into one chart config. Shallow spread
* plus the two nested objects both layers contribute to (`title`, `axis`);
* the expressive layer wins on conflicts (there are none today — the layers
* own disjoint properties).
*/
export function mergeChartLayers(base: Config, expressive: Config): Config {
return {
...base,
...expressive,
title: { ...base.title, ...expressive.title },
axis: { ...base.axis, ...expressive.axis },
};
}
export const lightChartConfig: Config = mergeChartLayers(lightBaseConfig, lightExpressiveConfig);
export const darkChartConfig: Config = mergeChartLayers(darkBaseConfig, darkExpressiveConfig);
const CHART_CONFIG: Record<UiTheme, Config> = {
light: lightChartConfig,
dark: darkChartConfig,
@@ -101,3 +145,87 @@ const CHART_CONFIG: Record<UiTheme, Config> = {
export function chartConfigFor(theme: UiTheme): Config {
return CHART_CONFIG[theme];
}
/**
* Selectable chart themes (docs/chart-theming-scope.md §4.2) — the user-facing
* choice of how charts render, distinct from (and composed with) the UI theme:
*
* - `'astrolabe'` — the house style above; resolves per UI theme. Default.
* - `'stock'` — no injected config at all: charts render exactly as Vega-Lite
* defaults would anywhere else (white background, tableau10, sans-serif).
* - a `vega-themes` preset id — that preset's config verbatim, UI-theme
* independent, exactly as it would render in the Vega editor's theme dropdown.
*
* The spec's own `config` overrides whatever is selected, property by property
* (vega-lite merges `opt.config` under `spec.config`), so a snippet can always
* opt out locally.
*/
export type ChartThemeId = 'astrolabe' | 'stock' | ChartThemePresetId;
/** The vega-themes presets we surface, in display order. */
const PRESET_IDS = [
'excel',
'ggplot2',
'quartz',
'vox',
'fivethirtyeight',
'latimes',
'urbaninstitute',
'googlecharts',
'powerbi',
'carbonwhite',
'carbong10',
'carbong90',
'carbong100',
'dark',
] as const;
export type ChartThemePresetId = (typeof PRESET_IDS)[number];
/** Empty config — the stock sentinel resolves to "inject nothing". */
const STOCK_CONFIG: Config = {};
export interface ChartThemeOption {
value: ChartThemeId;
label: string;
/** Secondary line for pickers (what the choice means). */
detail?: string;
}
/** Display metadata for every selectable chart theme, in display order. */
export const CHART_THEME_OPTIONS: ReadonlyArray<ChartThemeOption> = [
{ value: 'astrolabe', label: 'Astrolabe', detail: 'House style, follows light/dark' },
{ value: 'stock', label: 'Stock Vega-Lite', detail: 'No theme applied' },
{ value: 'excel', label: 'Excel' },
{ value: 'ggplot2', label: 'ggplot2' },
{ value: 'quartz', label: 'Quartz' },
{ value: 'vox', label: 'Vox' },
{ value: 'fivethirtyeight', label: 'FiveThirtyEight' },
{ value: 'latimes', label: 'LA Times' },
{ value: 'urbaninstitute', label: 'Urban Institute' },
{ value: 'googlecharts', label: 'Google Charts' },
{ value: 'powerbi', label: 'Power BI' },
{ value: 'carbonwhite', label: 'Carbon — White' },
{ value: 'carbong10', label: 'Carbon — G10' },
{ value: 'carbong90', label: 'Carbon — G90' },
{ value: 'carbong100', label: 'Carbon — G100' },
{ value: 'dark', label: 'Vega Dark' },
];
const CHART_THEME_IDS: ReadonlySet<string> = new Set(['astrolabe', 'stock', ...PRESET_IDS]);
/** Type guard for persisted values (load-with-fallback; unknown ids fall back). */
export function isChartThemeId(value: unknown): value is ChartThemeId {
return typeof value === 'string' && CHART_THEME_IDS.has(value);
}
/**
* 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).
*/
export function chartConfigForSelection(selection: ChartThemeId, uiTheme: UiTheme): Config {
if (selection === 'astrolabe') return CHART_CONFIG[uiTheme];
if (selection === 'stock') return STOCK_CONFIG;
return presets[selection] as Config;
}
+11 -3
View File
@@ -1,7 +1,12 @@
import { createRoot } from 'react-dom/client';
import { App } from './app/App';
import { initPanes, wirePanes } from './app/orchestration/panes';
import { initPreviewFitMode, wirePreviewFitMode } from './app/orchestration/preferences';
import {
initChartTheme,
initPreviewFitMode,
wireChartTheme,
wirePreviewFitMode,
} from './app/orchestration/preferences';
import { initSettings, wireSettings } from './app/orchestration/settings';
import { initSnippetSort, wireSnippetSort } from './app/orchestration/snippet-sort';
import { initPersistentStorage, registerServiceWorker } from './app/orchestration/pwa';
@@ -15,10 +20,13 @@ import '../styles/base.css';
initTheme();
wireTheme();
// Hydrate + persist the small UI preferences (preview fit mode, pane widths) the
// same way. Pane widths hydrate before render so the layout opens as left.
// Hydrate + persist the small UI preferences (preview fit mode, chart theme,
// pane widths) the same way. Pane widths hydrate before render so the layout
// opens as left.
initPreviewFitMode();
wirePreviewFitMode();
initChartTheme();
wireChartTheme();
initPanes();
wirePanes();