-
-
- {/* A plain textarea, deliberately not Monaco: a second Monaco mount
- is heavy inside a modal for an occasional surface, and the parse
- error below is the feedback channel that matters here. Revisit
- only if real usage asks for config completions. */}
-
{draftConfig !== null &&
diff --git a/src/app/stores/CustomThemeStore.ts b/src/app/stores/CustomThemeStore.ts
index 9d701f6..b0b0426 100644
--- a/src/app/stores/CustomThemeStore.ts
+++ b/src/app/stores/CustomThemeStore.ts
@@ -64,6 +64,13 @@ export interface CustomThemeState {
* the current text is invalid JSON — fix the JSON first.
*/
applyDraftFont: (family: string) => void;
+ /**
+ * Apply a pure transform to the draft config and reflect it in both the
+ * parsed config (the gallery follows) and the reformatted text. The single
+ * write path for every structured control. No-op with a parse error — the
+ * controls are disabled while the JSON is invalid; fix the JSON first.
+ */
+ mutateDraftConfig: (fn: (config: JsonObject) => JsonObject) => void;
/**
* Validate and commit the draft to the selected theme. On failure sets
* `saveError`/`parseError` and returns false.
@@ -166,7 +173,10 @@ export const useCustomThemeStore = create((set, get) => ({
set({ draft: next, saveError: null });
},
- applyDraftFont: (family) => {
+ applyDraftFont: (family) =>
+ get().mutateDraftConfig((config) => applyFontToConfig(config, family)),
+
+ mutateDraftConfig: (fn) => {
const draft = get().draft;
if (!draft) return;
const parsed = parseConfigText(draft.configText);
@@ -174,7 +184,7 @@ export const useCustomThemeStore = create((set, get) => ({
set({ parseError: parsed.error });
return;
}
- const config = applyFontToConfig(parsed.config, family);
+ const config = fn(parsed.config);
set({
draft: { ...draft, configText: configToText(config) },
draftConfig: config,
diff --git a/src/core/custom-theme.test.ts b/src/core/custom-theme.test.ts
index c961e34..6696526 100644
--- a/src/core/custom-theme.test.ts
+++ b/src/core/custom-theme.test.ts
@@ -88,4 +88,20 @@ describe('THEME_PREVIEW_SPECS', () => {
const ids = THEME_PREVIEW_SPECS.map((c) => c.id);
expect(new Set(ids).size).toBe(ids.length);
});
+
+ it('exercises every color family the builder controls', () => {
+ type ColorDef = { type?: string; scale?: { domainMid?: unknown } };
+ const colorOf = (spec: (typeof THEME_PREVIEW_SPECS)[number]['spec']): ColorDef | undefined =>
+ (spec.encoding as { color?: ColorDef } | undefined)?.color;
+
+ const colors = THEME_PREVIEW_SPECS.map((c) => colorOf(c.spec));
+ // mark.color (a card with no color encoding), categorical, sequential
+ // (quantitative, no midpoint), and diverging (quantitative with a midpoint).
+ expect(colors.some((c) => c === undefined)).toBe(true);
+ expect(colors.some((c) => c?.type === 'nominal')).toBe(true);
+ expect(colors.some((c) => c?.type === 'quantitative' && c.scale?.domainMid === undefined)).toBe(
+ true,
+ );
+ expect(colors.some((c) => c?.scale?.domainMid !== undefined)).toBe(true);
+ });
});
diff --git a/src/core/theme-controls.test.ts b/src/core/theme-controls.test.ts
new file mode 100644
index 0000000..dd92091
--- /dev/null
+++ b/src/core/theme-controls.test.ts
@@ -0,0 +1,123 @@
+import { describe, expect, it } from 'vitest';
+import {
+ THEME_SCHEMES,
+ getConfigValue,
+ schemeColors,
+ schemesByKind,
+ setConfigValue,
+} from './theme-controls';
+
+describe('getConfigValue', () => {
+ const config = { range: { category: ['#111', '#222'] }, mark: { color: '#333' } };
+
+ it('reads a nested value', () => {
+ expect(getConfigValue(config, ['range', 'category'])).toEqual(['#111', '#222']);
+ expect(getConfigValue(config, ['mark', 'color'])).toBe('#333');
+ });
+
+ it('returns undefined for a missing key', () => {
+ expect(getConfigValue(config, ['legend', 'orient'])).toBeUndefined();
+ });
+
+ it('returns undefined when a segment is not an object', () => {
+ expect(getConfigValue(config, ['mark', 'color', 'deeper'])).toBeUndefined();
+ });
+});
+
+describe('setConfigValue', () => {
+ it('sets a leaf and preserves siblings', () => {
+ const config = { background: 'transparent', font: 'Inter' };
+ expect(setConfigValue(config, ['font'], 'Georgia')).toEqual({
+ background: 'transparent',
+ font: 'Georgia',
+ });
+ });
+
+ it('creates intermediate objects when setting deep', () => {
+ expect(setConfigValue({}, ['range', 'category'], ['#111'])).toEqual({
+ range: { category: ['#111'] },
+ });
+ });
+
+ it('preserves unknown sibling keys at the edited level (Vega-layer keys survive)', () => {
+ const config = { range: { category: ['#111'], symbol: { size: 60 } } };
+ expect(setConfigValue(config, ['range', 'category'], ['#aaa', '#bbb'])).toEqual({
+ range: { category: ['#aaa', '#bbb'], symbol: { size: 60 } },
+ });
+ });
+
+ it('deletes a leaf when the value is undefined', () => {
+ const config = { background: 'white', font: 'Inter' };
+ expect(setConfigValue(config, ['font'], undefined)).toEqual({ background: 'white' });
+ });
+
+ it('prunes an ancestor object the deletion empties', () => {
+ const config = { range: { category: ['#111'] }, font: 'Inter' };
+ expect(setConfigValue(config, ['range', 'category'], undefined)).toEqual({ font: 'Inter' });
+ });
+
+ it('keeps an ancestor that still holds sibling keys after a deletion', () => {
+ const config = { range: { category: ['#111'], symbol: { size: 60 } } };
+ expect(setConfigValue(config, ['range', 'category'], undefined)).toEqual({
+ range: { symbol: { size: 60 } },
+ });
+ });
+
+ it('is a no-op deleting an absent key', () => {
+ const config = { font: 'Inter' };
+ expect(setConfigValue(config, ['range', 'category'], undefined)).toEqual({ font: 'Inter' });
+ });
+
+ it('does not mutate the input', () => {
+ const config = { range: { category: ['#111'] } };
+ const snapshot = structuredClone(config);
+ setConfigValue(config, ['range', 'category'], ['#999']);
+ setConfigValue(config, ['range', 'category'], undefined);
+ expect(config).toEqual(snapshot);
+ });
+
+ it('returns the config unchanged for an empty path', () => {
+ const config = { font: 'Inter' };
+ expect(setConfigValue(config, [], 'x')).toBe(config);
+ });
+});
+
+describe('THEME_SCHEMES catalog', () => {
+ it('every catalog scheme resolves to a non-empty palette (guards registry drift)', () => {
+ const blank = THEME_SCHEMES.filter((s) => schemeColors(s.name).length === 0);
+ expect(blank.map((s) => s.name)).toEqual([]);
+ });
+
+ it('has no duplicate names', () => {
+ const names = THEME_SCHEMES.map((s) => s.name);
+ expect(new Set(names).size).toBe(names.length);
+ });
+
+ it('groups by kind in catalog order', () => {
+ expect(schemesByKind('categorical').every((s) => s.kind === 'categorical')).toBe(true);
+ expect(schemesByKind('sequential').length).toBeGreaterThan(0);
+ expect(schemesByKind('diverging').map((s) => s.name)).toContain('spectral');
+ });
+});
+
+describe('schemeColors', () => {
+ it('returns the full fixed palette for a categorical scheme (count ignored)', () => {
+ const colors = schemeColors('tableau10', 3);
+ expect(colors).toHaveLength(10);
+ expect(colors.every((c) => /^#[0-9a-f]{6}$/i.test(c))).toBe(true);
+ });
+
+ it('samples a continuous scheme at count stops, normalized to hex', () => {
+ const colors = schemeColors('viridis', 5);
+ expect(colors).toHaveLength(5);
+ expect(colors.every((c) => /^#[0-9a-f]{6}$/.test(c))).toBe(true);
+ });
+
+ it('samples the midpoint for a single continuous stop', () => {
+ expect(schemeColors('viridis', 1)).toHaveLength(1);
+ });
+
+ it('returns [] for an unknown scheme', () => {
+ expect(schemeColors('not-a-scheme')).toEqual([]);
+ });
+});
diff --git a/src/core/theme-controls.ts b/src/core/theme-controls.ts
new file mode 100644
index 0000000..23c72ba
--- /dev/null
+++ b/src/core/theme-controls.ts
@@ -0,0 +1,186 @@
+/**
+ * Theme Builder structured-control primitives (docs/chart-theming-scope.md §5).
+ *
+ * Pure core: the read/write transforms the builder's structured controls run on
+ * a draft config, plus the named-color-scheme catalog and its resolution. Three
+ * concerns:
+ *
+ * - **Path get/set** — read a value at a nested config path, and set one back
+ * immutably while preserving every sibling key at each level. That guarantee
+ * is the point: the vega-themes presets carry Vega-_layer_ keys (`symbol`,
+ * `shape`, `path`, `group`) that are absent from the Vega-Lite `Config`
+ * schema but forwarded to Vega unchanged, so they must survive a control's
+ * edit rather than be dropped by a rebuild-from-schema. Setting `undefined`
+ * deletes the leaf and prunes objects the deletion empties, so a control
+ * writes a minimal diff, never a default dump.
+ * - **Scheme catalog** — the named Vega color schemes the color controls offer,
+ * grouped by kind (categorical / sequential / diverging).
+ * - **Scheme resolution** (`schemeColors`) — a scheme name to hex swatches, for
+ * the picker preview and the "materialize to an editable array" action.
+ *
+ * `vega-scale` (a focused vega sub-package, like core's `vega-expression`) owns
+ * the scheme registry; importing it here keeps the umbrella `vega` out of core.
+ * The one control transform that predates this module, `applyFontToConfig`,
+ * stays in custom-theme.ts with the record entity.
+ */
+
+import { scheme } from 'vega-scale';
+import { isJsonObject, type JsonObject } from './spec-config';
+
+// ── Path get/set ──────────────────────────────────────────────────────────
+
+/** A nested config location, e.g. `['range', 'category']` or `['mark', 'color']`. */
+export type ConfigPath = readonly string[];
+
+/** Read the value at `path`, or undefined if any segment is missing/non-object. */
+export function getConfigValue(config: JsonObject, path: ConfigPath): unknown {
+ let cur: unknown = config;
+ for (const key of path) {
+ if (!isJsonObject(cur)) return undefined;
+ cur = cur[key];
+ }
+ return cur;
+}
+
+/**
+ * Immutably set `value` at `path`. Siblings at every level are preserved (the
+ * key-preservation guarantee §5 rests on). `value === undefined` deletes the
+ * leaf and prunes any ancestor object the deletion leaves empty. Intermediate
+ * objects are created as needed; a non-object value blocking the path is
+ * replaced with a fresh object. Returns a new object; input not mutated. An
+ * empty path returns the config unchanged.
+ */
+export function setConfigValue(config: JsonObject, path: ConfigPath, value: unknown): JsonObject {
+ if (path.length === 0) return config;
+ const [head, ...rest] = path;
+
+ if (rest.length === 0) {
+ if (value === undefined) return omitKey(config, head);
+ return { ...config, [head]: value };
+ }
+
+ const child = isJsonObject(config[head]) ? config[head] : {};
+ const nextChild = setConfigValue(child, rest, value);
+ // A deletion that empties the child prunes the child too (minimal diff); a
+ // child still holding sibling keys (e.g. a preset's Vega-layer keys) stays.
+ if (value === undefined && Object.keys(nextChild).length === 0) {
+ return omitKey(config, head);
+ }
+ return { ...config, [head]: nextChild };
+}
+
+/** A copy of `obj` without `key` (returns the same object when the key is absent). */
+function omitKey(obj: JsonObject, key: string): JsonObject {
+ if (!(key in obj)) return obj;
+ const { [key]: _omit, ...rest } = obj;
+ return rest;
+}
+
+// ── Named color schemes ─────────────────────────────────────────────────────
+
+type SchemeKind = 'categorical' | 'sequential' | 'diverging';
+
+interface ThemeScheme {
+ /** The Vega scheme id — written verbatim into `range.*` and resolved by Vega. */
+ name: string;
+ /** Human label for the picker. */
+ label: string;
+ kind: SchemeKind;
+}
+
+/**
+ * The named Vega schemes the color controls offer, in display order within each
+ * kind. Categorical feeds `range.category`; sequential feeds `range.heatmap` /
+ * `ramp` / `ordinal`; diverging feeds `range.diverging`. Every name is verified
+ * present in the installed vega-scale registry by the catalog test, so a
+ * registry change surfaces as a test failure rather than a blank swatch.
+ */
+export const THEME_SCHEMES: ReadonlyArray = [
+ // Categorical
+ { name: 'tableau10', label: 'Tableau 10', kind: 'categorical' },
+ { name: 'tableau20', label: 'Tableau 20', kind: 'categorical' },
+ { name: 'category10', label: 'Category 10', kind: 'categorical' },
+ { name: 'category20', label: 'Category 20', kind: 'categorical' },
+ { name: 'category20b', label: 'Category 20b', kind: 'categorical' },
+ { name: 'category20c', label: 'Category 20c', kind: 'categorical' },
+ { name: 'observable10', label: 'Observable 10', kind: 'categorical' },
+ { name: 'accent', label: 'Accent', kind: 'categorical' },
+ { name: 'dark2', label: 'Dark 2', kind: 'categorical' },
+ { name: 'paired', label: 'Paired', kind: 'categorical' },
+ { name: 'set1', label: 'Set 1', kind: 'categorical' },
+ { name: 'set2', label: 'Set 2', kind: 'categorical' },
+ { name: 'set3', label: 'Set 3', kind: 'categorical' },
+ { name: 'pastel1', label: 'Pastel 1', kind: 'categorical' },
+ { name: 'pastel2', label: 'Pastel 2', kind: 'categorical' },
+ // Sequential (single- and multi-hue)
+ { name: 'viridis', label: 'Viridis', kind: 'sequential' },
+ { name: 'magma', label: 'Magma', kind: 'sequential' },
+ { name: 'inferno', label: 'Inferno', kind: 'sequential' },
+ { name: 'plasma', label: 'Plasma', kind: 'sequential' },
+ { name: 'cividis', label: 'Cividis', kind: 'sequential' },
+ { name: 'turbo', label: 'Turbo', kind: 'sequential' },
+ { name: 'blues', label: 'Blues', kind: 'sequential' },
+ { name: 'greens', label: 'Greens', kind: 'sequential' },
+ { name: 'greys', label: 'Greys', kind: 'sequential' },
+ { name: 'oranges', label: 'Oranges', kind: 'sequential' },
+ { name: 'purples', label: 'Purples', kind: 'sequential' },
+ { name: 'reds', label: 'Reds', kind: 'sequential' },
+ { name: 'bluegreen', label: 'Blue-Green', kind: 'sequential' },
+ { name: 'bluepurple', label: 'Blue-Purple', kind: 'sequential' },
+ { name: 'greenblue', label: 'Green-Blue', kind: 'sequential' },
+ { name: 'orangered', label: 'Orange-Red', kind: 'sequential' },
+ { name: 'purpleblue', label: 'Purple-Blue', kind: 'sequential' },
+ { name: 'purplebluegreen', label: 'Purple-Blue-Green', kind: 'sequential' },
+ { name: 'purplered', label: 'Purple-Red', kind: 'sequential' },
+ { name: 'redpurple', label: 'Red-Purple', kind: 'sequential' },
+ { name: 'yellowgreen', label: 'Yellow-Green', kind: 'sequential' },
+ { name: 'yellowgreenblue', label: 'Yellow-Green-Blue', kind: 'sequential' },
+ { name: 'yelloworangebrown', label: 'Yellow-Orange-Brown', kind: 'sequential' },
+ { name: 'yelloworangered', label: 'Yellow-Orange-Red', kind: 'sequential' },
+ // Diverging
+ { name: 'blueorange', label: 'Blue-Orange', kind: 'diverging' },
+ { name: 'brownbluegreen', label: 'Brown-Blue-Green', kind: 'diverging' },
+ { name: 'purplegreen', label: 'Purple-Green', kind: 'diverging' },
+ { name: 'pinkyellowgreen', label: 'Pink-Yellow-Green', kind: 'diverging' },
+ { name: 'purpleorange', label: 'Purple-Orange', kind: 'diverging' },
+ { name: 'redblue', label: 'Red-Blue', kind: 'diverging' },
+ { name: 'redgrey', label: 'Red-Grey', kind: 'diverging' },
+ { name: 'redyellowblue', label: 'Red-Yellow-Blue', kind: 'diverging' },
+ { name: 'redyellowgreen', label: 'Red-Yellow-Green', kind: 'diverging' },
+ { name: 'spectral', label: 'Spectral', kind: 'diverging' },
+];
+
+/** The schemes of one kind, in catalog order. */
+export function schemesByKind(kind: SchemeKind): ThemeScheme[] {
+ return THEME_SCHEMES.filter((s) => s.kind === kind);
+}
+
+// ── Scheme resolution ───────────────────────────────────────────────────────
+
+/**
+ * Resolve a named Vega scheme to hex swatches. A categorical scheme returns its
+ * fixed palette in full; a continuous scheme (sequential/diverging) is sampled
+ * at `count` evenly-spaced stops (`count` applies to continuous schemes only).
+ * Continuous interpolators yield `rgb(...)`, normalized to hex here. An unknown
+ * name returns `[]` — the picker shows that scheme without a preview rather than
+ * throwing. Used for the swatch/gradient preview and "materialize to swatches".
+ */
+export function schemeColors(name: string, count = 9): string[] {
+ const resolved: unknown = scheme(name);
+ if (Array.isArray(resolved)) return resolved.map((c) => toHex(String(c)));
+ if (typeof resolved === 'function' && count > 0) {
+ const interp = resolved as (t: number) => string;
+ if (count === 1) return [toHex(interp(0.5))];
+ return Array.from({ length: count }, (_, i) => toHex(interp(i / (count - 1))));
+ }
+ return [];
+}
+
+/** Normalize a CSS color to `#rrggbb`; passes through existing hex and unknowns. */
+function toHex(color: string): string {
+ if (color.startsWith('#')) return color;
+ const m = /rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i.exec(color);
+ if (!m) return color;
+ const h = (n: string) => Math.round(Number(n)).toString(16).padStart(2, '0');
+ return `#${h(m[1])}${h(m[2])}${h(m[3])}`;
+}
diff --git a/src/core/theme-preview-specs.ts b/src/core/theme-preview-specs.ts
index eeb5dab..b05cdc5 100644
--- a/src/core/theme-preview-specs.ts
+++ b/src/core/theme-preview-specs.ts
@@ -4,13 +4,16 @@
* A fixed set of small, self-contained Vega-Lite specs the Theme Builder
* renders side by side with the draft config, so an edit is previewed across
* every chart surface a config styles: titles and subtitles, axes and grids,
- * categorical and gradient legends, facet headers, and the major mark types.
- * Inline data only, compact fixed sizes — these are swatches, not analyses.
+ * facet headers, the major mark types, and — so every color control has a
+ * mirror — each color family: categorical (`range.category`), sequential
+ * (`range.heatmap` for rect, `range.ramp` for a continuous legend), and
+ * diverging (`range.diverging`, selected by a quantitative color scale with a
+ * `domainMid`). Inline data only, compact fixed sizes — swatches, not analyses.
*/
import type { JsonObject } from './spec-config';
-const SCHEMA = 'https://vega.github.io/schema/vega-lite/v5.json';
+const SCHEMA = 'https://vega.github.io/schema/vega-lite/v6.json';
export interface ThemePreviewSpec {
/** Stable key for React lists and test assertions. */
@@ -160,6 +163,39 @@ const heatmap: ThemePreviewSpec = {
},
};
+const diverging: ThemePreviewSpec = {
+ id: 'diverging',
+ caption: 'Diverging — scale around a midpoint',
+ spec: {
+ $schema: SCHEMA,
+ title: 'Net change by topic',
+ width: 200,
+ height: 140,
+ data: {
+ values: [
+ { topic: 'Cost', delta: -8 },
+ { topic: 'Speed', delta: -3 },
+ { topic: 'Help', delta: 1 },
+ { topic: 'Look', delta: 6 },
+ { topic: 'Value', delta: 11 },
+ ],
+ },
+ mark: 'bar',
+ encoding: {
+ x: { field: 'topic', type: 'nominal', axis: { labelAngle: 0 } },
+ y: { field: 'delta', type: 'quantitative' },
+ // `domainMid` makes this a diverging color scale, so it reads from
+ // `range.diverging` rather than `range.ramp`/`heatmap` (the others above).
+ color: {
+ field: 'delta',
+ type: 'quantitative',
+ scale: { domainMid: 0 },
+ legend: { title: null },
+ },
+ },
+ },
+};
+
const donut: ThemePreviewSpec = {
id: 'donut',
caption: 'Donut — palette, symbol legend',
@@ -212,13 +248,15 @@ const facet: ThemePreviewSpec = {
},
};
-/** The gallery, in display order. */
+/** The gallery, in display order — the three continuous-color examples
+ * (scatter → ramp, heatmap → heatmap, diverging → diverging) sit together. */
export const THEME_PREVIEW_SPECS: ReadonlyArray = [
bar,
line,
area,
scatter,
heatmap,
+ diverging,
donut,
facet,
];
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
index 25999b8..de2029a 100644
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -21,3 +21,11 @@ declare module 'vega-lite/vega-lite-schema.json' {
declare module 'monaco-editor/esm/vs/editor/edcore.main' {
export * from 'monaco-editor/esm/vs/editor/editor.api';
}
+
+// vega-scale ships types at `index.d.ts` but its package.json `exports` maps
+// only `default` (no `types`), so `bundler` resolution can't find them. Declare
+// the one symbol core uses: `scheme(name)` returns a categorical color array, a
+// continuous interpolator, or undefined for an unknown name.
+declare module 'vega-scale' {
+ export function scheme(name: string): string[] | ((t: number) => string) | undefined;
+}