Theme Builder: Layout/Axes/Legend/Type panels, scheme-render fix, fail-loud gallery

This commit is contained in:
2026-06-14 13:08:29 +03:00
parent b03c3b08ab
commit 91b8b1e7fe
19 changed files with 1381 additions and 135 deletions
+139
View File
@@ -0,0 +1,139 @@
/**
* Theme Builder — Axes & grid panel (docs/chart-theming-scope.md §5).
*
* Structured controls over the base `axis` config only — grid visibility, grid
* color and dash style, the domain line, label color and angle, and title
* color. The 25 per-channel variants (`axisX`, `axisY`, `axisBand`, …) stay in
* the JSON; this is the common surface a brand actually tunes. Axis *type*
* (label/title size and weight) lives in the Type panel.
*/
import type { JsonObject } from '@core/spec-config';
import {
asBoolean,
asNumber,
asString,
type ConfigPath,
getConfigValue,
} from '@core/theme-controls';
import { useConfigSetter } from '../stores/CustomThemeStore';
import { ColorRow, ControlSection, NumberRow, SelectRow } from './ThemeFields';
import type { SelectControlOption } from './SelectControl';
import styles from './ThemeFields.module.css';
const GRID: ConfigPath = ['axis', 'grid'];
const GRID_COLOR: ConfigPath = ['axis', 'gridColor'];
const GRID_DASH: ConfigPath = ['axis', 'gridDash'];
const DOMAIN_COLOR: ConfigPath = ['axis', 'domainColor'];
const LABEL_COLOR: ConfigPath = ['axis', 'labelColor'];
const LABEL_ANGLE: ConfigPath = ['axis', 'labelAngle'];
const TITLE_COLOR: ConfigPath = ['axis', 'titleColor'];
const GRID_GREY = '#888888';
// Grid visibility: tri-state (theme default · shown · hidden) over a boolean.
type GridState = '' | 'true' | 'false';
const gridOptions: SelectControlOption<GridState>[] = [
{ value: '', label: 'Theme default' },
{ value: 'true', label: 'Shown' },
{ value: 'false', label: 'Hidden' },
];
const gridState = (v: boolean | undefined): GridState =>
v === undefined ? '' : v ? 'true' : 'false';
// Dash presets, matched by array shape; an unrecognised array reads as no preset
// (the trigger shows "—") so the control never misreports a hand-authored dash.
type DashStyle = '' | 'solid' | 'dotted' | 'dashed' | 'custom';
const dashOptions: SelectControlOption<DashStyle>[] = [
{ value: '', label: 'Theme default' },
{ value: 'solid', label: 'Solid' },
{ value: 'dotted', label: 'Dotted' },
{ value: 'dashed', label: 'Dashed' },
];
const DASH_VALUES: Record<Exclude<DashStyle, '' | 'custom'>, number[]> = {
solid: [],
dotted: [2, 2],
dashed: [6, 3],
};
const dashStyle = (v: unknown): DashStyle => {
if (v === undefined) return '';
if (!Array.isArray(v)) return 'custom';
if (v.length === 0) return 'solid';
if (v.length === 2 && v[0] === 2 && v[1] === 2) return 'dotted';
if (v.length === 2 && v[0] === 6 && v[1] === 3) return 'dashed';
return 'custom';
};
export function AxesControls({ config }: { config: JsonObject }) {
const set = useConfigSetter();
const grid = asBoolean(getConfigValue(config, GRID));
const gridColor = asString(getConfigValue(config, GRID_COLOR));
const dash = dashStyle(getConfigValue(config, GRID_DASH));
const domainColor = asString(getConfigValue(config, DOMAIN_COLOR));
const labelColor = asString(getConfigValue(config, LABEL_COLOR));
const labelAngle = asNumber(getConfigValue(config, LABEL_ANGLE));
const titleColor = asString(getConfigValue(config, TITLE_COLOR));
return (
<div className={styles.panel}>
<ControlSection title="Grid" hint="Reference lines behind the marks.">
<SelectRow
id="axes-grid"
label="Grid lines"
options={gridOptions}
value={gridState(grid)}
onSelect={(s) => set(GRID, s === '' ? undefined : s === 'true')}
/>
<ColorRow
label="Grid color"
value={gridColor}
fallback={GRID_GREY}
onChange={(hex) => set(GRID_COLOR, hex)}
onClear={() => set(GRID_COLOR, undefined)}
/>
<SelectRow
id="axes-grid-dash"
label="Grid style"
options={dashOptions}
value={dash}
onSelect={(s) =>
set(GRID_DASH, s === '' ? undefined : DASH_VALUES[s as keyof typeof DASH_VALUES])
}
/>
</ControlSection>
<ControlSection title="Domain & labels" hint="The axis line, its tick labels, and title.">
<ColorRow
label="Domain line"
value={domainColor}
fallback={GRID_GREY}
onChange={(hex) => set(DOMAIN_COLOR, hex)}
onClear={() => set(DOMAIN_COLOR, undefined)}
/>
<ColorRow
label="Label color"
value={labelColor}
fallback={GRID_GREY}
onChange={(hex) => set(LABEL_COLOR, hex)}
onClear={() => set(LABEL_COLOR, undefined)}
/>
<NumberRow
label="Label angle"
value={labelAngle}
min={-90}
max={90}
unit="°"
onChange={(n) => set(LABEL_ANGLE, n)}
/>
<ColorRow
label="Title color"
value={titleColor}
fallback={GRID_GREY}
onChange={(hex) => set(TITLE_COLOR, hex)}
onClear={() => set(TITLE_COLOR, undefined)}
/>
</ControlSection>
</div>
);
}
+6 -5
View File
@@ -170,25 +170,26 @@ describe('ColorControls', () => {
expect(tableau.querySelectorAll('span span').length).toBeGreaterThan(0);
});
test('picking a scheme writes range.category as a string', () => {
test('picking a scheme writes range.category as a Vega scheme object', () => {
render();
open({});
act(() => picker('Categorical color scheme')!.click());
act(() => button('Category 10')!.click());
expect(range().category).toBe('category10');
// The `{ scheme }` object — a bare scheme-name string is rejected at render.
expect(range().category).toEqual({ scheme: 'category10' });
});
test('a sequential pick sets both heatmap and ramp', () => {
test('a sequential pick sets both heatmap and ramp to the scheme object', () => {
render();
open({});
act(() => picker('Sequential color scheme')!.click());
act(() => button('Viridis')!.click());
expect(range().heatmap).toBe('viridis');
expect(range().ramp).toBe('viridis');
expect(range().heatmap).toEqual({ scheme: 'viridis' });
expect(range().ramp).toEqual({ scheme: 'viridis' });
});
test('invalid JSON disables the controls', () => {
+35 -22
View File
@@ -8,29 +8,25 @@
* and the gallery follow; reads come from the parsed draft config, so a JSON
* hand-edit reflects straight back into the controls.
*
* Color model (§5): every family holds either a named Vega scheme (compact, the
* quick path) or an explicit color array (custom tuning). Picking a scheme from
* the preview-bearing dropdown writes the name; "Materialize" expands it to an
* editable array of swatches — categorical, sequential, and diverging alike.
* Color model (§5): every family holds either a named Vega scheme as the
* range-scheme object `{ scheme: name }` (compact, the quick path) or an explicit
* color array (custom tuning). Picking a scheme from the preview-bearing dropdown
* writes that object (a bare string is rejected by Vega at render); "Materialize"
* expands it to an editable array of swatches — categorical, sequential, and
* diverging alike.
* Each swatch pairs the native color picker with a hex text field, so a value
* can be read, copied, and retyped anywhere.
*/
import type { ReactNode } from 'react';
import type { JsonObject } from '@core/spec-config';
import {
type ConfigPath,
getConfigValue,
schemeColors,
schemesByKind,
setConfigValue,
} from '@core/theme-controls';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { type ConfigPath, getConfigValue, schemeColors, schemesByKind } from '@core/theme-controls';
import { Button } from './Button';
import { ColorField } from './ColorField';
import { Icon } from './Icon';
import { IconButton } from './IconButton';
import { SelectControl, type SelectControlOption } from './SelectControl';
import { useConfigSetter } from '../stores/CustomThemeStore';
import styles from './ColorControls.module.css';
const CATEGORY: ConfigPath = ['range', 'category'];
@@ -53,6 +49,22 @@ const asArray = (v: unknown): string[] | null =>
Array.isArray(v) && v.every((c) => typeof c === 'string') ? v : null;
const asString = (v: unknown): string | null => (typeof v === 'string' ? v : null);
/**
* A named scheme is stored in a `range` family as Vega's range-scheme object
* `{ scheme: name }`. A bare scheme-name string compiles but is rejected by Vega
* at render ("Unrecognized scale range value: …"), blanking the chart — so reads
* accept either the object or a legacy/hand-authored bare string, while writes
* (`schemeRange`) always use the object form.
*/
const asScheme = (v: unknown): string | null => {
if (typeof v === 'string') return v;
if (v && typeof v === 'object' && typeof (v as { scheme?: unknown }).scheme === 'string') {
return (v as { scheme: string }).scheme;
}
return null;
};
const schemeRange = (name: string): { scheme: string } => ({ scheme: name });
const gradientCss = (colors: string[]): string =>
colors.length ? `linear-gradient(90deg, ${colors.join(', ')})` : 'transparent';
@@ -117,25 +129,26 @@ function SwatchRow({
}
export function ColorControls({ config }: { config: JsonObject }) {
const mutate = useCustomThemeStore((s) => s.mutateDraftConfig);
const set = (path: ConfigPath, value: unknown) => mutate((c) => setConfigValue(c, path, value));
const set = useConfigSetter();
// Sequential color lives in two slots (heatmaps + continuous legends); keep them together.
const setSeq = (value: unknown) =>
mutate((c) => setConfigValue(setConfigValue(c, HEATMAP, value), RAMP, value));
const setSeq = (value: unknown) => {
set(HEATMAP, value);
set(RAMP, value);
};
const catValue = getConfigValue(config, CATEGORY);
const catArray = asArray(catValue);
const catScheme = asString(catValue);
const catScheme = asScheme(catValue);
const markColor = asString(getConfigValue(config, MARK_COLOR));
const seqValue = getConfigValue(config, HEATMAP);
const seqArray = asArray(seqValue);
const seqScheme = asString(seqValue);
const seqScheme = asScheme(seqValue);
const divValue = getConfigValue(config, DIVERGING);
const divArray = asArray(divValue);
const divScheme = asString(divValue);
const divScheme = asScheme(divValue);
/** Stops to drive a gradient preview for a family in any of its states. */
const previewStops = (array: string[] | null, scheme: string | null): string[] =>
@@ -155,7 +168,7 @@ export function ColorControls({ config }: { config: JsonObject }) {
heading="Color scheme"
options={CATEGORICAL_OPTIONS}
value={catScheme ?? undefined}
onSelect={(name) => set(CATEGORY, name)}
onSelect={(name) => set(CATEGORY, schemeRange(name))}
triggerContent={
<>
<span className={styles.triggerPreview} aria-hidden="true">
@@ -252,7 +265,7 @@ export function ColorControls({ config }: { config: JsonObject }) {
heading="Sequential scheme"
options={SEQUENTIAL_OPTIONS}
value={seqScheme ?? undefined}
onSelect={(name) => setSeq(name)}
onSelect={(name) => setSeq(schemeRange(name))}
triggerContent={
<>
<span>
@@ -315,7 +328,7 @@ export function ColorControls({ config }: { config: JsonObject }) {
heading="Diverging scheme"
options={DIVERGING_OPTIONS}
value={divScheme ?? undefined}
onSelect={(name) => set(DIVERGING, name)}
onSelect={(name) => set(DIVERGING, schemeRange(name))}
triggerContent={
<>
<span>
+133
View File
@@ -0,0 +1,133 @@
/**
* Theme Builder — Layout panel (docs/chart-theming-scope.md §5).
*
* Structured controls over the draft config's surfaces and spacing: the chart
* `background`, the plot area's `view` fill / border / corner radius, and outer
* `padding`. Fill controls are tri-state (theme default · transparent/none ·
* custom color) because "transparent" is a meaningful, distinct choice from
* "unset" here — the house style sets `background` and `view.stroke` to
* transparent, and stock Vega-Lite defaults them to white / `#ddd`.
*/
import type { JsonObject } from '@core/spec-config';
import { isJsonObject } from '@core/spec-config';
import { asNumber, asString, type ConfigPath, getConfigValue } from '@core/theme-controls';
import { useConfigSetter } from '../stores/CustomThemeStore';
import { ColorRow, ControlSection, NumberRow, SelectRow } from './ThemeFields';
import type { SelectControlOption } from './SelectControl';
import styles from './ThemeFields.module.css';
const BACKGROUND: ConfigPath = ['background'];
const VIEW_FILL: ConfigPath = ['view', 'fill'];
const VIEW_STROKE: ConfigPath = ['view', 'stroke'];
const VIEW_RADIUS: ConfigPath = ['view', 'cornerRadius'];
const PADDING: ConfigPath = ['padding'];
type FillMode = 'default' | 'transparent' | 'custom';
const fillMode = (v: unknown): FillMode =>
v === undefined ? 'default' : v === 'transparent' ? 'transparent' : 'custom';
const bgOptions: SelectControlOption<FillMode>[] = [
{ value: 'default', label: 'Theme default' },
{ value: 'transparent', label: 'Transparent' },
{ value: 'custom', label: 'Custom color' },
];
// Same modes; "None" reads better than "Transparent" for a border.
const strokeOptions: SelectControlOption<FillMode>[] = [
{ value: 'default', label: 'Theme default' },
{ value: 'transparent', label: 'None' },
{ value: 'custom', label: 'Custom color' },
];
const fillValue = (mode: FillMode, current: string | undefined, seed: string): unknown =>
mode === 'default' ? undefined : mode === 'transparent' ? 'transparent' : (current ?? seed);
export function LayoutControls({ config }: { config: JsonObject }) {
const set = useConfigSetter();
const bg = asString(getConfigValue(config, BACKGROUND));
const bgMode = fillMode(getConfigValue(config, BACKGROUND));
const viewFill = asString(getConfigValue(config, VIEW_FILL));
const stroke = asString(getConfigValue(config, VIEW_STROKE));
const strokeMode = fillMode(getConfigValue(config, VIEW_STROKE));
const radius = asNumber(getConfigValue(config, VIEW_RADIUS));
const padding = getConfigValue(config, PADDING);
const paddingNum = asNumber(padding);
return (
<div className={styles.panel}>
<ControlSection title="Background" hint="Fill behind the whole chart, padding included.">
<SelectRow
id="layout-bg-mode"
label="Background"
options={bgOptions}
value={bgMode}
onSelect={(m) => set(BACKGROUND, fillValue(m, bg, '#ffffff'))}
/>
{bgMode === 'custom' && (
<ColorRow
label="Color"
name="Background color"
value={bg}
fallback="#ffffff"
onChange={(hex) => set(BACKGROUND, hex)}
/>
)}
</ControlSection>
<ControlSection title="Plot area" hint="The plotting rectangle inside the axes.">
<ColorRow
label="Fill"
name="Plot area fill"
value={viewFill}
fallback="#ffffff"
onChange={(hex) => set(VIEW_FILL, hex)}
onClear={() => set(VIEW_FILL, undefined)}
/>
<SelectRow
id="layout-stroke-mode"
label="Border"
options={strokeOptions}
value={strokeMode}
onSelect={(m) => set(VIEW_STROKE, fillValue(m, stroke, '#cccccc'))}
/>
{strokeMode === 'custom' && (
<ColorRow
label="Border color"
value={stroke}
fallback="#cccccc"
onChange={(hex) => set(VIEW_STROKE, hex)}
/>
)}
<NumberRow
label="Corner radius"
value={radius}
min={0}
unit="px"
onChange={(n) => set(VIEW_RADIUS, n)}
/>
</ControlSection>
<ControlSection title="Spacing" hint="Margin between the chart and its container edge.">
{isJsonObject(padding) ? (
<p className={styles.hint}>
Padding is set per-side as an object edit it in the JSON below.
</p>
) : (
<NumberRow
label="Padding"
value={paddingNum}
min={0}
unit="px"
onChange={(n) => set(PADDING, n)}
/>
)}
</ControlSection>
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
/**
* Theme Builder — Legend panel (docs/chart-theming-scope.md §5).
*
* Structured controls over the base `legend` config: placement (`orient`), the
* title's and labels' color and size, and the symbol size. Legend type (size)
* lives here rather than the Type panel so every legend property a brand tunes
* sits together — the scope's "label/title color+size" grouping.
*/
import type { JsonObject } from '@core/spec-config';
import { asNumber, asString, type ConfigPath, getConfigValue } from '@core/theme-controls';
import { useConfigSetter } from '../stores/CustomThemeStore';
import { ColorRow, ControlSection, NumberRow, SelectRow } from './ThemeFields';
import type { SelectControlOption } from './SelectControl';
import styles from './ThemeFields.module.css';
const ORIENT: ConfigPath = ['legend', 'orient'];
const TITLE_COLOR: ConfigPath = ['legend', 'titleColor'];
const TITLE_SIZE: ConfigPath = ['legend', 'titleFontSize'];
const LABEL_COLOR: ConfigPath = ['legend', 'labelColor'];
const LABEL_SIZE: ConfigPath = ['legend', 'labelFontSize'];
const SYMBOL_SIZE: ConfigPath = ['legend', 'symbolSize'];
const TEXT_GREY = '#888888';
// Vega-Lite legend `orient` values; '' is the unset (theme default) sentinel.
type Orient = '' | 'right' | 'left' | 'top' | 'bottom' | 'top-left' | 'top-right' | 'none';
const orientOptions: SelectControlOption<Orient>[] = [
{ value: '', label: 'Theme default' },
{ value: 'right', label: 'Right' },
{ value: 'left', label: 'Left' },
{ value: 'top', label: 'Top' },
{ value: 'bottom', label: 'Bottom' },
{ value: 'top-left', label: 'Top-left' },
{ value: 'top-right', label: 'Top-right' },
{ value: 'none', label: 'Hidden' },
];
const orientValue = (v: unknown): Orient => {
const s = asString(v);
return s !== undefined && orientOptions.some((o) => o.value === s) ? (s as Orient) : '';
};
export function LegendControls({ config }: { config: JsonObject }) {
const set = useConfigSetter();
const orient = orientValue(getConfigValue(config, ORIENT));
const titleColor = asString(getConfigValue(config, TITLE_COLOR));
const titleSize = asNumber(getConfigValue(config, TITLE_SIZE));
const labelColor = asString(getConfigValue(config, LABEL_COLOR));
const labelSize = asNumber(getConfigValue(config, LABEL_SIZE));
const symbolSize = asNumber(getConfigValue(config, SYMBOL_SIZE));
return (
<div className={styles.panel}>
<ControlSection title="Placement" hint="Where the legend sits relative to the plot.">
<SelectRow
id="legend-orient"
label="Position"
options={orientOptions}
value={orient}
onSelect={(o) => set(ORIENT, o === '' ? undefined : o)}
/>
</ControlSection>
<ControlSection title="Title">
<ColorRow
label="Color"
name="Legend title color"
value={titleColor}
fallback={TEXT_GREY}
onChange={(hex) => set(TITLE_COLOR, hex)}
onClear={() => set(TITLE_COLOR, undefined)}
/>
<NumberRow
label="Size"
name="Legend title size"
value={titleSize}
min={0}
unit="px"
onChange={(n) => set(TITLE_SIZE, n)}
/>
</ControlSection>
<ControlSection title="Labels">
<ColorRow
label="Color"
name="Legend label color"
value={labelColor}
fallback={TEXT_GREY}
onChange={(hex) => set(LABEL_COLOR, hex)}
onClear={() => set(LABEL_COLOR, undefined)}
/>
<NumberRow
label="Size"
name="Legend label size"
value={labelSize}
min={0}
unit="px"
onChange={(n) => set(LABEL_SIZE, n)}
/>
</ControlSection>
<ControlSection title="Symbols" hint="The colored keys beside each label.">
<NumberRow
label="Symbol size"
value={symbolSize}
min={0}
unit="px²"
onChange={(n) => set(SYMBOL_SIZE, n)}
/>
</ControlSection>
</div>
);
}
+17 -25
View File
@@ -191,31 +191,6 @@
color: var(--text-secondary);
}
.typePanel {
display: grid;
gap: var(--space-3);
padding: var(--space-5);
}
.typeTitle {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.typeHint {
margin: 0;
font-size: 12px;
color: var(--text-secondary);
}
.fontCaret {
margin-left: var(--space-3);
font-size: 10px;
color: var(--text-secondary);
}
/* ── Controls + JSON (left) | gallery rail (right) ─────────────────────── */
/* The gallery takes the whole right side, full height; the controls column —
@@ -322,3 +297,20 @@
font-size: 11px;
color: var(--text-secondary);
}
/* A render failure shows its message in place of the chart (fail-loud, arch 02),
sized to the reserved card box so the gallery doesn't reflow. */
.cardError {
min-width: 260px;
min-height: 180px;
margin: 0;
padding: var(--space-3);
display: flex;
align-items: center;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.4;
color: var(--support-error);
white-space: pre-wrap;
overflow: auto;
}
+20 -5
View File
@@ -11,13 +11,12 @@ 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 { renderSpec } from '../services/chart-renderer';
import { ThemeBuilderModal } from './ThemeBuilderModal';
vi.mock('../services/chart-renderer', () => ({
renderSpec: vi.fn(() =>
Promise.resolve({ destroy() {}, resize() {}, toImageURL: () => Promise.resolve('') }),
),
}));
vi.mock('../services/chart-renderer', () => ({ renderSpec: vi.fn() }));
const okHandle = () => ({ destroy() {}, resize() {}, toImageURL: () => Promise.resolve('') });
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -26,6 +25,8 @@ let root: Root;
beforeEach(() => {
vi.useFakeTimers();
vi.mocked(renderSpec).mockReset();
vi.mocked(renderSpec).mockImplementation(() => Promise.resolve(okHandle()));
useCustomThemeStore.getState().reset();
useAppStore.getState().setChartTheme('astrolabe');
container = document.createElement('div');
@@ -110,6 +111,20 @@ describe('ThemeBuilderModal', () => {
expect(container.textContent).toContain('Invalid JSON');
});
test('a gallery card surfaces a render failure instead of blanking silently', async () => {
vi.mocked(renderSpec).mockRejectedValue(new Error('Vega could not render this config'));
renderModal();
act(() => {
useCustomThemeStore.getState().createTheme('Brand', {});
});
// Fire the gallery's render debounce and flush the async render chain.
await act(async () => {
await vi.advanceTimersByTimeAsync(400);
});
expect(container.textContent).toContain('Vega could not render this config');
expect(container.querySelector('[role="alert"]')).toBeTruthy();
});
test('selecting another theme from the list swaps the draft', () => {
renderModal();
act(() => {
+38 -52
View File
@@ -12,9 +12,9 @@
*/
import { useEffect, useRef, useState } 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 { normalizeRangeSchemes } from '@core/theme-controls';
import { chartConfigForSelection, chartThemeOptions } from '@core/vega-themes';
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
import { useAppStore } from '../stores/AppStore';
@@ -26,29 +26,24 @@ import {
} from '../stores/CustomThemeStore';
import { notify } from '../stores/NotificationStore';
import { resnapshot } from '../modals/ModalCoordinator';
import { AxesControls } from './AxesControls';
import { Button } from './Button';
import { ColorControls } from './ColorControls';
import { SelectControl, type SelectControlOption } from './SelectControl';
import { LayoutControls } from './LayoutControls';
import { LegendControls } from './LegendControls';
import { TypeControls } from './TypeControls';
import styles from './ThemeBuilderModal.module.css';
/** Structured-control tabs, in display order (docs/chart-theming-scope.md §5). */
const THEME_TABS = [
{ id: 'color', label: 'Color' },
{ id: 'type', label: 'Type' },
{ id: 'layout', label: 'Layout' },
{ id: 'axes', label: 'Axes & grid' },
{ id: 'legend', label: 'Legend' },
] as const;
type ThemeTab = (typeof THEME_TABS)[number]['id'];
/**
* Font options, each labelled in its own family so the dropdown previews the
* typeface (the type analogue of the color dropdowns' swatches). Render-safe
* faces only today — see THEME_FONT_OPTIONS.
*/
const FONT_OPTIONS: SelectControlOption<string>[] = THEME_FONT_OPTIONS.map(({ value, label }) => ({
value,
label,
labelStyle: { fontFamily: value },
}));
/** Debounce for gallery re-renders while the config text is edited (ms). */
const GALLERY_DEBOUNCE = 250;
@@ -58,14 +53,17 @@ const GALLERY_DEBOUNCE = 250;
* 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.
* shared host node. A render failure surfaces in the card as its message (the
* same fail-loud treatment LivePreview gives the editor — arch 02), never a
* silent blank: a config the user is editing that Vega can't render is exactly
* the feedback they need.
*/
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());
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const node = hostRef.current;
@@ -83,7 +81,10 @@ function GalleryCard({ card, config }: { card: ThemePreviewSpec; config: JsonObj
if (mine !== generationRef.current) return;
handleRef.current?.destroy();
handleRef.current = null;
const handle = await renderSpec(node, card.spec, config, {
// Heal a bare range scheme string (rejected by Vega at render) the same
// way the live preview does — so a theme loaded with the old form still
// previews. New control writes already use the `{ scheme }` object.
const handle = await renderSpec(node, card.spec, normalizeRangeSchemes(config), {
renderer: 'canvas',
});
if (mine !== generationRef.current) {
@@ -91,8 +92,12 @@ function GalleryCard({ card, config }: { card: ThemePreviewSpec; config: JsonObj
return;
}
handleRef.current = handle;
} catch {
// Leave the card blank; the config editor reports the actionable error.
setError(null);
} catch (err) {
// Don't bury it (arch 02 fail-loud): show the message in the card so a
// config that renders on valid JSON but Vega rejects at runtime is
// visible, not a silent blank.
if (mine === generationRef.current) setError((err as Error).message);
} finally {
release();
}
@@ -112,7 +117,12 @@ function GalleryCard({ card, config }: { card: ThemePreviewSpec; config: JsonObj
return (
<figure className={styles.card}>
<div className={styles.cardHost} ref={hostRef} />
{error !== null && (
<pre className={styles.cardError} role="alert">
{error}
</pre>
)}
<div className={styles.cardHost} ref={hostRef} hidden={error !== null} />
<figcaption className={styles.cardCaption}>{card.caption}</figcaption>
</figure>
);
@@ -133,12 +143,6 @@ export function ThemeBuilderModal() {
// (it's the only place to fix the JSON).
const [jsonExpanded, setJsonExpanded] = useState(false);
const jsonOpen = jsonExpanded || parseError !== null;
// The font option matching the config's top-level font (if any) — drives the
// Type tab's selected state and its in-face trigger label.
const currentFont =
typeof draftConfig?.font === 'string'
? FONT_OPTIONS.find((o) => o.value === draftConfig.font)
: undefined;
// APG tabs: arrow/Home/End move selection, which follows focus (automatic
// activation — the panel swap is cheap). The portaled SelectControl popovers
@@ -302,35 +306,17 @@ export function ThemeBuilderModal() {
<p className={styles.controlsDisabled}>
Fix the JSON below to use these controls.
</p>
) : activeTab === 'color' && draftConfig !== null ? (
) : draftConfig === null ? null : activeTab === 'color' ? (
<ColorControls config={draftConfig} />
) : activeTab === 'type' ? (
<div className={styles.typePanel}>
<h4 className={styles.typeTitle}>Font family</h4>
<p className={styles.typeHint}>
Writes one family into every font slot of the config.
</p>
<SelectControl
id="theme-builder-font"
label="Font family"
heading="Apply font"
options={FONT_OPTIONS}
value={currentFont?.value}
onSelect={(family) => useCustomThemeStore.getState().applyDraftFont(family)}
triggerContent={
<>
<span style={currentFont ? { fontFamily: currentFont.value } : undefined}>
{currentFont?.label ?? 'Apply font…'}
</span>
<span className={styles.fontCaret} aria-hidden="true">
</span>
</>
}
triggerTitle="Write one font family into every font slot of the config"
/>
</div>
) : null}
<TypeControls config={draftConfig} />
) : activeTab === 'layout' ? (
<LayoutControls config={draftConfig} />
) : activeTab === 'axes' ? (
<AxesControls config={draftConfig} />
) : (
<LegendControls config={draftConfig} />
)}
</div>
{/* Raw JSON — collapsed by default (the structured controls are the
@@ -0,0 +1,173 @@
/**
* Layout / Axes & grid / Legend / Type panels — behavioural wiring through the
* live modal. The pure config transforms (path get/set, coercion) are covered in
* core/theme-controls.test.ts; these confirm each panel reads the draft config
* and writes the right path back through `mutateDraftConfig`, including the
* minimal-diff delete (clearing a value removes the key). vega-embed is mocked
* (the gallery is integration-heavy).
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { JsonObject } from '@core/spec-config';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { usePopoverStore } from '../stores/PopoverStore';
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();
usePopoverStore.getState().close(); // the open-popover registry is global; isolate tests
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
});
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.useRealTimers();
});
const render = () => act(() => root.render(<ThemeBuilderModal />));
/** Open a draft seeded with `config`, then switch to a structured-control tab. */
const open = (config: JsonObject, tabLabel: string) => {
act(() => {
useCustomThemeStore.getState().createTheme('Brand', config);
});
const tab = [...container.querySelectorAll('button')].find(
(b) => b.getAttribute('role') === 'tab' && b.textContent === tabLabel,
)!;
act(() => tab.click());
};
const config = () => useCustomThemeStore.getState().draftConfig as JsonObject;
const at = (...path: string[]): unknown =>
path.reduce<unknown>(
(cur, key) =>
cur && typeof cur === 'object' ? (cur as Record<string, unknown>)[key] : undefined,
config(),
);
/** Drive an input's value through the native setter so React's tracker fires onChange. */
function setNativeValue(el: HTMLInputElement, value: string) {
// eslint-disable-next-line @typescript-eslint/unbound-method -- invoked immediately via .call
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
setter.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
}
const numberInput = (name: string) =>
container.querySelector<HTMLInputElement>(`input[aria-label="${name}"]`)!;
/** A button anywhere in the document (popovers portal to <body>) by exact label. */
const button = (label: string) =>
[...document.querySelectorAll('button')].find((b) => b.textContent === label);
/** Open the SelectControl whose accessible name starts with `prefix`, then pick `option`. */
const pick = (prefix: string, option: string) => {
const trigger = [...container.querySelectorAll('button')].find((b) =>
b.getAttribute('aria-label')?.startsWith(prefix),
)!;
act(() => trigger.click());
act(() => button(option)!.click());
};
describe('LayoutControls', () => {
test('background mode "Transparent" writes background: transparent', () => {
render();
open({}, 'Layout');
pick('Background', 'Transparent');
expect(config().background).toBe('transparent');
});
test('corner radius writes view.cornerRadius and clearing prunes the view object', () => {
render();
open({}, 'Layout');
act(() => setNativeValue(numberInput('Corner radius'), '8'));
expect(at('view', 'cornerRadius')).toBe(8);
act(() => setNativeValue(numberInput('Corner radius'), ''));
expect(config().view).toBeUndefined();
});
test('object padding shows the JSON hint instead of a number control', () => {
render();
open({ padding: { left: 5, top: 5 } }, 'Layout');
expect(container.textContent).toContain('edit it in the JSON below');
expect(container.querySelector('input[aria-label="Padding"]')).toBeNull();
});
});
describe('AxesControls', () => {
test('grid lines "Hidden" writes axis.grid false', () => {
render();
open({}, 'Axes & grid');
pick('Grid lines', 'Hidden');
expect(at('axis', 'grid')).toBe(false);
});
test('grid style "Dotted" writes a dash array; "Theme default" removes it', () => {
render();
open({}, 'Axes & grid');
pick('Grid style', 'Dotted');
expect(at('axis', 'gridDash')).toEqual([2, 2]);
pick('Grid style', 'Theme default');
expect(at('axis', 'gridDash')).toBeUndefined();
});
test('a negative label angle is accepted', () => {
render();
open({}, 'Axes & grid');
act(() => setNativeValue(numberInput('Label angle'), '-45'));
expect(at('axis', 'labelAngle')).toBe(-45);
});
});
describe('LegendControls', () => {
test('position writes legend.orient', () => {
render();
open({}, 'Legend');
pick('Position', 'Bottom');
expect(at('legend', 'orient')).toBe('bottom');
});
test('symbol size writes legend.symbolSize', () => {
render();
open({}, 'Legend');
act(() => setNativeValue(numberInput('Symbol size'), '120'));
expect(at('legend', 'symbolSize')).toBe(120);
});
});
describe('TypeControls', () => {
test('title weight "Bold" writes title.fontWeight 700', () => {
render();
open({}, 'Type');
pick('Title weight', 'Bold');
expect(at('title', 'fontWeight')).toBe(700);
});
test('axis label size writes axis.labelFontSize', () => {
render();
open({}, 'Type');
act(() => setNativeValue(numberInput('Axis label size'), '9'));
expect(at('axis', 'labelFontSize')).toBe(9);
});
});
+77
View File
@@ -0,0 +1,77 @@
/* Theme Builder structured-control field primitives — shared by the Layout,
Axes & grid, Legend, and Type panels (docs/chart-theming-scope.md §5). */
.panel {
display: grid;
gap: var(--space-6);
padding: var(--space-5);
}
.group {
display: grid;
gap: var(--space-3);
}
.groupTitle {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.hint {
margin: 0;
font-size: 12px;
color: var(--text-secondary);
}
/* Fields within a section stack with a label column for cross-row alignment. */
.fields {
display: grid;
gap: var(--space-3);
}
.field {
display: grid;
grid-template-columns: 132px 1fr;
align-items: center;
gap: var(--space-3);
min-height: var(--control-height);
}
.fieldLabel {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.control {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.number {
width: 88px;
height: var(--control-height);
padding: 0 var(--space-3);
font-size: 13px;
}
.unit {
font-size: 12px;
color: var(--text-secondary);
}
.defaultNote {
font-size: 12px;
color: var(--text-secondary);
}
/* Caret for a custom SelectControl trigger (e.g. the Type panel's in-face font). */
.caret {
margin-left: var(--space-3);
font-size: 10px;
color: var(--text-secondary);
}
+195
View File
@@ -0,0 +1,195 @@
/**
* Theme Builder structured-control field primitives (docs/chart-theming-scope.md
* §5).
*
* The Layout / Axes & grid / Legend / Type panels are forms of scalar controls
* over the draft config — a labelled color, number, or enum per config key. This
* is the small shared vocabulary they're built from, so the panels read
* declaratively and look identical:
*
* - `ControlSection` — a titled group with an optional hint.
* - `ColorRow` / `NumberRow` / `SelectRow` — one labelled control each.
*
* Each control reports a value or `undefined` (cleared); the panel writes it
* through `mutateDraftConfig` + `setConfigValue`, where `undefined` deletes the
* key for a minimal diff. The Color panel predates this module and keeps its own
* swatch/gradient controls; these primitives cover the scalar surface the later
* panels share. Leaf coercion (`asString`/`asNumber`/`asBoolean`) lives in core
* `theme-controls.ts` with the path get/set it pairs with.
*/
import { useState, type ReactNode } from 'react';
import { Button } from './Button';
import { ColorField } from './ColorField';
import { SelectControl, type SelectControlOption } from './SelectControl';
import styles from './ThemeFields.module.css';
const slug = (s: string): string =>
s
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
/**
* A titled group of fields with an optional one-line hint. A `role="group"`
* labelled by its heading, so a screen reader announces the group context when
* entering it — which is what lets the rows inside carry short labels ("Size",
* "Color") that repeat across sections without colliding (APG group pattern).
*/
export function ControlSection({
title,
hint,
children,
}: {
title: string;
hint?: string;
children: ReactNode;
}) {
const headingId = `theme-group-${slug(title)}`;
return (
<section className={styles.group} role="group" aria-labelledby={headingId}>
<h4 id={headingId} className={styles.groupTitle}>
{title}
</h4>
{hint && <p className={styles.hint}>{hint}</p>}
<div className={styles.fields}>{children}</div>
</section>
);
}
/**
* A labelled color. When unset, the swatch shows `fallback` (the rendered
* default) with a "Default" note rather than a blank chip; once set, a Clear
* deletes the key.
*/
export function ColorRow({
label,
name,
value,
fallback,
onChange,
onClear,
}: {
label: string;
/** Accessible name when `label` is too generic to stand alone (e.g. "Size"). */
name?: string;
value: string | undefined;
fallback: string;
onChange: (hex: string) => void;
onClear?: () => void;
}) {
return (
<div className={styles.field}>
<span className={styles.fieldLabel}>{label}</span>
<div className={styles.control}>
<ColorField value={value ?? fallback} label={name ?? label} onChange={onChange} hex />
{value !== undefined ? (
onClear && (
<Button variant="ghost" onClick={onClear}>
Clear
</Button>
)
) : (
<span className={styles.defaultNote}>Default</span>
)}
</div>
</div>
);
}
/**
* A labelled number. Local text state so a transient entry (a lone "-", a
* half-typed value) isn't rejected mid-keystroke — commits a finished number,
* deletes the key when emptied, reverts to the committed value on blur. Resyncs
* to an outside change (another control, a JSON edit) the same way ColorField's
* hex field does.
*/
export function NumberRow({
label,
name,
value,
onChange,
min,
max,
step,
unit,
}: {
label: string;
/** Accessible name when `label` is too generic to stand alone (e.g. "Size"). */
name?: string;
value: number | undefined;
onChange: (value: number | undefined) => void;
min?: number;
max?: number;
step?: number;
unit?: string;
}) {
const [text, setText] = useState(value === undefined ? '' : String(value));
const [synced, setSynced] = useState(value);
if (value !== synced) {
setSynced(value);
const typed = text.trim() === '' ? undefined : Number(text);
if (typed !== value) setText(value === undefined ? '' : String(value));
}
return (
<div className={styles.field}>
<span className={styles.fieldLabel}>{label}</span>
<div className={styles.control}>
<input
type="number"
className={styles.number}
aria-label={name ?? label}
value={text}
min={min}
max={max}
step={step}
onChange={(e) => {
const raw = e.target.value;
setText(raw);
const t = raw.trim();
if (t === '') onChange(undefined);
else if (t !== '-' && Number.isFinite(Number(t))) onChange(Number(t));
}}
onBlur={() => setText(value === undefined ? '' : String(value))}
/>
{unit && <span className={styles.unit}>{unit}</span>}
</div>
</div>
);
}
/**
* A labelled enum, on the app's SelectControl. The caller maps the config value
* to/from option strings; an unset config key is conventionally the `''` option
* ("Theme default"), so the trigger always shows a current choice.
*/
export function SelectRow<V extends string>({
id,
label,
name,
options,
value,
onSelect,
}: {
id: string;
label: string;
/** Accessible name when `label` is too generic to stand alone (e.g. "Weight"). */
name?: string;
options: ReadonlyArray<SelectControlOption<V>>;
value: V;
onSelect: (value: V) => void;
}) {
return (
<div className={styles.field}>
<span className={styles.fieldLabel}>{label}</span>
<SelectControl
id={id}
label={name ?? label}
options={options}
value={value}
onSelect={onSelect}
/>
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
/**
* Theme Builder — Type panel (docs/chart-theming-scope.md §5).
*
* The font family (written into every font slot via `applyDraftFont`, the one
* transform that walks the whole config) plus the type scale a brand tunes:
* title size/weight and axis title/label size/weight. Legend type lives in the
* Legend panel so all legend properties sit together. Color of text lives in
* the per-domain panels (Axes, Legend); this panel is family, size, and weight.
*/
import { THEME_FONT_OPTIONS } from '@core/custom-theme';
import type { JsonObject } from '@core/spec-config';
import { asNumber, type ConfigPath, getConfigValue } from '@core/theme-controls';
import { useConfigSetter, useCustomThemeStore } from '../stores/CustomThemeStore';
import { SelectControl, type SelectControlOption } from './SelectControl';
import { ControlSection, NumberRow, SelectRow } from './ThemeFields';
import styles from './ThemeFields.module.css';
const TITLE_SIZE: ConfigPath = ['title', 'fontSize'];
const TITLE_WEIGHT: ConfigPath = ['title', 'fontWeight'];
const AXIS_TITLE_SIZE: ConfigPath = ['axis', 'titleFontSize'];
const AXIS_TITLE_WEIGHT: ConfigPath = ['axis', 'titleFontWeight'];
const AXIS_LABEL_SIZE: ConfigPath = ['axis', 'labelFontSize'];
const AXIS_LABEL_WEIGHT: ConfigPath = ['axis', 'labelFontWeight'];
/**
* Font options, each labelled in its own family so the dropdown previews the
* typeface (the type analogue of the color dropdowns' swatches). The roster
* (THEME_FONT_OPTIONS) is loaded before render by the chart-renderer's font gate.
*/
const FONT_OPTIONS: SelectControlOption<string>[] = THEME_FONT_OPTIONS.map(({ value, label }) => ({
value,
label,
labelStyle: { fontFamily: value },
}));
// Numeric font weights as a tri-state enum; '' is the unset (default) sentinel.
type Weight = '' | '400' | '500' | '600' | '700' | 'custom';
const weightOptions: SelectControlOption<Weight>[] = [
{ value: '', label: 'Theme default' },
{ value: '400', label: 'Normal' },
{ value: '500', label: 'Medium' },
{ value: '600', label: 'Semibold' },
{ value: '700', label: 'Bold' },
];
const weightValue = (v: unknown): Weight => {
if (v === undefined) return '';
if (typeof v === 'number') {
const s = String(v) as Weight;
return weightOptions.some((o) => o.value === s) ? s : 'custom';
}
// Named CSS weights map onto the two presets that have names.
if (v === 'normal') return '400';
if (v === 'bold') return '700';
return 'custom';
};
export function TypeControls({ config }: { config: JsonObject }) {
const set = useConfigSetter();
const currentFont =
typeof config.font === 'string' ? FONT_OPTIONS.find((o) => o.value === config.font) : undefined;
const setWeight = (path: ConfigPath) => (w: Weight) =>
set(path, w === '' ? undefined : Number(w));
return (
<div className={styles.panel}>
<ControlSection title="Font family" hint="Applied to every text slot in the config.">
<div className={styles.field}>
<span className={styles.fieldLabel}>Font</span>
<SelectControl
id="theme-builder-font"
label="Font family"
heading="Apply font"
options={FONT_OPTIONS}
value={currentFont?.value}
onSelect={(family) => useCustomThemeStore.getState().applyDraftFont(family)}
triggerContent={
<>
<span style={currentFont ? { fontFamily: currentFont.value } : undefined}>
{currentFont?.label ?? 'Apply font…'}
</span>
<span className={styles.caret} aria-hidden="true">
</span>
</>
}
triggerTitle="Write one font family into every font slot of the config"
/>
</div>
</ControlSection>
<ControlSection title="Title">
<NumberRow
label="Size"
name="Title size"
value={asNumber(getConfigValue(config, TITLE_SIZE))}
min={0}
unit="px"
onChange={(n) => set(TITLE_SIZE, n)}
/>
<SelectRow
id="type-title-weight"
label="Weight"
name="Title weight"
options={weightOptions}
value={weightValue(getConfigValue(config, TITLE_WEIGHT))}
onSelect={setWeight(TITLE_WEIGHT)}
/>
</ControlSection>
<ControlSection title="Axis titles">
<NumberRow
label="Size"
name="Axis title size"
value={asNumber(getConfigValue(config, AXIS_TITLE_SIZE))}
min={0}
unit="px"
onChange={(n) => set(AXIS_TITLE_SIZE, n)}
/>
<SelectRow
id="type-axis-title-weight"
label="Weight"
name="Axis title weight"
options={weightOptions}
value={weightValue(getConfigValue(config, AXIS_TITLE_WEIGHT))}
onSelect={setWeight(AXIS_TITLE_WEIGHT)}
/>
</ControlSection>
<ControlSection title="Axis labels">
<NumberRow
label="Size"
name="Axis label size"
value={asNumber(getConfigValue(config, AXIS_LABEL_SIZE))}
min={0}
unit="px"
onChange={(n) => set(AXIS_LABEL_SIZE, n)}
/>
<SelectRow
id="type-axis-label-weight"
label="Weight"
name="Axis label weight"
options={weightOptions}
value={weightValue(getConfigValue(config, AXIS_LABEL_WEIGHT))}
onSelect={setWeight(AXIS_LABEL_WEIGHT)}
/>
</ControlSection>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Chart-renderer contract regression: the `range` scheme config form.
*
* The Theme Builder's Color panel writes a named scheme into `config.range.*`.
* A bare scheme-name *string* passes vega-lite compile but Vega rejects it at
* run ("Unrecognized scale range value"), silently blanking the chart — so the
* panel must write the object form `{ scheme: name }`. This drives a real
* vega-lite compile → vega parse → view run (renderer 'none', no DOM) over an
* actual gallery spec to lock that in. (renderSpec itself is integration-heavy —
* vega-embed + a live DOM — and is mocked in component tests; this covers the
* config contract underneath it.)
*/
import { describe, expect, it } from 'vitest';
import { compile } from 'vega-lite';
import * as vega from 'vega';
import type { TopLevelSpec } from 'vega-lite';
import type { Config } from 'vega-lite';
import { THEME_PREVIEW_SPECS } from '@core/theme-preview-specs';
const nominalColorSpec = THEME_PREVIEW_SPECS.find((c) => c.id === 'line')!
.spec as unknown as TopLevelSpec;
async function run(config: Config): Promise<void> {
const view = new vega.View(vega.parse(compile(nominalColorSpec, { config }).spec), {
renderer: 'none',
});
await view.runAsync();
view.finalize();
}
describe('range scheme config form', () => {
it('renders with the { scheme } object form (what the Color panel writes)', async () => {
await expect(run({ range: { category: { scheme: 'category20b' } } })).resolves.toBeUndefined();
});
it('renders with an explicit color array', async () => {
await expect(
run({ range: { category: ['#111111', '#222222', '#333333'] } }),
).resolves.toBeUndefined();
});
it('rejects a bare scheme-name string (the bug this guards against)', async () => {
await expect(
run({ range: { category: 'category20b' } as unknown as Config['range'] }),
).rejects.toThrow(/Unrecognized scale range value/);
});
});
+15
View File
@@ -21,6 +21,7 @@ import { create } from 'zustand';
import { applyFontToConfig, createCustomTheme, type CustomTheme } from '@core/custom-theme';
import { isNameTaken, makeUniqueName } from '@core/naming';
import { isJsonObject, type JsonObject } from '@core/spec-config';
import { type ConfigPath, setConfigValue } from '@core/theme-controls';
import { customThemeSelection } from '@core/vega-themes';
import { useAppStore } from './AppStore';
@@ -264,6 +265,20 @@ export const useCustomThemeStore = create<CustomThemeState>((set, get) => ({
}),
}));
/**
* The write half every Theme Builder structured-control panel shares: a
* `set(path, value)` that mutates the draft config through `mutateDraftConfig`,
* deleting the leaf (and pruning emptied ancestors) when `value` is `undefined`.
* Panels bind their controls to this, so the read (`getConfigValue` + the `as*`
* coercions) and write live in one vocabulary. Lives here, beside
* `mutateDraftConfig`, rather than in the JSX field module — the field components
* must stay component-only for fast refresh.
*/
export function useConfigSetter(): (path: ConfigPath, value: unknown) => void {
const mutate = useCustomThemeStore((s) => s.mutateDraftConfig);
return (path, value) => mutate((c) => setConfigValue(c, path, value));
}
/** Selector: the saved record the builder has open, or null. Derive — never store. */
export const selectSelectedTheme = (s: CustomThemeState): CustomTheme | null =>
s.themes.find((t) => t.id === s.selectedId) ?? null;