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

This commit is contained in:
2026-06-14 13:08:29 +03:00
parent b03c3b08ab
commit 91b8b1e7fe
19 changed files with 1381 additions and 135 deletions
@@ -139,6 +139,17 @@ async function rerender(node: HTMLElement, spec: TopLevelSpec, config: Config) {
**render-size** limit (the chart is physically too big), distinct from the readability
cardinality warnings — don't conflate them. Only an _unbounded_ axis overflows: a
`width: 'container'` axis is bounded, so it's the deleted (natural-height) axis to watch.
- **Load fonts before rendering.** Vega measures every text label via canvas
`measureText` **regardless of renderer** (even the `'none'` probe runs layout),
so a face that finishes loading after embed lays the whole chart out with
fallback metrics. `renderSpec` therefore gates on `document.fonts.load` for the
families a spec+config reference (`collectFontFamilies`, core) before any layout
pass. This is a non-critical enhancement, so it waits on `allSettled` + a
timeout: a face failing (offline, 404, a system family with no `@font-face`)
degrades to fallback metrics rather than failing the chart (the sanctioned
swallow under §7's fail-loud rule). Chart fonts are self-hosted in
`styles/chart-fonts.css` (offered by the Theme Builder's font control); only
their latin subsets are precached, the rest runtime-cached (vite.config Workbox).
- **Do** call `view.finalize()` on every previous view before rendering a new
one, and on component unmount.
- **Do** keep exactly one live view per preview node.
@@ -218,7 +229,9 @@ The gallery (`core/theme-preview-specs.ts`, fixed inline-data swatch specs)
renders `draftConfig` per card through the shared `renderSpec` with the
**canvas** renderer and a per-card debounce + chain-lock (the LivePreview
serialization pattern, one lock per card) — so invalid JSON mid-edit never
blanks the preview, and seven concurrent embeds never interleave on a node.
blanks the preview, and seven concurrent embeds never interleave on a node. A
card whose render throws shows the error message in place of the chart (the same
fail-loud treatment as LivePreview, §7), never a silent blank.
`applyFontToConfig(config, family)` is the font control's transform: it sets
the top-level `font` and rewrites every `font`/`*Font` string slot at any
depth — explicit slots would otherwise keep overriding the new default.
@@ -238,25 +251,42 @@ that boundary deliberately: merge bakes the selected theme into `spec.config`
### 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`).
The builder's panels Color, Type, Layout, Axes & grid, Legend
(`ColorControls` + `TypeControls`/`LayoutControls`/`AxesControls`/`LegendControls`
on the shared `ThemeFields` field primitives) — 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, leaf coercion, 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 as Vega's range-scheme **object**
`{ scheme: name }` **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.
Repeated control labels across panels ("Size", "Color", "Weight") get a
qualified accessible name while keeping the short visible label; the section is a
`role="group"` labelled by its heading (APG group pattern), so the name a screen
reader announces is unambiguous.
- **Do** bind a panel's writes to the shared `useConfigSetter()` hook (in
`CustomThemeStore` — beside `mutateDraftConfig`, not the JSX field module, which
stays component-only for fast refresh). It is `mutateDraftConfig` + `setConfigValue`:
sets a value at a path **immutably, preserving sibling keys**, and deletes
(pruning emptied ancestors) on `undefined` so a theme stays a diff. A new panel
uses it rather than re-inlining the pair.
- **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** write a named scheme into `range.*` as the object `{ scheme: name }`. A
bare scheme-name string passes vega-lite _compile_ but Vega rejects it at
_render_ ("Unrecognized scale range value"), blanking the chart.
`normalizeRangeSchemes` (core) heals the bare form at the render-resolution
points (`chartConfigForSelection`; the builder gallery) for configs authored or
saved before this was enforced.
- **Do** add a `theme-preview-specs.ts` gallery card for any new color family,
so no control ships without a visible mirror.
+71 -9
View File
@@ -154,9 +154,14 @@ ships, it is an explicit per-font user action, never automatic.
active one falls back to Astrolabe. Custom themes travel in the §08 workspace
export/import envelope (additive `themes` array, name auto-suffix on clash, ids
reassigned by the store, rolled back with datasets on a failed import).
5. **Shipped font roster** — fontsource packages, `@font-face` registration, selector
metadata (which themes/fonts pair), `document.fonts.load` gate in the render path,
precache strategy above. Roster finalized via visual specimen.
5. **Shipped font roster** ✅ (2026-06-14) — 11 self-hosted families via @fontsource
(`styles/chart-fonts.css`, full subsets bundled) extending `THEME_FONT_OPTIONS` to 17
entries; `collectFontFamilies` (core) + a `document.fonts.load` gate at the top of
`renderSpec` (before the layout/probe pass, which measures text regardless of
renderer); Workbox precaches the `latin` subset of the roster (~520KB) plus every
subset of the UI Plex Sans/Mono, and runtime-caches the rest (latin-ext + non-latin)
CacheFirst so a script works offline after first use. Roster picked from a visual
specimen. Not done here: theme↔font pairing metadata (a suggestion nicety, deferred).
6. **User font upload** — FontFace-from-IndexedDB tier; theme entity's `fonts` field
carries `{ family, source: 'file' }`.
7. **Deferred** — Google Fonts opt-in tier; SVG export font embedding; built-in
@@ -188,7 +193,10 @@ schema-typed model, or it silently drops those keys on a round-trip. Same shape
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).
either an explicit color array or a named scheme written as Vega's range-scheme object
`{ scheme: name }`. (A bare scheme-name _string_ passes vega-lite compile but Vega
rejects it at render — "Unrecognized scale range value" — so the controls write the
object form, read either, and `normalizeRangeSchemes` heals the bare form at render.)
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
@@ -199,11 +207,11 @@ schema-typed model, or it silently drops those keys on a round-trip. Same shape
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).
`diverging`) · Type (base `font`, title + axis title/label size+weight) · Layout (`background`
incl. transparent, `padding`, `view.stroke`/`fill`/cornerRadius) · Axes & grid (grid on/off +
color + dash, domain, label color/angle, title color — base `axis` only; the 25 variants stay
JSON) · Legend (orient, title/label color+size, symbol size). Legend _type_ (size) lives in
the Legend panel rather than Type, so every legend property a brand tunes sits together.
**Build order:** (a) core foundation — scheme catalog + immutable config path get/set +
`schemeColors` materialize, with tests; (b) Color panel (highest payoff); (c) Type, Layout,
@@ -216,6 +224,60 @@ the app standardizes on v6.
## 6. Status log
- **2026-06-14 (Color panel bugfix)** — **scheme picks rendered blank.** A named scheme
was written into `config.range.*` as a bare string, which vega-lite compiles but Vega
rejects at render ("Unrecognized scale range value") — silently caught by the gallery's
per-card try/catch, so the categorical/sequential/diverging charts blanked the moment a
scheme was picked. Predates this session's panels/fonts (shipped with the Color panel).
Fix: the controls write Vega's range-scheme object `{ scheme: name }` and read either
form; `normalizeRangeSchemes` (core) heals a bare-form config at the render-resolution
points (`chartConfigForSelection` for the live preview/export, and the builder gallery),
so themes saved/imported with the old form self-heal. The gallery's catch now surfaces the
error message in the card (fail-loud, arch 02) so a render failure on valid JSON isn't
invisible again. Regression cover: a real
vega-lite→vega compile/parse/run asserting `{ scheme }` renders and the bare string
throws, plus `normalizeRangeSchemes` unit tests. Verified: typecheck, lint, tests (983).
- **2026-06-14 (slice 5)** — **shipped font roster.** 11 self-hosted families
(`styles/chart-fonts.css`, imported in main.tsx, separate from the UI Plex in base.css):
Inter · Libre Franklin · Roboto Condensed · IBM Plex Sans Condensed · IBM Plex Serif ·
Source Serif 4 · Spectral · Space Grotesk · Playfair Display · Caveat · Space Mono, at
400 + 600 (Space Mono 400 + 700). `THEME_FONT_OPTIONS` grew to 17 (roster grouped by
role, then the system stacks); each roster stack carries a category fallback. The render
path now gates on fonts: `collectFontFamilies` (core, the read-counterpart of
`applyFontToConfig`; skips `data`/`datasets`) gathers the families a spec+config use and
`renderSpec` awaits `document.fonts.load` for them before the first layout pass — Vega
measures text via canvas `measureText` regardless of renderer, so a face loading after
embed would lay out with fallback metrics. Best-effort + 3s-capped so a slow first fetch
never freezes the preview. Precache strategy (vite.config Workbox): the `latin` subset
of every family (~520KB for the roster) + all Plex Sans/Mono subsets (UI capability) are
precached; latin-ext and non-latin scripts are runtime-cached CacheFirst (`*-latin-[0-9]*`
excludes latin-ext; the Plex Sans brace-list avoids matching the condensed roster font).
Verified: typecheck, lint, full tests (976), production build + precache-manifest
inspection. Note: @fontsource ships legacy `.woff` beside `.woff2`; modern browsers use
woff2, so the `.woff` sit unused in dist (pre-existing for Plex — neither precached nor
runtime-cached).
- **2026-06-14 (slice 4b complete)** — **Layout / Axes & grid / Legend panels + Type
size/weight.** The remaining structured-control panels, built on a small shared
primitives module `ThemeFields.tsx` (`ControlSection`, `ColorRow`, `NumberRow`,
`SelectRow`) so the panels read declaratively and match the Color panel's look. Each
control writes one config path through the same inline `mutateDraftConfig` +
`setConfigValue` the Color panel uses, with the minimal-diff delete (clearing a value
removes the key, pruning emptied objects). Leaf coercion (`asString`/`asNumber`/
`asBoolean`) moved into core `theme-controls.ts` beside the path get/set, tested there.
Panels: **Layout** (background and `view` fill/border as tri-state default·transparent/
none·custom, corner radius, scalar padding with a JSON hint when it's a per-side object);
**Axes & grid** (grid visibility/color/dash-preset, domain/label/title color, label
angle — base `axis` only); **Legend** (orient, title/label color+size, symbol size);
**Type** rounded out with title and axis title/label size+weight (font family relocated
into the extracted `TypeControls`). Resolved while building: each generic row label
("Size", "Color", "Weight") repeats across sections, so `ControlSection` is a
`role="group"` labelled by its heading and rows take an accessible-name override — the
visible label stays short, the control's announced name is qualified ("Title size"). The
font-roster decision (slice 5) was teed up with a throwaway visual specimen. Verified:
typecheck, lint, full tests (969). Remaining in 4b: swatch reorder (Color panel).
- **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
+139
View File
@@ -0,0 +1,139 @@
/**
* Theme Builder — Axes & grid panel (docs/chart-theming-scope.md §5).
*
* Structured controls over the base `axis` config only — grid visibility, grid
* color and dash style, the domain line, label color and angle, and title
* color. The 25 per-channel variants (`axisX`, `axisY`, `axisBand`, …) stay in
* the JSON; this is the common surface a brand actually tunes. Axis *type*
* (label/title size and weight) lives in the Type panel.
*/
import type { JsonObject } from '@core/spec-config';
import {
asBoolean,
asNumber,
asString,
type ConfigPath,
getConfigValue,
} from '@core/theme-controls';
import { useConfigSetter } from '../stores/CustomThemeStore';
import { ColorRow, ControlSection, NumberRow, SelectRow } from './ThemeFields';
import type { SelectControlOption } from './SelectControl';
import styles from './ThemeFields.module.css';
const GRID: ConfigPath = ['axis', 'grid'];
const GRID_COLOR: ConfigPath = ['axis', 'gridColor'];
const GRID_DASH: ConfigPath = ['axis', 'gridDash'];
const DOMAIN_COLOR: ConfigPath = ['axis', 'domainColor'];
const LABEL_COLOR: ConfigPath = ['axis', 'labelColor'];
const LABEL_ANGLE: ConfigPath = ['axis', 'labelAngle'];
const TITLE_COLOR: ConfigPath = ['axis', 'titleColor'];
const GRID_GREY = '#888888';
// Grid visibility: tri-state (theme default · shown · hidden) over a boolean.
type GridState = '' | 'true' | 'false';
const gridOptions: SelectControlOption<GridState>[] = [
{ value: '', label: 'Theme default' },
{ value: 'true', label: 'Shown' },
{ value: 'false', label: 'Hidden' },
];
const gridState = (v: boolean | undefined): GridState =>
v === undefined ? '' : v ? 'true' : 'false';
// Dash presets, matched by array shape; an unrecognised array reads as no preset
// (the trigger shows "—") so the control never misreports a hand-authored dash.
type DashStyle = '' | 'solid' | 'dotted' | 'dashed' | 'custom';
const dashOptions: SelectControlOption<DashStyle>[] = [
{ value: '', label: 'Theme default' },
{ value: 'solid', label: 'Solid' },
{ value: 'dotted', label: 'Dotted' },
{ value: 'dashed', label: 'Dashed' },
];
const DASH_VALUES: Record<Exclude<DashStyle, '' | 'custom'>, number[]> = {
solid: [],
dotted: [2, 2],
dashed: [6, 3],
};
const dashStyle = (v: unknown): DashStyle => {
if (v === undefined) return '';
if (!Array.isArray(v)) return 'custom';
if (v.length === 0) return 'solid';
if (v.length === 2 && v[0] === 2 && v[1] === 2) return 'dotted';
if (v.length === 2 && v[0] === 6 && v[1] === 3) return 'dashed';
return 'custom';
};
export function AxesControls({ config }: { config: JsonObject }) {
const set = useConfigSetter();
const grid = asBoolean(getConfigValue(config, GRID));
const gridColor = asString(getConfigValue(config, GRID_COLOR));
const dash = dashStyle(getConfigValue(config, GRID_DASH));
const domainColor = asString(getConfigValue(config, DOMAIN_COLOR));
const labelColor = asString(getConfigValue(config, LABEL_COLOR));
const labelAngle = asNumber(getConfigValue(config, LABEL_ANGLE));
const titleColor = asString(getConfigValue(config, TITLE_COLOR));
return (
<div className={styles.panel}>
<ControlSection title="Grid" hint="Reference lines behind the marks.">
<SelectRow
id="axes-grid"
label="Grid lines"
options={gridOptions}
value={gridState(grid)}
onSelect={(s) => set(GRID, s === '' ? undefined : s === 'true')}
/>
<ColorRow
label="Grid color"
value={gridColor}
fallback={GRID_GREY}
onChange={(hex) => set(GRID_COLOR, hex)}
onClear={() => set(GRID_COLOR, undefined)}
/>
<SelectRow
id="axes-grid-dash"
label="Grid style"
options={dashOptions}
value={dash}
onSelect={(s) =>
set(GRID_DASH, s === '' ? undefined : DASH_VALUES[s as keyof typeof DASH_VALUES])
}
/>
</ControlSection>
<ControlSection title="Domain & labels" hint="The axis line, its tick labels, and title.">
<ColorRow
label="Domain line"
value={domainColor}
fallback={GRID_GREY}
onChange={(hex) => set(DOMAIN_COLOR, hex)}
onClear={() => set(DOMAIN_COLOR, undefined)}
/>
<ColorRow
label="Label color"
value={labelColor}
fallback={GRID_GREY}
onChange={(hex) => set(LABEL_COLOR, hex)}
onClear={() => set(LABEL_COLOR, undefined)}
/>
<NumberRow
label="Label angle"
value={labelAngle}
min={-90}
max={90}
unit="°"
onChange={(n) => set(LABEL_ANGLE, n)}
/>
<ColorRow
label="Title color"
value={titleColor}
fallback={GRID_GREY}
onChange={(hex) => set(TITLE_COLOR, hex)}
onClear={() => set(TITLE_COLOR, undefined)}
/>
</ControlSection>
</div>
);
}
+6 -5
View File
@@ -170,25 +170,26 @@ describe('ColorControls', () => {
expect(tableau.querySelectorAll('span span').length).toBeGreaterThan(0);
});
test('picking a scheme writes range.category as a string', () => {
test('picking a scheme writes range.category as a Vega scheme object', () => {
render();
open({});
act(() => picker('Categorical color scheme')!.click());
act(() => button('Category 10')!.click());
expect(range().category).toBe('category10');
// The `{ scheme }` object — a bare scheme-name string is rejected at render.
expect(range().category).toEqual({ scheme: 'category10' });
});
test('a sequential pick sets both heatmap and ramp', () => {
test('a sequential pick sets both heatmap and ramp to the scheme object', () => {
render();
open({});
act(() => picker('Sequential color scheme')!.click());
act(() => button('Viridis')!.click());
expect(range().heatmap).toBe('viridis');
expect(range().ramp).toBe('viridis');
expect(range().heatmap).toEqual({ scheme: 'viridis' });
expect(range().ramp).toEqual({ scheme: 'viridis' });
});
test('invalid JSON disables the controls', () => {
+35 -22
View File
@@ -8,29 +8,25 @@
* and the gallery follow; reads come from the parsed draft config, so a JSON
* hand-edit reflects straight back into the controls.
*
* Color model (§5): every family holds either a named Vega scheme (compact, the
* quick path) or an explicit color array (custom tuning). Picking a scheme from
* the preview-bearing dropdown writes the name; "Materialize" expands it to an
* editable array of swatches — categorical, sequential, and diverging alike.
* Color model (§5): every family holds either a named Vega scheme as the
* range-scheme object `{ scheme: name }` (compact, the quick path) or an explicit
* color array (custom tuning). Picking a scheme from the preview-bearing dropdown
* writes that object (a bare string is rejected by Vega at render); "Materialize"
* expands it to an editable array of swatches — categorical, sequential, and
* diverging alike.
* Each swatch pairs the native color picker with a hex text field, so a value
* can be read, copied, and retyped anywhere.
*/
import type { ReactNode } from 'react';
import type { JsonObject } from '@core/spec-config';
import {
type ConfigPath,
getConfigValue,
schemeColors,
schemesByKind,
setConfigValue,
} from '@core/theme-controls';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { type ConfigPath, getConfigValue, schemeColors, schemesByKind } from '@core/theme-controls';
import { Button } from './Button';
import { ColorField } from './ColorField';
import { Icon } from './Icon';
import { IconButton } from './IconButton';
import { SelectControl, type SelectControlOption } from './SelectControl';
import { useConfigSetter } from '../stores/CustomThemeStore';
import styles from './ColorControls.module.css';
const CATEGORY: ConfigPath = ['range', 'category'];
@@ -53,6 +49,22 @@ const asArray = (v: unknown): string[] | null =>
Array.isArray(v) && v.every((c) => typeof c === 'string') ? v : null;
const asString = (v: unknown): string | null => (typeof v === 'string' ? v : null);
/**
* A named scheme is stored in a `range` family as Vega's range-scheme object
* `{ scheme: name }`. A bare scheme-name string compiles but is rejected by Vega
* at render ("Unrecognized scale range value: …"), blanking the chart — so reads
* accept either the object or a legacy/hand-authored bare string, while writes
* (`schemeRange`) always use the object form.
*/
const asScheme = (v: unknown): string | null => {
if (typeof v === 'string') return v;
if (v && typeof v === 'object' && typeof (v as { scheme?: unknown }).scheme === 'string') {
return (v as { scheme: string }).scheme;
}
return null;
};
const schemeRange = (name: string): { scheme: string } => ({ scheme: name });
const gradientCss = (colors: string[]): string =>
colors.length ? `linear-gradient(90deg, ${colors.join(', ')})` : 'transparent';
@@ -117,25 +129,26 @@ function SwatchRow({
}
export function ColorControls({ config }: { config: JsonObject }) {
const mutate = useCustomThemeStore((s) => s.mutateDraftConfig);
const set = (path: ConfigPath, value: unknown) => mutate((c) => setConfigValue(c, path, value));
const set = useConfigSetter();
// Sequential color lives in two slots (heatmaps + continuous legends); keep them together.
const setSeq = (value: unknown) =>
mutate((c) => setConfigValue(setConfigValue(c, HEATMAP, value), RAMP, value));
const setSeq = (value: unknown) => {
set(HEATMAP, value);
set(RAMP, value);
};
const catValue = getConfigValue(config, CATEGORY);
const catArray = asArray(catValue);
const catScheme = asString(catValue);
const catScheme = asScheme(catValue);
const markColor = asString(getConfigValue(config, MARK_COLOR));
const seqValue = getConfigValue(config, HEATMAP);
const seqArray = asArray(seqValue);
const seqScheme = asString(seqValue);
const seqScheme = asScheme(seqValue);
const divValue = getConfigValue(config, DIVERGING);
const divArray = asArray(divValue);
const divScheme = asString(divValue);
const divScheme = asScheme(divValue);
/** Stops to drive a gradient preview for a family in any of its states. */
const previewStops = (array: string[] | null, scheme: string | null): string[] =>
@@ -155,7 +168,7 @@ export function ColorControls({ config }: { config: JsonObject }) {
heading="Color scheme"
options={CATEGORICAL_OPTIONS}
value={catScheme ?? undefined}
onSelect={(name) => set(CATEGORY, name)}
onSelect={(name) => set(CATEGORY, schemeRange(name))}
triggerContent={
<>
<span className={styles.triggerPreview} aria-hidden="true">
@@ -252,7 +265,7 @@ export function ColorControls({ config }: { config: JsonObject }) {
heading="Sequential scheme"
options={SEQUENTIAL_OPTIONS}
value={seqScheme ?? undefined}
onSelect={(name) => setSeq(name)}
onSelect={(name) => setSeq(schemeRange(name))}
triggerContent={
<>
<span>
@@ -315,7 +328,7 @@ export function ColorControls({ config }: { config: JsonObject }) {
heading="Diverging scheme"
options={DIVERGING_OPTIONS}
value={divScheme ?? undefined}
onSelect={(name) => set(DIVERGING, name)}
onSelect={(name) => set(DIVERGING, schemeRange(name))}
triggerContent={
<>
<span>
+133
View File
@@ -0,0 +1,133 @@
/**
* Theme Builder Layout panel (docs/chart-theming-scope.md §5).
*
* Structured controls over the draft config's surfaces and spacing: the chart
* `background`, the plot area's `view` fill / border / corner radius, and outer
* `padding`. Fill controls are tri-state (theme default · transparent/none ·
* custom color) because "transparent" is a meaningful, distinct choice from
* "unset" here the house style sets `background` and `view.stroke` to
* transparent, and stock Vega-Lite defaults them to white / `#ddd`.
*/
import type { JsonObject } from '@core/spec-config';
import { isJsonObject } from '@core/spec-config';
import { asNumber, asString, type ConfigPath, getConfigValue } from '@core/theme-controls';
import { useConfigSetter } from '../stores/CustomThemeStore';
import { ColorRow, ControlSection, NumberRow, SelectRow } from './ThemeFields';
import type { SelectControlOption } from './SelectControl';
import styles from './ThemeFields.module.css';
const BACKGROUND: ConfigPath = ['background'];
const VIEW_FILL: ConfigPath = ['view', 'fill'];
const VIEW_STROKE: ConfigPath = ['view', 'stroke'];
const VIEW_RADIUS: ConfigPath = ['view', 'cornerRadius'];
const PADDING: ConfigPath = ['padding'];
type FillMode = 'default' | 'transparent' | 'custom';
const fillMode = (v: unknown): FillMode =>
v === undefined ? 'default' : v === 'transparent' ? 'transparent' : 'custom';
const bgOptions: SelectControlOption<FillMode>[] = [
{ value: 'default', label: 'Theme default' },
{ value: 'transparent', label: 'Transparent' },
{ value: 'custom', label: 'Custom color' },
];
// Same modes; "None" reads better than "Transparent" for a border.
const strokeOptions: SelectControlOption<FillMode>[] = [
{ value: 'default', label: 'Theme default' },
{ value: 'transparent', label: 'None' },
{ value: 'custom', label: 'Custom color' },
];
const fillValue = (mode: FillMode, current: string | undefined, seed: string): unknown =>
mode === 'default' ? undefined : mode === 'transparent' ? 'transparent' : (current ?? seed);
export function LayoutControls({ config }: { config: JsonObject }) {
const set = useConfigSetter();
const bg = asString(getConfigValue(config, BACKGROUND));
const bgMode = fillMode(getConfigValue(config, BACKGROUND));
const viewFill = asString(getConfigValue(config, VIEW_FILL));
const stroke = asString(getConfigValue(config, VIEW_STROKE));
const strokeMode = fillMode(getConfigValue(config, VIEW_STROKE));
const radius = asNumber(getConfigValue(config, VIEW_RADIUS));
const padding = getConfigValue(config, PADDING);
const paddingNum = asNumber(padding);
return (
<div className={styles.panel}>
<ControlSection title="Background" hint="Fill behind the whole chart, padding included.">
<SelectRow
id="layout-bg-mode"
label="Background"
options={bgOptions}
value={bgMode}
onSelect={(m) => set(BACKGROUND, fillValue(m, bg, '#ffffff'))}
/>
{bgMode === 'custom' && (
<ColorRow
label="Color"
name="Background color"
value={bg}
fallback="#ffffff"
onChange={(hex) => set(BACKGROUND, hex)}
/>
)}
</ControlSection>
<ControlSection title="Plot area" hint="The plotting rectangle inside the axes.">
<ColorRow
label="Fill"
name="Plot area fill"
value={viewFill}
fallback="#ffffff"
onChange={(hex) => set(VIEW_FILL, hex)}
onClear={() => set(VIEW_FILL, undefined)}
/>
<SelectRow
id="layout-stroke-mode"
label="Border"
options={strokeOptions}
value={strokeMode}
onSelect={(m) => set(VIEW_STROKE, fillValue(m, stroke, '#cccccc'))}
/>
{strokeMode === 'custom' && (
<ColorRow
label="Border color"
value={stroke}
fallback="#cccccc"
onChange={(hex) => set(VIEW_STROKE, hex)}
/>
)}
<NumberRow
label="Corner radius"
value={radius}
min={0}
unit="px"
onChange={(n) => set(VIEW_RADIUS, n)}
/>
</ControlSection>
<ControlSection title="Spacing" hint="Margin between the chart and its container edge.">
{isJsonObject(padding) ? (
<p className={styles.hint}>
Padding is set per-side as an object edit it in the JSON below.
</p>
) : (
<NumberRow
label="Padding"
value={paddingNum}
min={0}
unit="px"
onChange={(n) => set(PADDING, n)}
/>
)}
</ControlSection>
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
/**
* Theme Builder Legend panel (docs/chart-theming-scope.md §5).
*
* Structured controls over the base `legend` config: placement (`orient`), the
* title's and labels' color and size, and the symbol size. Legend type (size)
* lives here rather than the Type panel so every legend property a brand tunes
* sits together the scope's "label/title color+size" grouping.
*/
import type { JsonObject } from '@core/spec-config';
import { asNumber, asString, type ConfigPath, getConfigValue } from '@core/theme-controls';
import { useConfigSetter } from '../stores/CustomThemeStore';
import { ColorRow, ControlSection, NumberRow, SelectRow } from './ThemeFields';
import type { SelectControlOption } from './SelectControl';
import styles from './ThemeFields.module.css';
const ORIENT: ConfigPath = ['legend', 'orient'];
const TITLE_COLOR: ConfigPath = ['legend', 'titleColor'];
const TITLE_SIZE: ConfigPath = ['legend', 'titleFontSize'];
const LABEL_COLOR: ConfigPath = ['legend', 'labelColor'];
const LABEL_SIZE: ConfigPath = ['legend', 'labelFontSize'];
const SYMBOL_SIZE: ConfigPath = ['legend', 'symbolSize'];
const TEXT_GREY = '#888888';
// Vega-Lite legend `orient` values; '' is the unset (theme default) sentinel.
type Orient = '' | 'right' | 'left' | 'top' | 'bottom' | 'top-left' | 'top-right' | 'none';
const orientOptions: SelectControlOption<Orient>[] = [
{ value: '', label: 'Theme default' },
{ value: 'right', label: 'Right' },
{ value: 'left', label: 'Left' },
{ value: 'top', label: 'Top' },
{ value: 'bottom', label: 'Bottom' },
{ value: 'top-left', label: 'Top-left' },
{ value: 'top-right', label: 'Top-right' },
{ value: 'none', label: 'Hidden' },
];
const orientValue = (v: unknown): Orient => {
const s = asString(v);
return s !== undefined && orientOptions.some((o) => o.value === s) ? (s as Orient) : '';
};
export function LegendControls({ config }: { config: JsonObject }) {
const set = useConfigSetter();
const orient = orientValue(getConfigValue(config, ORIENT));
const titleColor = asString(getConfigValue(config, TITLE_COLOR));
const titleSize = asNumber(getConfigValue(config, TITLE_SIZE));
const labelColor = asString(getConfigValue(config, LABEL_COLOR));
const labelSize = asNumber(getConfigValue(config, LABEL_SIZE));
const symbolSize = asNumber(getConfigValue(config, SYMBOL_SIZE));
return (
<div className={styles.panel}>
<ControlSection title="Placement" hint="Where the legend sits relative to the plot.">
<SelectRow
id="legend-orient"
label="Position"
options={orientOptions}
value={orient}
onSelect={(o) => set(ORIENT, o === '' ? undefined : o)}
/>
</ControlSection>
<ControlSection title="Title">
<ColorRow
label="Color"
name="Legend title color"
value={titleColor}
fallback={TEXT_GREY}
onChange={(hex) => set(TITLE_COLOR, hex)}
onClear={() => set(TITLE_COLOR, undefined)}
/>
<NumberRow
label="Size"
name="Legend title size"
value={titleSize}
min={0}
unit="px"
onChange={(n) => set(TITLE_SIZE, n)}
/>
</ControlSection>
<ControlSection title="Labels">
<ColorRow
label="Color"
name="Legend label color"
value={labelColor}
fallback={TEXT_GREY}
onChange={(hex) => set(LABEL_COLOR, hex)}
onClear={() => set(LABEL_COLOR, undefined)}
/>
<NumberRow
label="Size"
name="Legend label size"
value={labelSize}
min={0}
unit="px"
onChange={(n) => set(LABEL_SIZE, n)}
/>
</ControlSection>
<ControlSection title="Symbols" hint="The colored keys beside each label.">
<NumberRow
label="Symbol size"
value={symbolSize}
min={0}
unit="px²"
onChange={(n) => set(SYMBOL_SIZE, n)}
/>
</ControlSection>
</div>
);
}
+17 -25
View File
@@ -191,31 +191,6 @@
color: var(--text-secondary);
}
.typePanel {
display: grid;
gap: var(--space-3);
padding: var(--space-5);
}
.typeTitle {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.typeHint {
margin: 0;
font-size: 12px;
color: var(--text-secondary);
}
.fontCaret {
margin-left: var(--space-3);
font-size: 10px;
color: var(--text-secondary);
}
/* ── Controls + JSON (left) | gallery rail (right) ─────────────────────── */
/* The gallery takes the whole right side, full height; the controls column
@@ -322,3 +297,20 @@
font-size: 11px;
color: var(--text-secondary);
}
/* A render failure shows its message in place of the chart (fail-loud, arch 02),
sized to the reserved card box so the gallery doesn't reflow. */
.cardError {
min-width: 260px;
min-height: 180px;
margin: 0;
padding: var(--space-3);
display: flex;
align-items: center;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.4;
color: var(--support-error);
white-space: pre-wrap;
overflow: auto;
}
+20 -5
View File
@@ -11,13 +11,12 @@ import { createRoot, type Root } from 'react-dom/client';
import { THEME_PREVIEW_SPECS } from '@core/theme-preview-specs';
import { useAppStore } from '../stores/AppStore';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { renderSpec } from '../services/chart-renderer';
import { ThemeBuilderModal } from './ThemeBuilderModal';
vi.mock('../services/chart-renderer', () => ({
renderSpec: vi.fn(() =>
Promise.resolve({ destroy() {}, resize() {}, toImageURL: () => Promise.resolve('') }),
),
}));
vi.mock('../services/chart-renderer', () => ({ renderSpec: vi.fn() }));
const okHandle = () => ({ destroy() {}, resize() {}, toImageURL: () => Promise.resolve('') });
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -26,6 +25,8 @@ let root: Root;
beforeEach(() => {
vi.useFakeTimers();
vi.mocked(renderSpec).mockReset();
vi.mocked(renderSpec).mockImplementation(() => Promise.resolve(okHandle()));
useCustomThemeStore.getState().reset();
useAppStore.getState().setChartTheme('astrolabe');
container = document.createElement('div');
@@ -110,6 +111,20 @@ describe('ThemeBuilderModal', () => {
expect(container.textContent).toContain('Invalid JSON');
});
test('a gallery card surfaces a render failure instead of blanking silently', async () => {
vi.mocked(renderSpec).mockRejectedValue(new Error('Vega could not render this config'));
renderModal();
act(() => {
useCustomThemeStore.getState().createTheme('Brand', {});
});
// Fire the gallery's render debounce and flush the async render chain.
await act(async () => {
await vi.advanceTimersByTimeAsync(400);
});
expect(container.textContent).toContain('Vega could not render this config');
expect(container.querySelector('[role="alert"]')).toBeTruthy();
});
test('selecting another theme from the list swaps the draft', () => {
renderModal();
act(() => {
+38 -52
View File
@@ -12,9 +12,9 @@
*/
import { useEffect, useRef, useState } from 'react';
import { THEME_FONT_OPTIONS } from '@core/custom-theme';
import type { JsonObject } from '@core/spec-config';
import { THEME_PREVIEW_SPECS, type ThemePreviewSpec } from '@core/theme-preview-specs';
import { normalizeRangeSchemes } from '@core/theme-controls';
import { chartConfigForSelection, chartThemeOptions } from '@core/vega-themes';
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
import { useAppStore } from '../stores/AppStore';
@@ -26,29 +26,24 @@ import {
} from '../stores/CustomThemeStore';
import { notify } from '../stores/NotificationStore';
import { resnapshot } from '../modals/ModalCoordinator';
import { AxesControls } from './AxesControls';
import { Button } from './Button';
import { ColorControls } from './ColorControls';
import { SelectControl, type SelectControlOption } from './SelectControl';
import { LayoutControls } from './LayoutControls';
import { LegendControls } from './LegendControls';
import { TypeControls } from './TypeControls';
import styles from './ThemeBuilderModal.module.css';
/** Structured-control tabs, in display order (docs/chart-theming-scope.md §5). */
const THEME_TABS = [
{ id: 'color', label: 'Color' },
{ id: 'type', label: 'Type' },
{ id: 'layout', label: 'Layout' },
{ id: 'axes', label: 'Axes & grid' },
{ id: 'legend', label: 'Legend' },
] as const;
type ThemeTab = (typeof THEME_TABS)[number]['id'];
/**
* Font options, each labelled in its own family so the dropdown previews the
* typeface (the type analogue of the color dropdowns' swatches). Render-safe
* faces only today see THEME_FONT_OPTIONS.
*/
const FONT_OPTIONS: SelectControlOption<string>[] = THEME_FONT_OPTIONS.map(({ value, label }) => ({
value,
label,
labelStyle: { fontFamily: value },
}));
/** Debounce for gallery re-renders while the config text is edited (ms). */
const GALLERY_DEBOUNCE = 250;
@@ -58,14 +53,17 @@ const GALLERY_DEBOUNCE = 250;
* modal; raster is invisible at swatch size (same trade-off as the Chart
* Builder preview). Renders are serialized per card with the LivePreview
* chain-lock pattern so a slow embed never interleaves with a newer one on the
* shared host node. Render failures blank the card silently the gallery is
* a preview aid; the config editor's parse error is the real feedback channel.
* shared host node. A render failure surfaces in the card as its message (the
* same fail-loud treatment LivePreview gives the editor arch 02), never a
* silent blank: a config the user is editing that Vega can't render is exactly
* the feedback they need.
*/
function GalleryCard({ card, config }: { card: ThemePreviewSpec; config: JsonObject }) {
const hostRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<RenderHandle | null>(null);
const generationRef = useRef(0);
const chainRef = useRef<Promise<void>>(Promise.resolve());
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const node = hostRef.current;
@@ -83,7 +81,10 @@ function GalleryCard({ card, config }: { card: ThemePreviewSpec; config: JsonObj
if (mine !== generationRef.current) return;
handleRef.current?.destroy();
handleRef.current = null;
const handle = await renderSpec(node, card.spec, config, {
// Heal a bare range scheme string (rejected by Vega at render) the same
// way the live preview does — so a theme loaded with the old form still
// previews. New control writes already use the `{ scheme }` object.
const handle = await renderSpec(node, card.spec, normalizeRangeSchemes(config), {
renderer: 'canvas',
});
if (mine !== generationRef.current) {
@@ -91,8 +92,12 @@ function GalleryCard({ card, config }: { card: ThemePreviewSpec; config: JsonObj
return;
}
handleRef.current = handle;
} catch {
// Leave the card blank; the config editor reports the actionable error.
setError(null);
} catch (err) {
// Don't bury it (arch 02 fail-loud): show the message in the card so a
// config that renders on valid JSON but Vega rejects at runtime is
// visible, not a silent blank.
if (mine === generationRef.current) setError((err as Error).message);
} finally {
release();
}
@@ -112,7 +117,12 @@ function GalleryCard({ card, config }: { card: ThemePreviewSpec; config: JsonObj
return (
<figure className={styles.card}>
<div className={styles.cardHost} ref={hostRef} />
{error !== null && (
<pre className={styles.cardError} role="alert">
{error}
</pre>
)}
<div className={styles.cardHost} ref={hostRef} hidden={error !== null} />
<figcaption className={styles.cardCaption}>{card.caption}</figcaption>
</figure>
);
@@ -133,12 +143,6 @@ export function ThemeBuilderModal() {
// (it's the only place to fix the JSON).
const [jsonExpanded, setJsonExpanded] = useState(false);
const jsonOpen = jsonExpanded || parseError !== null;
// The font option matching the config's top-level font (if any) — drives the
// Type tab's selected state and its in-face trigger label.
const currentFont =
typeof draftConfig?.font === 'string'
? FONT_OPTIONS.find((o) => o.value === draftConfig.font)
: undefined;
// APG tabs: arrow/Home/End move selection, which follows focus (automatic
// activation — the panel swap is cheap). The portaled SelectControl popovers
@@ -302,35 +306,17 @@ export function ThemeBuilderModal() {
<p className={styles.controlsDisabled}>
Fix the JSON below to use these controls.
</p>
) : activeTab === 'color' && draftConfig !== null ? (
) : draftConfig === null ? null : activeTab === 'color' ? (
<ColorControls config={draftConfig} />
) : activeTab === 'type' ? (
<div className={styles.typePanel}>
<h4 className={styles.typeTitle}>Font family</h4>
<p className={styles.typeHint}>
Writes one family into every font slot of the config.
</p>
<SelectControl
id="theme-builder-font"
label="Font family"
heading="Apply font"
options={FONT_OPTIONS}
value={currentFont?.value}
onSelect={(family) => useCustomThemeStore.getState().applyDraftFont(family)}
triggerContent={
<>
<span style={currentFont ? { fontFamily: currentFont.value } : undefined}>
{currentFont?.label ?? 'Apply font…'}
</span>
<span className={styles.fontCaret} aria-hidden="true">
</span>
</>
}
triggerTitle="Write one font family into every font slot of the config"
/>
</div>
) : null}
<TypeControls config={draftConfig} />
) : activeTab === 'layout' ? (
<LayoutControls config={draftConfig} />
) : activeTab === 'axes' ? (
<AxesControls config={draftConfig} />
) : (
<LegendControls config={draftConfig} />
)}
</div>
{/* Raw JSON collapsed by default (the structured controls are the
@@ -0,0 +1,173 @@
/**
* Layout / Axes & grid / Legend / Type panels behavioural wiring through the
* live modal. The pure config transforms (path get/set, coercion) are covered in
* core/theme-controls.test.ts; these confirm each panel reads the draft config
* and writes the right path back through `mutateDraftConfig`, including the
* minimal-diff delete (clearing a value removes the key). vega-embed is mocked
* (the gallery is integration-heavy).
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { JsonObject } from '@core/spec-config';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { usePopoverStore } from '../stores/PopoverStore';
import { ThemeBuilderModal } from './ThemeBuilderModal';
vi.mock('../services/chart-renderer', () => ({
renderSpec: vi.fn(() =>
Promise.resolve({ destroy() {}, resize() {}, toImageURL: () => Promise.resolve('') }),
),
}));
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
vi.useFakeTimers();
useCustomThemeStore.getState().reset();
usePopoverStore.getState().close(); // the open-popover registry is global; isolate tests
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
});
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.useRealTimers();
});
const render = () => act(() => root.render(<ThemeBuilderModal />));
/** Open a draft seeded with `config`, then switch to a structured-control tab. */
const open = (config: JsonObject, tabLabel: string) => {
act(() => {
useCustomThemeStore.getState().createTheme('Brand', config);
});
const tab = [...container.querySelectorAll('button')].find(
(b) => b.getAttribute('role') === 'tab' && b.textContent === tabLabel,
)!;
act(() => tab.click());
};
const config = () => useCustomThemeStore.getState().draftConfig as JsonObject;
const at = (...path: string[]): unknown =>
path.reduce<unknown>(
(cur, key) =>
cur && typeof cur === 'object' ? (cur as Record<string, unknown>)[key] : undefined,
config(),
);
/** Drive an input's value through the native setter so React's tracker fires onChange. */
function setNativeValue(el: HTMLInputElement, value: string) {
// eslint-disable-next-line @typescript-eslint/unbound-method -- invoked immediately via .call
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
setter.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
}
const numberInput = (name: string) =>
container.querySelector<HTMLInputElement>(`input[aria-label="${name}"]`)!;
/** A button anywhere in the document (popovers portal to <body>) by exact label. */
const button = (label: string) =>
[...document.querySelectorAll('button')].find((b) => b.textContent === label);
/** Open the SelectControl whose accessible name starts with `prefix`, then pick `option`. */
const pick = (prefix: string, option: string) => {
const trigger = [...container.querySelectorAll('button')].find((b) =>
b.getAttribute('aria-label')?.startsWith(prefix),
)!;
act(() => trigger.click());
act(() => button(option)!.click());
};
describe('LayoutControls', () => {
test('background mode "Transparent" writes background: transparent', () => {
render();
open({}, 'Layout');
pick('Background', 'Transparent');
expect(config().background).toBe('transparent');
});
test('corner radius writes view.cornerRadius and clearing prunes the view object', () => {
render();
open({}, 'Layout');
act(() => setNativeValue(numberInput('Corner radius'), '8'));
expect(at('view', 'cornerRadius')).toBe(8);
act(() => setNativeValue(numberInput('Corner radius'), ''));
expect(config().view).toBeUndefined();
});
test('object padding shows the JSON hint instead of a number control', () => {
render();
open({ padding: { left: 5, top: 5 } }, 'Layout');
expect(container.textContent).toContain('edit it in the JSON below');
expect(container.querySelector('input[aria-label="Padding"]')).toBeNull();
});
});
describe('AxesControls', () => {
test('grid lines "Hidden" writes axis.grid false', () => {
render();
open({}, 'Axes & grid');
pick('Grid lines', 'Hidden');
expect(at('axis', 'grid')).toBe(false);
});
test('grid style "Dotted" writes a dash array; "Theme default" removes it', () => {
render();
open({}, 'Axes & grid');
pick('Grid style', 'Dotted');
expect(at('axis', 'gridDash')).toEqual([2, 2]);
pick('Grid style', 'Theme default');
expect(at('axis', 'gridDash')).toBeUndefined();
});
test('a negative label angle is accepted', () => {
render();
open({}, 'Axes & grid');
act(() => setNativeValue(numberInput('Label angle'), '-45'));
expect(at('axis', 'labelAngle')).toBe(-45);
});
});
describe('LegendControls', () => {
test('position writes legend.orient', () => {
render();
open({}, 'Legend');
pick('Position', 'Bottom');
expect(at('legend', 'orient')).toBe('bottom');
});
test('symbol size writes legend.symbolSize', () => {
render();
open({}, 'Legend');
act(() => setNativeValue(numberInput('Symbol size'), '120'));
expect(at('legend', 'symbolSize')).toBe(120);
});
});
describe('TypeControls', () => {
test('title weight "Bold" writes title.fontWeight 700', () => {
render();
open({}, 'Type');
pick('Title weight', 'Bold');
expect(at('title', 'fontWeight')).toBe(700);
});
test('axis label size writes axis.labelFontSize', () => {
render();
open({}, 'Type');
act(() => setNativeValue(numberInput('Axis label size'), '9'));
expect(at('axis', 'labelFontSize')).toBe(9);
});
});
+77
View File
@@ -0,0 +1,77 @@
/* Theme Builder structured-control field primitives shared by the Layout,
Axes & grid, Legend, and Type panels (docs/chart-theming-scope.md §5). */
.panel {
display: grid;
gap: var(--space-6);
padding: var(--space-5);
}
.group {
display: grid;
gap: var(--space-3);
}
.groupTitle {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.hint {
margin: 0;
font-size: 12px;
color: var(--text-secondary);
}
/* Fields within a section stack with a label column for cross-row alignment. */
.fields {
display: grid;
gap: var(--space-3);
}
.field {
display: grid;
grid-template-columns: 132px 1fr;
align-items: center;
gap: var(--space-3);
min-height: var(--control-height);
}
.fieldLabel {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.control {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.number {
width: 88px;
height: var(--control-height);
padding: 0 var(--space-3);
font-size: 13px;
}
.unit {
font-size: 12px;
color: var(--text-secondary);
}
.defaultNote {
font-size: 12px;
color: var(--text-secondary);
}
/* Caret for a custom SelectControl trigger (e.g. the Type panel's in-face font). */
.caret {
margin-left: var(--space-3);
font-size: 10px;
color: var(--text-secondary);
}
+195
View File
@@ -0,0 +1,195 @@
/**
* Theme Builder structured-control field primitives (docs/chart-theming-scope.md
* §5).
*
* The Layout / Axes & grid / Legend / Type panels are forms of scalar controls
* over the draft config a labelled color, number, or enum per config key. This
* is the small shared vocabulary they're built from, so the panels read
* declaratively and look identical:
*
* - `ControlSection` a titled group with an optional hint.
* - `ColorRow` / `NumberRow` / `SelectRow` one labelled control each.
*
* Each control reports a value or `undefined` (cleared); the panel writes it
* through `mutateDraftConfig` + `setConfigValue`, where `undefined` deletes the
* key for a minimal diff. The Color panel predates this module and keeps its own
* swatch/gradient controls; these primitives cover the scalar surface the later
* panels share. Leaf coercion (`asString`/`asNumber`/`asBoolean`) lives in core
* `theme-controls.ts` with the path get/set it pairs with.
*/
import { useState, type ReactNode } from 'react';
import { Button } from './Button';
import { ColorField } from './ColorField';
import { SelectControl, type SelectControlOption } from './SelectControl';
import styles from './ThemeFields.module.css';
const slug = (s: string): string =>
s
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
/**
* A titled group of fields with an optional one-line hint. A `role="group"`
* labelled by its heading, so a screen reader announces the group context when
* entering it which is what lets the rows inside carry short labels ("Size",
* "Color") that repeat across sections without colliding (APG group pattern).
*/
export function ControlSection({
title,
hint,
children,
}: {
title: string;
hint?: string;
children: ReactNode;
}) {
const headingId = `theme-group-${slug(title)}`;
return (
<section className={styles.group} role="group" aria-labelledby={headingId}>
<h4 id={headingId} className={styles.groupTitle}>
{title}
</h4>
{hint && <p className={styles.hint}>{hint}</p>}
<div className={styles.fields}>{children}</div>
</section>
);
}
/**
* A labelled color. When unset, the swatch shows `fallback` (the rendered
* default) with a "Default" note rather than a blank chip; once set, a Clear
* deletes the key.
*/
export function ColorRow({
label,
name,
value,
fallback,
onChange,
onClear,
}: {
label: string;
/** Accessible name when `label` is too generic to stand alone (e.g. "Size"). */
name?: string;
value: string | undefined;
fallback: string;
onChange: (hex: string) => void;
onClear?: () => void;
}) {
return (
<div className={styles.field}>
<span className={styles.fieldLabel}>{label}</span>
<div className={styles.control}>
<ColorField value={value ?? fallback} label={name ?? label} onChange={onChange} hex />
{value !== undefined ? (
onClear && (
<Button variant="ghost" onClick={onClear}>
Clear
</Button>
)
) : (
<span className={styles.defaultNote}>Default</span>
)}
</div>
</div>
);
}
/**
* A labelled number. Local text state so a transient entry (a lone "-", a
* half-typed value) isn't rejected mid-keystroke commits a finished number,
* deletes the key when emptied, reverts to the committed value on blur. Resyncs
* to an outside change (another control, a JSON edit) the same way ColorField's
* hex field does.
*/
export function NumberRow({
label,
name,
value,
onChange,
min,
max,
step,
unit,
}: {
label: string;
/** Accessible name when `label` is too generic to stand alone (e.g. "Size"). */
name?: string;
value: number | undefined;
onChange: (value: number | undefined) => void;
min?: number;
max?: number;
step?: number;
unit?: string;
}) {
const [text, setText] = useState(value === undefined ? '' : String(value));
const [synced, setSynced] = useState(value);
if (value !== synced) {
setSynced(value);
const typed = text.trim() === '' ? undefined : Number(text);
if (typed !== value) setText(value === undefined ? '' : String(value));
}
return (
<div className={styles.field}>
<span className={styles.fieldLabel}>{label}</span>
<div className={styles.control}>
<input
type="number"
className={styles.number}
aria-label={name ?? label}
value={text}
min={min}
max={max}
step={step}
onChange={(e) => {
const raw = e.target.value;
setText(raw);
const t = raw.trim();
if (t === '') onChange(undefined);
else if (t !== '-' && Number.isFinite(Number(t))) onChange(Number(t));
}}
onBlur={() => setText(value === undefined ? '' : String(value))}
/>
{unit && <span className={styles.unit}>{unit}</span>}
</div>
</div>
);
}
/**
* A labelled enum, on the app's SelectControl. The caller maps the config value
* to/from option strings; an unset config key is conventionally the `''` option
* ("Theme default"), so the trigger always shows a current choice.
*/
export function SelectRow<V extends string>({
id,
label,
name,
options,
value,
onSelect,
}: {
id: string;
label: string;
/** Accessible name when `label` is too generic to stand alone (e.g. "Weight"). */
name?: string;
options: ReadonlyArray<SelectControlOption<V>>;
value: V;
onSelect: (value: V) => void;
}) {
return (
<div className={styles.field}>
<span className={styles.fieldLabel}>{label}</span>
<SelectControl
id={id}
label={name ?? label}
options={options}
value={value}
onSelect={onSelect}
/>
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
/**
* Theme Builder Type panel (docs/chart-theming-scope.md §5).
*
* The font family (written into every font slot via `applyDraftFont`, the one
* transform that walks the whole config) plus the type scale a brand tunes:
* title size/weight and axis title/label size/weight. Legend type lives in the
* Legend panel so all legend properties sit together. Color of text lives in
* the per-domain panels (Axes, Legend); this panel is family, size, and weight.
*/
import { THEME_FONT_OPTIONS } from '@core/custom-theme';
import type { JsonObject } from '@core/spec-config';
import { asNumber, type ConfigPath, getConfigValue } from '@core/theme-controls';
import { useConfigSetter, useCustomThemeStore } from '../stores/CustomThemeStore';
import { SelectControl, type SelectControlOption } from './SelectControl';
import { ControlSection, NumberRow, SelectRow } from './ThemeFields';
import styles from './ThemeFields.module.css';
const TITLE_SIZE: ConfigPath = ['title', 'fontSize'];
const TITLE_WEIGHT: ConfigPath = ['title', 'fontWeight'];
const AXIS_TITLE_SIZE: ConfigPath = ['axis', 'titleFontSize'];
const AXIS_TITLE_WEIGHT: ConfigPath = ['axis', 'titleFontWeight'];
const AXIS_LABEL_SIZE: ConfigPath = ['axis', 'labelFontSize'];
const AXIS_LABEL_WEIGHT: ConfigPath = ['axis', 'labelFontWeight'];
/**
* Font options, each labelled in its own family so the dropdown previews the
* typeface (the type analogue of the color dropdowns' swatches). The roster
* (THEME_FONT_OPTIONS) is loaded before render by the chart-renderer's font gate.
*/
const FONT_OPTIONS: SelectControlOption<string>[] = THEME_FONT_OPTIONS.map(({ value, label }) => ({
value,
label,
labelStyle: { fontFamily: value },
}));
// Numeric font weights as a tri-state enum; '' is the unset (default) sentinel.
type Weight = '' | '400' | '500' | '600' | '700' | 'custom';
const weightOptions: SelectControlOption<Weight>[] = [
{ value: '', label: 'Theme default' },
{ value: '400', label: 'Normal' },
{ value: '500', label: 'Medium' },
{ value: '600', label: 'Semibold' },
{ value: '700', label: 'Bold' },
];
const weightValue = (v: unknown): Weight => {
if (v === undefined) return '';
if (typeof v === 'number') {
const s = String(v) as Weight;
return weightOptions.some((o) => o.value === s) ? s : 'custom';
}
// Named CSS weights map onto the two presets that have names.
if (v === 'normal') return '400';
if (v === 'bold') return '700';
return 'custom';
};
export function TypeControls({ config }: { config: JsonObject }) {
const set = useConfigSetter();
const currentFont =
typeof config.font === 'string' ? FONT_OPTIONS.find((o) => o.value === config.font) : undefined;
const setWeight = (path: ConfigPath) => (w: Weight) =>
set(path, w === '' ? undefined : Number(w));
return (
<div className={styles.panel}>
<ControlSection title="Font family" hint="Applied to every text slot in the config.">
<div className={styles.field}>
<span className={styles.fieldLabel}>Font</span>
<SelectControl
id="theme-builder-font"
label="Font family"
heading="Apply font"
options={FONT_OPTIONS}
value={currentFont?.value}
onSelect={(family) => useCustomThemeStore.getState().applyDraftFont(family)}
triggerContent={
<>
<span style={currentFont ? { fontFamily: currentFont.value } : undefined}>
{currentFont?.label ?? 'Apply font…'}
</span>
<span className={styles.caret} aria-hidden="true">
</span>
</>
}
triggerTitle="Write one font family into every font slot of the config"
/>
</div>
</ControlSection>
<ControlSection title="Title">
<NumberRow
label="Size"
name="Title size"
value={asNumber(getConfigValue(config, TITLE_SIZE))}
min={0}
unit="px"
onChange={(n) => set(TITLE_SIZE, n)}
/>
<SelectRow
id="type-title-weight"
label="Weight"
name="Title weight"
options={weightOptions}
value={weightValue(getConfigValue(config, TITLE_WEIGHT))}
onSelect={setWeight(TITLE_WEIGHT)}
/>
</ControlSection>
<ControlSection title="Axis titles">
<NumberRow
label="Size"
name="Axis title size"
value={asNumber(getConfigValue(config, AXIS_TITLE_SIZE))}
min={0}
unit="px"
onChange={(n) => set(AXIS_TITLE_SIZE, n)}
/>
<SelectRow
id="type-axis-title-weight"
label="Weight"
name="Axis title weight"
options={weightOptions}
value={weightValue(getConfigValue(config, AXIS_TITLE_WEIGHT))}
onSelect={setWeight(AXIS_TITLE_WEIGHT)}
/>
</ControlSection>
<ControlSection title="Axis labels">
<NumberRow
label="Size"
name="Axis label size"
value={asNumber(getConfigValue(config, AXIS_LABEL_SIZE))}
min={0}
unit="px"
onChange={(n) => set(AXIS_LABEL_SIZE, n)}
/>
<SelectRow
id="type-axis-label-weight"
label="Weight"
name="Axis label weight"
options={weightOptions}
value={weightValue(getConfigValue(config, AXIS_LABEL_WEIGHT))}
onSelect={setWeight(AXIS_LABEL_WEIGHT)}
/>
</ControlSection>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Chart-renderer contract regression: the `range` scheme config form.
*
* The Theme Builder's Color panel writes a named scheme into `config.range.*`.
* A bare scheme-name *string* passes vega-lite compile but Vega rejects it at
* run ("Unrecognized scale range value"), silently blanking the chart so the
* panel must write the object form `{ scheme: name }`. This drives a real
* vega-lite compile vega parse view run (renderer 'none', no DOM) over an
* actual gallery spec to lock that in. (renderSpec itself is integration-heavy
* vega-embed + a live DOM and is mocked in component tests; this covers the
* config contract underneath it.)
*/
import { describe, expect, it } from 'vitest';
import { compile } from 'vega-lite';
import * as vega from 'vega';
import type { TopLevelSpec } from 'vega-lite';
import type { Config } from 'vega-lite';
import { THEME_PREVIEW_SPECS } from '@core/theme-preview-specs';
const nominalColorSpec = THEME_PREVIEW_SPECS.find((c) => c.id === 'line')!
.spec as unknown as TopLevelSpec;
async function run(config: Config): Promise<void> {
const view = new vega.View(vega.parse(compile(nominalColorSpec, { config }).spec), {
renderer: 'none',
});
await view.runAsync();
view.finalize();
}
describe('range scheme config form', () => {
it('renders with the { scheme } object form (what the Color panel writes)', async () => {
await expect(run({ range: { category: { scheme: 'category20b' } } })).resolves.toBeUndefined();
});
it('renders with an explicit color array', async () => {
await expect(
run({ range: { category: ['#111111', '#222222', '#333333'] } }),
).resolves.toBeUndefined();
});
it('rejects a bare scheme-name string (the bug this guards against)', async () => {
await expect(
run({ range: { category: 'category20b' } as unknown as Config['range'] }),
).rejects.toThrow(/Unrecognized scale range value/);
});
});
+15
View File
@@ -21,6 +21,7 @@ import { create } from 'zustand';
import { applyFontToConfig, createCustomTheme, type CustomTheme } from '@core/custom-theme';
import { isNameTaken, makeUniqueName } from '@core/naming';
import { isJsonObject, type JsonObject } from '@core/spec-config';
import { type ConfigPath, setConfigValue } from '@core/theme-controls';
import { customThemeSelection } from '@core/vega-themes';
import { useAppStore } from './AppStore';
@@ -264,6 +265,20 @@ export const useCustomThemeStore = create<CustomThemeState>((set, get) => ({
}),
}));
/**
* The write half every Theme Builder structured-control panel shares: a
* `set(path, value)` that mutates the draft config through `mutateDraftConfig`,
* deleting the leaf (and pruning emptied ancestors) when `value` is `undefined`.
* Panels bind their controls to this, so the read (`getConfigValue` + the `as*`
* coercions) and write live in one vocabulary. Lives here, beside
* `mutateDraftConfig`, rather than in the JSX field module the field components
* must stay component-only for fast refresh.
*/
export function useConfigSetter(): (path: ConfigPath, value: unknown) => void {
const mutate = useCustomThemeStore((s) => s.mutateDraftConfig);
return (path, value) => mutate((c) => setConfigValue(c, path, value));
}
/** Selector: the saved record the builder has open, or null. Derive — never store. */
export const selectSelectedTheme = (s: CustomThemeState): CustomTheme | null =>
s.themes.find((t) => t.id === s.selectedId) ?? null;
+61
View File
@@ -1,7 +1,11 @@
import { describe, expect, it } from 'vitest';
import {
THEME_SCHEMES,
asBoolean,
asNumber,
asString,
getConfigValue,
normalizeRangeSchemes,
schemeColors,
schemesByKind,
setConfigValue,
@@ -100,6 +104,63 @@ describe('THEME_SCHEMES catalog', () => {
});
});
describe('normalizeRangeSchemes', () => {
it('wraps a bare scheme string in the { scheme } object form', () => {
expect(normalizeRangeSchemes({ range: { category: 'category20b' } })).toEqual({
range: { category: { scheme: 'category20b' } },
});
});
it('normalizes every range family and preserves siblings', () => {
const out = normalizeRangeSchemes({
background: 'transparent',
range: { category: ['#111'], heatmap: 'viridis', ramp: 'viridis', diverging: 'redblue' },
});
expect(out).toEqual({
background: 'transparent',
range: {
category: ['#111'],
heatmap: { scheme: 'viridis' },
ramp: { scheme: 'viridis' },
diverging: { scheme: 'redblue' },
},
});
});
it('is idempotent and returns the same object when nothing changes', () => {
const config = { range: { category: { scheme: 'category10' }, ordinal: ['#000'] } };
expect(normalizeRangeSchemes(config)).toBe(config);
});
it('is a no-op when there is no range object', () => {
const config = { font: 'Inter' };
expect(normalizeRangeSchemes(config)).toBe(config);
});
});
describe('leaf coercion', () => {
it('asString accepts strings only', () => {
expect(asString('x')).toBe('x');
expect(asString(1)).toBeUndefined();
expect(asString(undefined)).toBeUndefined();
});
it('asNumber accepts finite numbers only', () => {
expect(asNumber(0)).toBe(0);
expect(asNumber(-45)).toBe(-45);
expect(asNumber(NaN)).toBeUndefined();
expect(asNumber(Infinity)).toBeUndefined();
expect(asNumber('11')).toBeUndefined();
});
it('asBoolean accepts booleans only', () => {
expect(asBoolean(false)).toBe(false);
expect(asBoolean(true)).toBe(true);
expect(asBoolean('true')).toBeUndefined();
expect(asBoolean(undefined)).toBeUndefined();
});
});
describe('schemeColors', () => {
it('returns the full fixed palette for a categorical scheme (count ignored)', () => {
const colors = schemeColors('tableau10', 3);
+37
View File
@@ -76,6 +76,43 @@ function omitKey(obj: JsonObject, key: string): JsonObject {
return rest;
}
// ── Range scheme normalization ────────────────────────────────────────────────
/**
* Convert any bare scheme-name string under `config.range.*` to Vega's
* range-scheme object `{ scheme: name }`. A bare string passes vega-lite compile
* but Vega rejects it at render ("Unrecognized scale range value"), silently
* blanking the chart; the object form is the one Vega accepts. Idempotent
* arrays, existing `{ scheme }` objects, and every other key pass through
* untouched. Applied where a theme config becomes a render config, so a config
* authored, imported, or saved by an earlier build with the bare form still
* renders. Returns the same object when nothing needed changing.
*/
export function normalizeRangeSchemes(config: JsonObject): JsonObject {
const range = config.range;
if (!isJsonObject(range)) return config;
let changed = false;
const out: JsonObject = { ...range };
for (const [family, value] of Object.entries(range)) {
if (typeof value === 'string') {
out[family] = { scheme: value };
changed = true;
}
}
return changed ? { ...config, range: out } : config;
}
// ── Leaf coercion ─────────────────────────────────────────────────────────────
// Read a config leaf back into the type a structured control binds to, or
// `undefined` when it holds something else (a hand-edit, a preset's richer
// form) — the control then shows its default rather than misrendering.
export const asString = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined);
export const asNumber = (v: unknown): number | undefined =>
typeof v === 'number' && Number.isFinite(v) ? v : undefined;
export const asBoolean = (v: unknown): boolean | undefined =>
typeof v === 'boolean' ? v : undefined;
// ── Named color schemes ─────────────────────────────────────────────────────
type SchemeKind = 'categorical' | 'sequential' | 'diverging';
+5 -1
View File
@@ -29,6 +29,7 @@ import type { Config } from 'vega-lite';
// tree via vega-embed). Pure data — config objects only — so portable for core.
import * as presets from 'vega-themes';
import type { CustomTheme } from './custom-theme';
import { normalizeRangeSchemes } from './theme-controls';
import type { UiTheme } from './theme';
const PLEX = '"IBM Plex Sans", system-ui, -apple-system, sans-serif';
@@ -298,7 +299,10 @@ export function chartConfigForSelection(
const customId = customThemeIdOf(selection);
if (customId !== null) {
const theme = customThemes.find((t) => t.id === customId);
return theme ? theme.config : CHART_CONFIG[uiTheme];
// Heal a custom config saved/imported with a bare range scheme string, which
// Vega rejects at render (see normalizeRangeSchemes); built-in configs below
// are already well-formed.
return theme ? normalizeRangeSchemes(theme.config) : CHART_CONFIG[uiTheme];
}
if (selection === 'astrolabe') return CHART_CONFIG[uiTheme];
if (selection === 'stock') return STOCK_CONFIG;