mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Chart theming: custom named themes + Theme Builder
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user