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;
+155 -34
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,30 +261,112 @@ export function ThemeBuilderModal() {
</div> </div>
</div> </div>
{(saveError ?? parseError) !== null && ( <div className={styles.body}>
<p className={styles.errorMessage} role="alert"> <div className={styles.controlsCol}>
{saveError ?? parseError} <div
</p> 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>
<div className={styles.work}> {(saveError ?? parseError) !== null && (
<div className={styles.editorPane}> <p className={styles.errorMessage} role="alert">
<label className={styles.label} htmlFor="theme-config"> {saveError ?? parseError}
Config (Vega-Lite JSON) </p>
</label> )}
{/* A plain textarea, deliberately not Monaco: a second Monaco mount
is heavy inside a modal for an occasional surface, and the parse <div
error below is the feedback channel that matters here. Revisit className={styles.tabpanel}
only if real usage asks for config completions. */} role="tabpanel"
<textarea id={`theme-tabpanel-${activeTab}`}
id="theme-config" aria-labelledby={`theme-tab-${activeTab}`}
className={styles.configText} >
spellCheck={false} {parseError !== null ? (
value={draft.configText} <p className={styles.controlsDisabled}>
onChange={(e) => Fix the JSON below to use these controls.
useCustomThemeStore.getState().updateDraft({ configText: e.target.value }) </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)
</button>
{jsonOpen && (
<div className={styles.editorPane}>
{/* A plain textarea, deliberately not Monaco: a second Monaco mount
is heavy inside a modal for an occasional surface, and the parse
error above is the feedback channel that matters here. Revisit
only if real usage asks for config completions. */}
<textarea
id="theme-config"
className={styles.configText}
spellCheck={false}
value={draft.configText}
onChange={(e) =>
useCustomThemeStore.getState().updateDraft({ configText: e.target.value })
}
/>
</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 &&
+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;
}