Theme Builder: structured color/type controls, scheme catalog, diverging preview

This commit is contained in:
2026-06-14 01:25:44 +03:00
parent 0470389b41
commit e4d1466ab0
17 changed files with 1607 additions and 52 deletions
@@ -236,6 +236,30 @@ key-by-key), so a snippet can always override or opt out locally. The
that boundary deliberately: merge bakes the selected theme into `spec.config` that boundary deliberately: merge bakes the selected theme into `spec.config`
(spec keys win — rendering unchanged), extract lifts `spec.config` out. (spec keys win — rendering unchanged), extract lifts `spec.config` out.
### Structured controls
The builder's panels (`ColorControls`; the Type tab's font control) are
accelerators over the same `draftConfig`: each reads a value and writes one back
through `CustomThemeStore.mutateDraftConfig(fn)` — the single transform path,
which reparses, reformats, and updates `draftConfig` so the JSON editor and
gallery follow (a parse error disables the controls). The pure transforms live
in `core/theme-controls.ts`: immutable config path get/set, the named-scheme
catalog (`THEME_SCHEMES`), and `schemeColors` (scheme name → hex swatches, from
the `vega-scale` registry — a focused vega sub-package). A color family holds
**either** a named scheme string **or** an explicit color array; the picker
materializes one to the other. Family by scale: `range.category` (nominal),
`range.ramp` (continuous; `range.heatmap` for `rect`), `range.diverging`
(continuous color with a `domainMid`).
- **Do** route structured edits through `mutateDraftConfig` + `setConfigValue`,
which sets a value at a path **immutably, preserving sibling keys**, and
deletes (pruning emptied ancestors) on `undefined` so a theme stays a diff.
- **Don't** rebuild the config from a fixed schema: vega-themes presets carry
Vega-_layer_ keys (`symbol`/`shape`/`path`/`group`) absent from the Vega-Lite
`Config` schema but forwarded to Vega — a rebuild drops them. Merge in place.
- **Do** add a `theme-preview-specs.ts` gallery card for any new color family,
so no control ships without a visible mirror.
### Rules ### Rules
- **Do** keep `chartConfigForSelection` as the _only_ place that maps the user's - **Do** keep `chartConfigForSelection` as the _only_ place that maps the user's
+74 -1
View File
@@ -165,7 +165,80 @@ ships, it is an explicit per-font user action, never automatic.
**Rejected:** per-snippet theme field (2026-06-12 — `spec.config` + merge/extract covers **Rejected:** per-snippet theme field (2026-06-12 — `spec.config` + merge/extract covers
it without a second mechanism). it without a second mechanism).
## 5. Status log ## 5. Structured controls (slice 4b)
The builder today is a raw JSON textarea + one font dropdown + the live gallery. Slice 4b
adds a strip of structured controls above the editor — accelerators that write into the
JSON, never replacing it. The JSON stays the source of truth and the full-power escape
hatch; controls cover the common ~80% (color, type, spacing, grid), not all 72 config
properties (that is the trap vega-editor deliberately avoids by staying JSON).
**Hard constraint — the builder must preserve unknown keys.** Verified by compiling: the
vega-themes presets carry Vega-_layer_ keys (`symbol`, `shape`, `path`, `group`) that are
not in the Vega-Lite `Config` schema, and Vega-Lite forwards the whole config to Vega
unchanged — they take effect. So a structured control must **merge into** the existing
config (immutable path-set that spreads siblings), never rebuild it from a closed
schema-typed model, or it silently drops those keys on a round-trip. Same shape as
`applyFontToConfig`, which walks and rewrites rather than reconstructing.
**Resolved design points:**
- **Surfacing — inline tab strip** (not popovers, not sub-modals). Tabs (Color / Type /
Layout / Axes & grid / Legend) sit between the toolbar and the JSON+gallery, all in the
one xlarge modal. No nested overlays/focus traps, no contention with the one-open-popover
registry, and panels + JSON + gallery stay visible together.
- **Color model — scheme picker that materializes to swatches.** A `range` family takes
either an explicit color array or a named Vega scheme string (both verified to compile).
Pick a named scheme for the quick path; "materialize" expands it to an editable swatch
array for brand tuning. Catalog ships 15 categorical + 24 sequential + 10 diverging
schemes; categorical schemes resolve to arrays, continuous ones to interpolators sampled
into stops for the gradient preview and the materialize action.
- Structured controls are gated on valid JSON (same as the font control): a parse error
disables them and the textarea is the fix.
- Controls write **minimal** config — clearing a value deletes the key rather than writing
a default, so a theme stays a diff against stock, not a full dump.
**Panels:** Color (`range.category` swatches/scheme, `mark.color`, `range.heatmap`/`ramp`/
`diverging`) · Type (base `font`, title/axis/legend size+weight) · Layout (`background` incl.
transparent, `padding`, `view.stroke`/`fill`/cornerRadius) · Axes & grid (grid on/off + color
- dash, domain, label color/angle — base `axis` only; the 25 variants stay JSON) · Legend
(orient, label/title color+size, symbol size).
**Build order:** (a) core foundation — scheme catalog + immutable config path get/set +
`schemeColors` materialize, with tests; (b) Color panel (highest payoff); (c) Type, Layout,
Axes, Legend panels; (d) wire the tab strip into the modal.
**Not in slice 4b:** the house style's own gaps — no `mark.color` (single-series charts stay
Vega-blue), unset legend/header/padding — are left for a separate house-style redo, not
papered over here. Minor cleanup noted: `theme-preview-specs.ts` declares `$schema` v5 while
the app standardizes on v6.
## 6. Status log
- **2026-06-14 (slice 4b, first increment)** — **structured-control foundation + Color
panel.** Core `theme-controls.ts`: immutable config path get/set (preserves siblings —
the Vega-layer-key guarantee — and prunes on delete) + the named-scheme catalog (15
categorical / 24 sequential / 10 diverging) + `schemeColors` resolution (categorical
arrays passthrough, continuous interpolators sampled to hex), all tested. `vega-scale`
added as a declared dep (focused sub-package, like `vega-expression`) with a typings
shim in `vite-env.d.ts` (its package.json `exports` omits `types`). Store gains the
generic `mutateDraftConfig(fn)` write path; `applyDraftFont` refactored onto it. Modal
gains an APG tab strip — **Color** (categorical scheme/swatches + materialize, default
`mark.color`, sequential/diverging gradient pickers) and **Type** (the relocated font
control). Tabpanel gated on valid JSON. From first-use feedback, same day: the modal
body is now **controls + JSON on the left, gallery as a full-height right rail** (the
previews were starved before); `SelectControl` gained an optional per-option `preview`
so the scheme dropdowns show swatch strips (categorical) / gradient bars (continuous);
every swatch is a reusable `SwatchRow` (color picker + copyable/editable hex field); and
sequential/diverging gained **Materialize → editable stops**, so custom gradient colors
are possible, not just named schemes. Second feedback pass: the raw JSON is now a
**collapsed disclosure** at the bottom of the controls column (it was eating half the
first screen), forced open only on a parse error; the structured controls fill the
column. `SelectControl` options gained a `labelStyle`, so the **font dropdown renders
each name in its own family** (the type analogue of the color swatches) and its trigger
shows the current font in-face. Verified: typecheck, lint, full tests (950).
Remaining: Layout / Axes & grid / Legend panels; swatch reorder.
- **2026-06-12 (slice 4 close-out)** — **custom themes in the §08 envelope.** The - **2026-06-12 (slice 4 close-out)** — **custom themes in the §08 envelope.** The
workspace export now writes a `themes` array (additive — no format bump; importers workspace export now writes a `themes` array (additive — no format bump; importers
+1
View File
@@ -18,6 +18,7 @@
"vega-embed": "^7.1.0", "vega-embed": "^7.1.0",
"vega-expression": "^6.1.0", "vega-expression": "^6.1.0",
"vega-lite": "^6.4.2", "vega-lite": "^6.4.2",
"vega-scale": "^8.1.0",
"vega-themes": "3.0.0", "vega-themes": "3.0.0",
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
+1
View File
@@ -34,6 +34,7 @@
"vega-embed": "^7.1.0", "vega-embed": "^7.1.0",
"vega-expression": "^6.1.0", "vega-expression": "^6.1.0",
"vega-lite": "^6.4.2", "vega-lite": "^6.4.2",
"vega-scale": "^8.1.0",
"vega-themes": "3.0.0", "vega-themes": "3.0.0",
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
+143
View File
@@ -0,0 +1,143 @@
/* Theme Builder Color panel — structured controls over the draft config. */
.panel {
display: grid;
gap: var(--space-6);
padding: var(--space-5);
overflow: auto;
}
.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);
}
.row {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.caret {
color: var(--text-secondary);
margin-left: var(--space-3);
}
/* Editable swatch rows (palette colors / gradient stops) wrap into a grid. */
.swatches {
display: flex;
align-items: center;
gap: var(--space-3) var(--space-4);
flex-wrap: wrap;
}
/* One swatch: color picker + copyable hex field, with a hover remove. */
.swatchUnit {
position: relative;
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.hexInput {
width: 8ch;
height: var(--control-height);
padding: 0 var(--space-2);
font-family: var(--font-mono);
font-size: 12px;
}
/* Native color input, sized to a swatch and stripped of its chrome. */
.colorInput {
width: 32px;
height: 32px;
padding: 0;
border: var(--border-width) solid var(--border-strong);
background: none;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
}
.colorInput::-webkit-color-swatch-wrapper {
padding: 2px;
}
.colorInput::-webkit-color-swatch {
border: none;
}
.colorInput::-moz-color-swatch {
border: none;
}
.swatchRemove {
position: absolute;
top: -6px;
right: -6px;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
font-size: 12px;
line-height: 1;
color: var(--text);
background: var(--layer-02);
border: var(--border-width) solid var(--border-strong);
border-radius: 50%;
cursor: pointer;
opacity: 0;
transition: opacity var(--dur-fast) var(--ease);
}
.swatchUnit:hover .swatchRemove,
.swatchRemove:focus-visible {
opacity: 1;
}
.gradientPreview {
width: 96px;
height: 24px;
flex: 0 0 auto;
border: var(--border-width) solid var(--border-strong);
}
/* Dropdown-option previews: a swatch strip (categorical) / gradient bar. */
.optStrip {
display: inline-flex;
width: 84px;
}
.optSwatch {
flex: 1 1 0;
height: 14px;
min-width: 4px;
}
.optGradient {
display: inline-block;
width: 84px;
height: 14px;
border: var(--border-width) solid var(--border);
}
/* Mini current-value preview inside a SelectControl trigger. */
.triggerPreview {
display: inline-flex;
margin-right: var(--space-2);
}
.triggerPreview .optSwatch {
width: 6px;
flex: 0 0 6px;
height: 14px;
}
+255
View File
@@ -0,0 +1,255 @@
/**
* Color panel behavioural wiring through the live modal. The pure config
* transforms are covered in core/theme-controls.test.ts; these confirm the
* controls read the draft config and write back through `mutateDraftConfig`
* (scheme pick, materialize, swatch add/remove, mark color, the JSON gate).
* 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` (block body so `act` returns void). */
const open = (config: JsonObject) =>
act(() => {
useCustomThemeStore.getState().createTheme('Brand', config);
});
const draftConfig = () => useCustomThemeStore.getState().draftConfig;
const range = () => (draftConfig()?.range ?? {}) as Record<string, unknown>;
/** Drive an input's value through the native setter so React's value tracker
* registers the change and fires onChange (a bare `input.value =` doesn't). */
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 }));
}
/** 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);
/** The trigger of a SelectControl by its `label`-derived aria-label prefix. */
const picker = (labelPrefix: string) =>
[...container.querySelectorAll('button')].find((b) =>
b.getAttribute('aria-label')?.startsWith(labelPrefix),
);
describe('ColorControls', () => {
test('materialize expands a named scheme into an editable array', () => {
render();
open({ range: { category: 'tableau10' } });
expect(range().category).toBe('tableau10');
act(() => button('Materialize to edit')!.click());
expect(Array.isArray(range().category)).toBe(true);
expect((range().category as string[]).length).toBe(10);
});
test('add and remove palette colors', () => {
render();
open({ range: { category: ['#111111', '#222222'] } });
act(() => button('Add color')!.click());
expect((range().category as string[]).length).toBe(3);
const remove = container.querySelector<HTMLButtonElement>(
'[aria-label="Remove Palette color 1"]',
)!;
act(() => remove.click());
expect(range().category).toEqual(['#222222', '#888888']);
});
test('removing the last swatch deletes range.category', () => {
render();
open({ range: { category: ['#111111'] } });
const remove = container.querySelector<HTMLButtonElement>(
'[aria-label="Remove Palette color 1"]',
)!;
act(() => remove.click());
expect('category' in range()).toBe(false);
});
test('setting a default mark color writes mark.color; clear removes it', () => {
render();
open({});
const input = container.querySelector<HTMLInputElement>(
'input[aria-label="Default mark color"]',
)!;
act(() => setNativeValue(input, '#ff0000'));
expect((draftConfig()?.mark as Record<string, unknown>).color).toBe('#ff0000');
act(() => button('Clear')!.click());
expect(draftConfig()?.mark).toBeUndefined();
});
test('typing in a swatch hex field updates that color', () => {
render();
open({ range: { category: ['#111111', '#222222'] } });
const hex = container.querySelector<HTMLInputElement>(
'input[aria-label="Palette color 1 hex value"]',
)!;
act(() => setNativeValue(hex, '#abcdef'));
expect((range().category as string[])[0]).toBe('#abcdef');
});
test('a sequential scheme materializes to editable stops written to heatmap and ramp', () => {
render();
// Seed the other families as arrays so the only "Materialize" button is the
// sequential one (categorical/diverging show "Add color"/"Add stop" instead).
open({
range: {
category: ['#111111'],
heatmap: 'viridis',
ramp: 'viridis',
diverging: ['#aa0000', '#0000aa'],
},
});
act(() => button('Materialize to edit')!.click());
expect(Array.isArray(range().heatmap)).toBe(true);
expect(range().ramp).toEqual(range().heatmap);
const remove = container.querySelector<HTMLButtonElement>(
'[aria-label="Remove Sequential stop 1"]',
)!;
const before = (range().heatmap as string[]).length;
act(() => remove.click());
expect((range().heatmap as string[]).length).toBe(before - 1);
expect(range().ramp).toEqual(range().heatmap);
});
test('scheme dropdown options carry a color preview', () => {
render();
open({});
act(() => picker('Categorical color scheme')!.click());
const tableau = button('Tableau 10')!;
// The decorative swatch strip renders as spans inside the option button.
expect(tableau.querySelectorAll('span span').length).toBeGreaterThan(0);
});
test('picking a scheme writes range.category as a string', () => {
render();
open({});
act(() => picker('Categorical color scheme')!.click());
act(() => button('Category 10')!.click());
expect(range().category).toBe('category10');
});
test('a sequential pick sets both heatmap and ramp', () => {
render();
open({});
act(() => picker('Sequential color scheme')!.click());
act(() => button('Viridis')!.click());
expect(range().heatmap).toBe('viridis');
expect(range().ramp).toBe('viridis');
});
test('invalid JSON disables the controls', () => {
render();
open({});
act(() => useCustomThemeStore.getState().updateDraft({ configText: '{oops' }));
expect(container.textContent).toContain('Fix the JSON below to use these controls');
expect(button('Materialize to edit')).toBeUndefined();
});
test('the Type tab exposes the font-apply control', () => {
render();
open({});
const typeTab = [...container.querySelectorAll('button')].find(
(b) => b.getAttribute('role') === 'tab' && b.textContent === 'Type',
)!;
act(() => typeTab.click());
expect(picker('Font family')).toBeTruthy();
});
test('font options render their name in their own family', () => {
render();
open({});
const typeTab = [...container.querySelectorAll('button')].find(
(b) => b.getAttribute('role') === 'tab' && b.textContent === 'Type',
)!;
act(() => typeTab.click());
act(() => picker('Font family')!.click());
const georgia = button('Georgia')!;
expect(georgia.querySelector<HTMLElement>('span')!.style.fontFamily).toContain('Georgia');
});
test('the raw JSON is collapsed by default and toggles open', () => {
render();
open({});
expect(container.querySelector('#theme-config')).toBeNull();
const toggle = container.querySelector<HTMLButtonElement>(
'button[aria-controls="theme-config"]',
)!;
expect(toggle.getAttribute('aria-expanded')).toBe('false');
act(() => toggle.click());
expect(container.querySelector('#theme-config')).toBeTruthy();
});
test('a parse error forces the JSON open', () => {
render();
open({});
expect(container.querySelector('#theme-config')).toBeNull();
act(() => useCustomThemeStore.getState().updateDraft({ configText: '{oops' }));
expect(container.querySelector('#theme-config')).toBeTruthy();
expect(
container
.querySelector('button[aria-controls="theme-config"]')!
.getAttribute('aria-expanded'),
).toBe('true');
});
});
+419
View File
@@ -0,0 +1,419 @@
/**
* Theme Builder Color panel (docs/chart-theming-scope.md §5).
*
* Structured controls over the draft config's color surface: the categorical
* palette (`range.category`), the default single-series mark color
* (`mark.color`), and the sequential/diverging gradients (`range.heatmap`+`ramp`
* and `range.diverging`). Each writes through `mutateDraftConfig` so the JSON
* 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.
* Each swatch pairs the native color picker with a hex text field, so a value
* can be read, copied, and retyped anywhere.
*/
import { useState, 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 { Button } from './Button';
import { SelectControl, type SelectControlOption } from './SelectControl';
import styles from './ColorControls.module.css';
const CATEGORY: ConfigPath = ['range', 'category'];
const MARK_COLOR: ConfigPath = ['mark', 'color'];
const HEATMAP: ConfigPath = ['range', 'heatmap'];
const RAMP: ConfigPath = ['range', 'ramp'];
const DIVERGING: ConfigPath = ['range', 'diverging'];
/** Vega-Lite's default mark color — shown as the starting value when unset. */
const VEGA_DEFAULT_MARK = '#4c78a8';
/** Seeded into a new swatch and the materialize-from-default paths. */
const NEW_SWATCH = '#888888';
const DEFAULT_CATEGORICAL = 'tableau10';
const DEFAULT_SEQUENTIAL = 'viridis';
const DEFAULT_DIVERGING = 'redblue';
/** Stops a materialized gradient starts with (continuous schemes are sampled). */
const GRADIENT_STOPS = 7;
const isHex = (c: string): boolean => /^#[0-9a-f]{6}$/i.test(c);
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);
/** `#rrggbb` (lowercased) from loose input, or null if not six hex digits. */
function normalizeHex(raw: string): string | null {
const v = raw.trim().replace(/^#/, '');
return /^[0-9a-f]{6}$/i.test(v) ? `#${v.toLowerCase()}` : null;
}
const gradientCss = (colors: string[]): string =>
colors.length ? `linear-gradient(90deg, ${colors.join(', ')})` : 'transparent';
// Dropdown previews, built once: a swatch strip for categorical, a gradient bar
// for the continuous families.
const swatchStrip = (colors: string[]): ReactNode => (
<span className={styles.optStrip}>
{colors.slice(0, 12).map((c, i) => (
<span key={i} className={styles.optSwatch} style={{ background: c }} />
))}
</span>
);
const gradientBar = (colors: string[]): ReactNode => (
<span className={styles.optGradient} style={{ background: gradientCss(colors) }} />
);
const CATEGORICAL_OPTIONS: SelectControlOption<string>[] = schemesByKind('categorical').map(
(s) => ({
value: s.name,
label: s.label,
preview: swatchStrip(schemeColors(s.name)),
}),
);
const SEQUENTIAL_OPTIONS: SelectControlOption<string>[] = schemesByKind('sequential').map((s) => ({
value: s.name,
label: s.label,
preview: gradientBar(schemeColors(s.name, 12)),
}));
const DIVERGING_OPTIONS: SelectControlOption<string>[] = schemesByKind('diverging').map((s) => ({
value: s.name,
label: s.label,
preview: gradientBar(schemeColors(s.name, 12)),
}));
/** A color picker paired with a copyable, editable hex field; optional remove. */
function SwatchRow({
color,
label,
onChange,
onRemove,
}: {
color: string;
label: string;
onChange: (hex: string) => void;
onRemove?: () => void;
}) {
// Local text so a partially-typed hex isn't rejected mid-keystroke; commits on
// a valid value, and reverts to the committed color on blur. Resync to the
// committed color when it changes from the outside (color picker, materialize,
// scheme) — but not from this field's own commit, so an in-progress edit isn't
// clobbered. The render-time adjustment is React's documented alternative to a
// sync effect.
const [text, setText] = useState(color);
const [synced, setSynced] = useState(color);
if (color !== synced) {
setSynced(color);
if (normalizeHex(text) !== color) setText(color);
}
return (
<span className={styles.swatchUnit}>
<input
type="color"
className={styles.colorInput}
aria-label={label}
value={isHex(color) ? color : '#000000'}
onChange={(e) => onChange(e.target.value)}
/>
<input
type="text"
className={styles.hexInput}
aria-label={`${label} hex value`}
spellCheck={false}
value={text}
onChange={(e) => {
setText(e.target.value);
const norm = normalizeHex(e.target.value);
if (norm) onChange(norm);
}}
onBlur={() => setText(color)}
/>
{onRemove && (
// TODO: icon-only control as a raw <button> with a literal "×" rather
// than IconButton + <Icon name="close"/> (the controlled glyph vocabulary
// arch 09 §5 uses everywhere else). The corner badge is 16px — below
// IconButton's 24px sm and tighter than a 16px Icon fits — so neither
// primitive drops in unchanged. Reconcile the badge size with the
// primitive in the batched control-consistency pass.
<button
type="button"
className={styles.swatchRemove}
aria-label={`Remove ${label}`}
title="Remove"
onClick={onRemove}
>
×
</button>
)}
</span>
);
}
export function ColorControls({ config }: { config: JsonObject }) {
const mutate = useCustomThemeStore((s) => s.mutateDraftConfig);
const set = (path: ConfigPath, value: unknown) => mutate((c) => setConfigValue(c, path, value));
// 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 catValue = getConfigValue(config, CATEGORY);
const catArray = asArray(catValue);
const catScheme = asString(catValue);
const markColor = asString(getConfigValue(config, MARK_COLOR));
const seqValue = getConfigValue(config, HEATMAP);
const seqArray = asArray(seqValue);
const seqScheme = asString(seqValue);
const divValue = getConfigValue(config, DIVERGING);
const divArray = asArray(divValue);
const divScheme = asString(divValue);
/** Stops to drive a gradient preview for a family in any of its states. */
const previewStops = (array: string[] | null, scheme: string | null): string[] =>
array ?? (scheme ? schemeColors(scheme, 9) : []);
return (
<div className={styles.panel}>
{/* ── Categorical palette ─────────────────────────────────────────── */}
<section className={styles.group}>
<h4 className={styles.groupTitle}>Categorical palette</h4>
<p className={styles.hint}>Series colors assigned to discrete categories in order.</p>
<div className={styles.row}>
<SelectControl
id="color-categorical-scheme"
label="Categorical color scheme"
heading="Color scheme"
options={CATEGORICAL_OPTIONS}
value={catScheme ?? undefined}
onSelect={(name) => set(CATEGORY, name)}
triggerContent={
<>
<span className={styles.triggerPreview} aria-hidden="true">
{(catArray ?? (catScheme ? schemeColors(catScheme) : []))
.slice(0, 6)
.map((c, i) => (
<span key={i} className={styles.optSwatch} style={{ background: c }} />
))}
</span>
<span>
{catArray ? `Custom (${catArray.length})` : (catScheme ?? 'Theme default')}
</span>
<span className={styles.caret} aria-hidden="true">
</span>
</>
}
triggerTitle="Pick a named palette"
/>
{catArray ? (
<Button variant="ghost" onClick={() => set(CATEGORY, [...catArray, NEW_SWATCH])}>
Add color
</Button>
) : (
<Button
variant="ghost"
onClick={() => set(CATEGORY, schemeColors(catScheme ?? DEFAULT_CATEGORICAL))}
>
Materialize to edit
</Button>
)}
</div>
{catArray && (
<div className={styles.swatches}>
{catArray.map((color, i) => (
<SwatchRow
key={i}
color={color}
label={`Palette color ${i + 1}`}
onChange={(hex) =>
set(
CATEGORY,
catArray.map((c, j) => (j === i ? hex : c)),
)
}
onRemove={() =>
set(
CATEGORY,
catArray.length === 1 ? undefined : catArray.filter((_, j) => j !== i),
)
}
/>
))}
</div>
)}
</section>
{/* ── Default mark color ──────────────────────────────────────────── */}
<section className={styles.group}>
<h4 className={styles.groupTitle}>Default mark color</h4>
<p className={styles.hint}>
Single-series fill bars, points, and lines with no color encoding.
</p>
<div className={styles.row}>
<SwatchRow
color={markColor && isHex(markColor) ? markColor : VEGA_DEFAULT_MARK}
label="Default mark color"
onChange={(hex) => set(MARK_COLOR, hex)}
/>
{markColor ? (
<Button variant="ghost" onClick={() => set(MARK_COLOR, undefined)}>
Clear
</Button>
) : (
<span className={styles.hint}>Unset Vega default ({VEGA_DEFAULT_MARK})</span>
)}
</div>
</section>
{/* ── Sequential gradient ─────────────────────────────────────────── */}
<section className={styles.group}>
<h4 className={styles.groupTitle}>Sequential gradient</h4>
<p className={styles.hint}>Continuous color heatmaps and quantitative legends.</p>
<div className={styles.row}>
<span
className={styles.gradientPreview}
aria-hidden="true"
style={{ background: gradientCss(previewStops(seqArray, seqScheme)) }}
/>
<SelectControl
id="color-sequential-scheme"
label="Sequential color scheme"
heading="Sequential scheme"
options={SEQUENTIAL_OPTIONS}
value={seqScheme ?? undefined}
onSelect={(name) => setSeq(name)}
triggerContent={
<>
<span>
{seqArray ? `Custom (${seqArray.length})` : (seqScheme ?? 'Theme default')}
</span>
<span className={styles.caret} aria-hidden="true">
</span>
</>
}
/>
{seqArray ? (
<Button variant="ghost" onClick={() => setSeq([...seqArray, NEW_SWATCH])}>
Add stop
</Button>
) : (
<Button
variant="ghost"
onClick={() => setSeq(schemeColors(seqScheme ?? DEFAULT_SEQUENTIAL, GRADIENT_STOPS))}
>
Materialize to edit
</Button>
)}
{(seqArray || seqScheme) && (
<Button variant="ghost" onClick={() => setSeq(undefined)}>
Clear
</Button>
)}
</div>
{seqArray && (
<div className={styles.swatches}>
{seqArray.map((color, i) => (
<SwatchRow
key={i}
color={color}
label={`Sequential stop ${i + 1}`}
onChange={(hex) => setSeq(seqArray.map((c, j) => (j === i ? hex : c)))}
onRemove={() =>
setSeq(seqArray.length === 1 ? undefined : seqArray.filter((_, j) => j !== i))
}
/>
))}
</div>
)}
</section>
{/* ── Diverging gradient ──────────────────────────────────────────── */}
<section className={styles.group}>
<h4 className={styles.groupTitle}>Diverging gradient</h4>
<p className={styles.hint}>Two-ended color values around a meaningful midpoint.</p>
<div className={styles.row}>
<span
className={styles.gradientPreview}
aria-hidden="true"
style={{ background: gradientCss(previewStops(divArray, divScheme)) }}
/>
<SelectControl
id="color-diverging-scheme"
label="Diverging color scheme"
heading="Diverging scheme"
options={DIVERGING_OPTIONS}
value={divScheme ?? undefined}
onSelect={(name) => set(DIVERGING, name)}
triggerContent={
<>
<span>
{divArray ? `Custom (${divArray.length})` : (divScheme ?? 'Theme default')}
</span>
<span className={styles.caret} aria-hidden="true">
</span>
</>
}
/>
{divArray ? (
<Button variant="ghost" onClick={() => set(DIVERGING, [...divArray, NEW_SWATCH])}>
Add stop
</Button>
) : (
<Button
variant="ghost"
onClick={() =>
set(DIVERGING, schemeColors(divScheme ?? DEFAULT_DIVERGING, GRADIENT_STOPS))
}
>
Materialize to edit
</Button>
)}
{(divArray || divScheme) && (
<Button variant="ghost" onClick={() => set(DIVERGING, undefined)}>
Clear
</Button>
)}
</div>
{divArray && (
<div className={styles.swatches}>
{divArray.map((color, i) => (
<SwatchRow
key={i}
color={color}
label={`Diverging stop ${i + 1}`}
onChange={(hex) =>
set(
DIVERGING,
divArray.map((c, j) => (j === i ? hex : c)),
)
}
onRemove={() =>
set(
DIVERGING,
divArray.length === 1 ? undefined : divArray.filter((_, j) => j !== i),
)
}
/>
))}
</div>
)}
</section>
</div>
);
}
@@ -127,9 +127,17 @@
.optionLabel { .optionLabel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1 1 auto;
min-width: 0; min-width: 0;
} }
/* Decorative leading visual (e.g. a color-scheme swatch strip / gradient bar). */
.optionPreview {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
}
.detail { .detail {
font-size: 11px; font-size: 11px;
font-weight: 400; font-weight: 400;
+17 -2
View File
@@ -22,7 +22,7 @@
* caller intercept the click entirely (the armed-channel fast path). * caller intercept the click entirely (the armed-channel fast path).
*/ */
import { Fragment, type ReactNode } from 'react'; import { Fragment, type CSSProperties, type ReactNode } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { usePopover } from '../hooks/usePopover'; import { usePopover } from '../hooks/usePopover';
import styles from './SelectControl.module.css'; import styles from './SelectControl.module.css';
@@ -47,6 +47,16 @@ export interface SelectControlOption<V extends string> {
* activating discloses further UI, matching the "…" label convention. * activating discloses further UI, matching the "…" label convention.
*/ */
hasPopup?: 'dialog'; hasPopup?: 'dialog';
/**
* Optional visual leading the option label e.g. a color-scheme swatch strip
* or gradient bar. Decorative (`aria-hidden`); the label carries the meaning.
*/
preview?: ReactNode;
/**
* Style applied to the label text e.g. render a font option's name in its
* own family, so the option previews the value (the type analogue of a swatch).
*/
labelStyle?: CSSProperties;
} }
export interface SelectControlProps<V extends string> { export interface SelectControlProps<V extends string> {
@@ -171,7 +181,12 @@ export function SelectControl<V extends string>({
aria-haspopup={o.hasPopup} aria-haspopup={o.hasPopup}
onClick={() => choose(o.value)} onClick={() => choose(o.value)}
> >
<span className={styles.optionLabel}> {o.preview !== undefined && (
<span className={styles.optionPreview} aria-hidden="true">
{o.preview}
</span>
)}
<span className={styles.optionLabel} style={o.labelStyle}>
{o.label} {o.label}
{o.detail !== undefined && ( {o.detail !== undefined && (
<span className={styles.detail}>{o.detail}</span> <span className={styles.detail}>{o.detail}</span>
+123 -9
View File
@@ -141,31 +141,145 @@
border-bottom: var(--border-width) solid var(--border); border-bottom: var(--border-width) solid var(--border);
} }
/* ── Editor + gallery split ────────────────────────────────────────────── */ /* ── Structured-control tabs ───────────────────────────────────────────── */
.work { .tabs {
flex: 0 0 auto;
display: flex;
gap: 0;
padding: 0 var(--space-5);
border-bottom: var(--border-width) solid var(--border);
}
.tab {
appearance: none;
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
padding: var(--space-3) var(--space-4);
font: inherit;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: color var(--dur-fast) var(--ease);
}
.tab:hover {
color: var(--text);
}
.tabActive {
color: var(--text);
border-bottom-color: var(--accent);
}
/* The structured controls are the first-screen surface: they fill the column
and scroll, with the collapsible JSON section docked below. */
.tabpanel {
flex: 1 1 auto;
overflow: auto;
min-height: 0;
border-bottom: var(--border-width) solid var(--border);
}
.controlsDisabled {
margin: 0;
padding: var(--space-5);
font-size: 13px;
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
tabs, the active panel, and the JSON editor stacked sits on the left,
capped so the previews get the room. */
.body {
flex: 1 1 auto; flex: 1 1 auto;
display: grid; display: grid;
grid-template-columns: minmax(280px, 400px) 1fr; grid-template-columns: clamp(420px, 38%, 600px) 1fr;
grid-template-rows: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr);
min-height: 0; min-height: 0;
min-width: 0; min-width: 0;
} }
.controlsCol {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
border-right: var(--border-width) solid var(--border);
}
/* Collapsible raw-JSON disclosure, docked at the bottom of the controls column. */
.jsonSection {
flex: 0 0 auto;
display: flex;
flex-direction: column;
border-top: var(--border-width) solid var(--border);
}
.jsonToggle {
appearance: none;
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-5);
background: none;
border: none;
font: inherit;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
text-align: left;
}
.jsonToggle:hover {
color: var(--text);
}
.jsonCaret {
font-size: 10px;
}
.editorPane { .editorPane {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-2); padding: 0 var(--space-5) var(--space-4);
padding: var(--space-4) var(--space-5);
border-right: var(--border-width) solid var(--border);
min-height: 0;
min-width: 0; min-width: 0;
} }
.configText { .configText {
flex: 1 1 auto;
width: 100%; width: 100%;
min-height: 0; height: clamp(180px, 34vh, 340px);
padding: var(--space-3); padding: var(--space-3);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 12px; font-size: 12px;
+137 -16
View File
@@ -11,7 +11,7 @@
* is exactly what selecting the theme will do. * is exactly what selecting the theme will do.
*/ */
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useState } from 'react';
import { THEME_FONT_OPTIONS } from '@core/custom-theme'; import { THEME_FONT_OPTIONS } from '@core/custom-theme';
import type { JsonObject } from '@core/spec-config'; import type { JsonObject } from '@core/spec-config';
import { THEME_PREVIEW_SPECS, type ThemePreviewSpec } from '@core/theme-preview-specs'; import { THEME_PREVIEW_SPECS, type ThemePreviewSpec } from '@core/theme-preview-specs';
@@ -27,9 +27,28 @@ import {
import { notify } from '../stores/NotificationStore'; import { notify } from '../stores/NotificationStore';
import { resnapshot } from '../modals/ModalCoordinator'; import { resnapshot } from '../modals/ModalCoordinator';
import { Button } from './Button'; import { Button } from './Button';
import { SelectControl } from './SelectControl'; import { ColorControls } from './ColorControls';
import { SelectControl, type SelectControlOption } from './SelectControl';
import styles from './ThemeBuilderModal.module.css'; 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' },
] 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). */ /** Debounce for gallery re-renders while the config text is edited (ms). */
const GALLERY_DEBOUNCE = 250; const GALLERY_DEBOUNCE = 250;
@@ -108,6 +127,35 @@ export function ThemeBuilderModal() {
const saveError = useCustomThemeStore((s) => s.saveError); const saveError = useCustomThemeStore((s) => s.saveError);
const dirty = useCustomThemeStore(selectIsDraftDirty); const dirty = useCustomThemeStore(selectIsDraftDirty);
const selectedTheme = useCustomThemeStore(selectSelectedTheme); const selectedTheme = useCustomThemeStore(selectSelectedTheme);
const [activeTab, setActiveTab] = useState<ThemeTab>('color');
// The raw JSON is the advanced/escape-hatch surface, collapsed off the first
// screen so the structured controls own it; a parse error forces it open
// (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
// inside a panel manage their own focus.
const onTabKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const ids = THEME_TABS.map((t) => t.id);
const i = ids.indexOf(activeTab);
let next = -1;
if (e.key === 'ArrowRight') next = (i + 1) % ids.length;
else if (e.key === 'ArrowLeft') next = (i - 1 + ids.length) % ids.length;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = ids.length - 1;
if (next < 0) return;
e.preventDefault();
setActiveTab(ids[next]);
(e.currentTarget.children[next] as HTMLElement | undefined)?.focus();
};
const handleNew = () => { const handleNew = () => {
const app = useAppStore.getState(); const app = useAppStore.getState();
@@ -199,15 +247,6 @@ export function ThemeBuilderModal() {
} }
/> />
</div> </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}> <div className={styles.toolbarEnd}>
<Button variant="danger-outline" onClick={() => void handleDelete()}> <Button variant="danger-outline" onClick={() => void handleDelete()}>
Delete Delete
@@ -222,20 +261,99 @@ export function ThemeBuilderModal() {
</div> </div>
</div> </div>
<div className={styles.body}>
<div className={styles.controlsCol}>
<div
className={styles.tabs}
role="tablist"
aria-label="Theme controls"
onKeyDown={onTabKeyDown}
>
{THEME_TABS.map((t) => (
<button
key={t.id}
type="button"
role="tab"
id={`theme-tab-${t.id}`}
aria-selected={activeTab === t.id}
aria-controls={`theme-tabpanel-${t.id}`}
tabIndex={activeTab === t.id ? 0 : -1}
className={`${styles.tab} ${activeTab === t.id ? styles.tabActive : ''}`}
onClick={() => setActiveTab(t.id)}
>
{t.label}
</button>
))}
</div>
{(saveError ?? parseError) !== null && ( {(saveError ?? parseError) !== null && (
<p className={styles.errorMessage} role="alert"> <p className={styles.errorMessage} role="alert">
{saveError ?? parseError} {saveError ?? parseError}
</p> </p>
)} )}
<div className={styles.work}> <div
<div className={styles.editorPane}> className={styles.tabpanel}
<label className={styles.label} htmlFor="theme-config"> role="tabpanel"
id={`theme-tabpanel-${activeTab}`}
aria-labelledby={`theme-tab-${activeTab}`}
>
{parseError !== null ? (
<p className={styles.controlsDisabled}>
Fix the JSON below to use these controls.
</p>
) : activeTab === 'color' && draftConfig !== null ? (
<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}
</div>
{/* Raw JSON collapsed by default (the structured controls are the
first-screen surface); forced open while a parse error is the
only thing that needs the textarea. */}
<div className={styles.jsonSection}>
<button
type="button"
className={styles.jsonToggle}
aria-expanded={jsonOpen}
aria-controls="theme-config"
onClick={() => setJsonExpanded((o) => !o)}
>
<span className={styles.jsonCaret} aria-hidden="true">
{jsonOpen ? '▾' : '▸'}
</span>
Config (Vega-Lite JSON) Config (Vega-Lite JSON)
</label> </button>
{jsonOpen && (
<div className={styles.editorPane}>
{/* A plain textarea, deliberately not Monaco: a second Monaco mount {/* A plain textarea, deliberately not Monaco: a second Monaco mount
is heavy inside a modal for an occasional surface, and the parse is heavy inside a modal for an occasional surface, and the parse
error below is the feedback channel that matters here. Revisit error above is the feedback channel that matters here. Revisit
only if real usage asks for config completions. */} only if real usage asks for config completions. */}
<textarea <textarea
id="theme-config" id="theme-config"
@@ -247,6 +365,9 @@ export function ThemeBuilderModal() {
} }
/> />
</div> </div>
)}
</div>
</div>
<div className={styles.gallery} aria-label="Theme preview gallery"> <div className={styles.gallery} aria-label="Theme preview gallery">
{draftConfig !== null && {draftConfig !== null &&
THEME_PREVIEW_SPECS.map((card) => ( THEME_PREVIEW_SPECS.map((card) => (
+12 -2
View File
@@ -64,6 +64,13 @@ export interface CustomThemeState {
* the current text is invalid JSON fix the JSON first. * the current text is invalid JSON fix the JSON first.
*/ */
applyDraftFont: (family: string) => void; 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 * Validate and commit the draft to the selected theme. On failure sets
* `saveError`/`parseError` and returns false. * `saveError`/`parseError` and returns false.
@@ -166,7 +173,10 @@ export const useCustomThemeStore = create<CustomThemeState>((set, get) => ({
set({ draft: next, saveError: null }); set({ draft: next, saveError: null });
}, },
applyDraftFont: (family) => { applyDraftFont: (family) =>
get().mutateDraftConfig((config) => applyFontToConfig(config, family)),
mutateDraftConfig: (fn) => {
const draft = get().draft; const draft = get().draft;
if (!draft) return; if (!draft) return;
const parsed = parseConfigText(draft.configText); const parsed = parseConfigText(draft.configText);
@@ -174,7 +184,7 @@ export const useCustomThemeStore = create<CustomThemeState>((set, get) => ({
set({ parseError: parsed.error }); set({ parseError: parsed.error });
return; return;
} }
const config = applyFontToConfig(parsed.config, family); const config = fn(parsed.config);
set({ set({
draft: { ...draft, configText: configToText(config) }, draft: { ...draft, configText: configToText(config) },
draftConfig: config, draftConfig: config,
+16
View File
@@ -88,4 +88,20 @@ describe('THEME_PREVIEW_SPECS', () => {
const ids = THEME_PREVIEW_SPECS.map((c) => c.id); const ids = THEME_PREVIEW_SPECS.map((c) => c.id);
expect(new Set(ids).size).toBe(ids.length); 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);
});
}); });
+123
View File
@@ -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([]);
});
});
+186
View File
@@ -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<ThemeScheme> = [
// 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])}`;
}
+42 -4
View File
@@ -4,13 +4,16 @@
* A fixed set of small, self-contained Vega-Lite specs the Theme Builder * 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 * 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, * every chart surface a config styles: titles and subtitles, axes and grids,
* categorical and gradient legends, facet headers, and the major mark types. * facet headers, the major mark types, and so every color control has a
* Inline data only, compact fixed sizes these are swatches, not analyses. * 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'; 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 { export interface ThemePreviewSpec {
/** Stable key for React lists and test assertions. */ /** 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 = { const donut: ThemePreviewSpec = {
id: 'donut', id: 'donut',
caption: 'Donut — palette, symbol legend', 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<ThemePreviewSpec> = [ export const THEME_PREVIEW_SPECS: ReadonlyArray<ThemePreviewSpec> = [
bar, bar,
line, line,
area, area,
scatter, scatter,
heatmap, heatmap,
diverging,
donut, donut,
facet, facet,
]; ];
+8
View File
@@ -21,3 +21,11 @@ declare module 'vega-lite/vega-lite-schema.json' {
declare module 'monaco-editor/esm/vs/editor/edcore.main' { declare module 'monaco-editor/esm/vs/editor/edcore.main' {
export * from 'monaco-editor/esm/vs/editor/editor.api'; 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;
}