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 **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 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. `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 - **Do** call `view.finalize()` on every previous view before rendering a new
one, and on component unmount. one, and on component unmount.
- **Do** keep exactly one live view per preview node. - **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 renders `draftConfig` per card through the shared `renderSpec` with the
**canvas** renderer and a per-card debounce + chain-lock (the LivePreview **canvas** renderer and a per-card debounce + chain-lock (the LivePreview
serialization pattern, one lock per card) — so invalid JSON mid-edit never 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 `applyFontToConfig(config, family)` is the font control's transform: it sets
the top-level `font` and rewrites every `font`/`*Font` string slot at any the top-level `font` and rewrites every `font`/`*Font` string slot at any
depth — explicit slots would otherwise keep overriding the new default. 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 ### Structured controls
The builder's panels (`ColorControls`; the Type tab's font control) are The builder's panels Color, Type, Layout, Axes & grid, Legend
accelerators over the same `draftConfig`: each reads a value and writes one back (`ColorControls` + `TypeControls`/`LayoutControls`/`AxesControls`/`LegendControls`
through `CustomThemeStore.mutateDraftConfig(fn)` — the single transform path, on the shared `ThemeFields` field primitives) — are accelerators over the same
which reparses, reformats, and updates `draftConfig` so the JSON editor and `draftConfig`: each reads a value and writes one back through
gallery follow (a parse error disables the controls). The pure transforms live `CustomThemeStore.mutateDraftConfig(fn)` — the single transform path, which
in `core/theme-controls.ts`: immutable config path get/set, the named-scheme reparses, reformats, and updates `draftConfig` so the JSON editor and gallery
catalog (`THEME_SCHEMES`), and `schemeColors` (scheme name → hex swatches, from follow (a parse error disables the controls). The pure transforms live in
the `vega-scale` registry — a focused vega sub-package). A color family holds `core/theme-controls.ts`: immutable config path get/set, leaf coercion, the
**either** a named scheme string **or** an explicit color array; the picker named-scheme catalog (`THEME_SCHEMES`), and `schemeColors` (scheme name → hex
materializes one to the other. Family by scale: `range.category` (nominal), swatches, from the `vega-scale` registry — a focused vega sub-package). A color
`range.ramp` (continuous; `range.heatmap` for `rect`), `range.diverging` family holds **either** a named scheme as Vega's range-scheme **object**
(continuous color with a `domainMid`). `{ 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`, Repeated control labels across panels ("Size", "Color", "Weight") get a
which sets a value at a path **immutably, preserving sibling keys**, and qualified accessible name while keeping the short visible label; the section is a
deletes (pruning emptied ancestors) on `undefined` so a theme stays a diff. `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 - **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 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. `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, - **Do** add a `theme-preview-specs.ts` gallery card for any new color family,
so no control ships without a visible mirror. 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 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 export/import envelope (additive `themes` array, name auto-suffix on clash, ids
reassigned by the store, rolled back with datasets on a failed import). reassigned by the store, rolled back with datasets on a failed import).
5. **Shipped font roster** — fontsource packages, `@font-face` registration, selector 5. **Shipped font roster** ✅ (2026-06-14) — 11 self-hosted families via @fontsource
metadata (which themes/fonts pair), `document.fonts.load` gate in the render path, (`styles/chart-fonts.css`, full subsets bundled) extending `THEME_FONT_OPTIONS` to 17
precache strategy above. Roster finalized via visual specimen. 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 6. **User font upload** — FontFace-from-IndexedDB tier; theme entity's `fonts` field
carries `{ family, source: 'file' }`. carries `{ family, source: 'file' }`.
7. **Deferred** — Google Fonts opt-in tier; SVG export font embedding; built-in 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 one xlarge modal. No nested overlays/focus traps, no contention with the one-open-popover
registry, and panels + JSON + gallery stay visible together. registry, and panels + JSON + gallery stay visible together.
- **Color model — scheme picker that materializes to swatches.** A `range` family takes - **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 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 array for brand tuning. Catalog ships 15 categorical + 24 sequential + 10 diverging
schemes; categorical schemes resolve to arrays, continuous ones to interpolators sampled 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. 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`/ **Panels:** Color (`range.category` swatches/scheme, `mark.color`, `range.heatmap`/`ramp`/
`diverging`) · Type (base `font`, title/axis/legend size+weight) · Layout (`background` incl. `diverging`) · Type (base `font`, title + axis title/label size+weight) · Layout (`background`
transparent, `padding`, `view.stroke`/`fill`/cornerRadius) · Axes & grid (grid on/off + color 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
- dash, domain, label color/angle — base `axis` only; the 25 variants stay JSON) · Legend JSON) · Legend (orient, title/label color+size, symbol size). Legend _type_ (size) lives in
(orient, label/title color+size, symbol size). 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 + **Build order:** (a) core foundation — scheme catalog + immutable config path get/set +
`schemeColors` materialize, with tests; (b) Color panel (highest payoff); (c) Type, Layout, `schemeColors` materialize, with tests; (b) Color panel (highest payoff); (c) Type, Layout,
@@ -216,6 +224,60 @@ the app standardizes on v6.
## 6. Status log ## 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 - **2026-06-14 (slice 4b, first increment)** — **structured-control foundation + Color
panel.** Core `theme-controls.ts`: immutable config path get/set (preserves siblings — 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 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); 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(); render();
open({}); open({});
act(() => picker('Categorical color scheme')!.click()); act(() => picker('Categorical color scheme')!.click());
act(() => button('Category 10')!.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(); render();
open({}); open({});
act(() => picker('Sequential color scheme')!.click()); act(() => picker('Sequential color scheme')!.click());
act(() => button('Viridis')!.click()); act(() => button('Viridis')!.click());
expect(range().heatmap).toBe('viridis'); expect(range().heatmap).toEqual({ scheme: 'viridis' });
expect(range().ramp).toBe('viridis'); expect(range().ramp).toEqual({ scheme: 'viridis' });
}); });
test('invalid JSON disables the controls', () => { 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 * and the gallery follow; reads come from the parsed draft config, so a JSON
* hand-edit reflects straight back into the controls. * hand-edit reflects straight back into the controls.
* *
* Color model (§5): every family holds either a named Vega scheme (compact, the * Color model (§5): every family holds either a named Vega scheme as the
* quick path) or an explicit color array (custom tuning). Picking a scheme from * range-scheme object `{ scheme: name }` (compact, the quick path) or an explicit
* the preview-bearing dropdown writes the name; "Materialize" expands it to an * color array (custom tuning). Picking a scheme from the preview-bearing dropdown
* editable array of swatches — categorical, sequential, and diverging alike. * 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 * Each swatch pairs the native color picker with a hex text field, so a value
* can be read, copied, and retyped anywhere. * can be read, copied, and retyped anywhere.
*/ */
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { JsonObject } from '@core/spec-config'; import type { JsonObject } from '@core/spec-config';
import { import { type ConfigPath, getConfigValue, schemeColors, schemesByKind } from '@core/theme-controls';
type ConfigPath,
getConfigValue,
schemeColors,
schemesByKind,
setConfigValue,
} from '@core/theme-controls';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { Button } from './Button'; import { Button } from './Button';
import { ColorField } from './ColorField'; import { ColorField } from './ColorField';
import { Icon } from './Icon'; import { Icon } from './Icon';
import { IconButton } from './IconButton'; import { IconButton } from './IconButton';
import { SelectControl, type SelectControlOption } from './SelectControl'; import { SelectControl, type SelectControlOption } from './SelectControl';
import { useConfigSetter } from '../stores/CustomThemeStore';
import styles from './ColorControls.module.css'; import styles from './ColorControls.module.css';
const CATEGORY: ConfigPath = ['range', 'category']; 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; Array.isArray(v) && v.every((c) => typeof c === 'string') ? v : null;
const asString = (v: unknown): string | null => (typeof v === '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 => const gradientCss = (colors: string[]): string =>
colors.length ? `linear-gradient(90deg, ${colors.join(', ')})` : 'transparent'; colors.length ? `linear-gradient(90deg, ${colors.join(', ')})` : 'transparent';
@@ -117,25 +129,26 @@ function SwatchRow({
} }
export function ColorControls({ config }: { config: JsonObject }) { export function ColorControls({ config }: { config: JsonObject }) {
const mutate = useCustomThemeStore((s) => s.mutateDraftConfig); const set = useConfigSetter();
const set = (path: ConfigPath, value: unknown) => mutate((c) => setConfigValue(c, path, value));
// Sequential color lives in two slots (heatmaps + continuous legends); keep them together. // Sequential color lives in two slots (heatmaps + continuous legends); keep them together.
const setSeq = (value: unknown) => const setSeq = (value: unknown) => {
mutate((c) => setConfigValue(setConfigValue(c, HEATMAP, value), RAMP, value)); set(HEATMAP, value);
set(RAMP, value);
};
const catValue = getConfigValue(config, CATEGORY); const catValue = getConfigValue(config, CATEGORY);
const catArray = asArray(catValue); const catArray = asArray(catValue);
const catScheme = asString(catValue); const catScheme = asScheme(catValue);
const markColor = asString(getConfigValue(config, MARK_COLOR)); const markColor = asString(getConfigValue(config, MARK_COLOR));
const seqValue = getConfigValue(config, HEATMAP); const seqValue = getConfigValue(config, HEATMAP);
const seqArray = asArray(seqValue); const seqArray = asArray(seqValue);
const seqScheme = asString(seqValue); const seqScheme = asScheme(seqValue);
const divValue = getConfigValue(config, DIVERGING); const divValue = getConfigValue(config, DIVERGING);
const divArray = asArray(divValue); 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. */ /** Stops to drive a gradient preview for a family in any of its states. */
const previewStops = (array: string[] | null, scheme: string | null): string[] => const previewStops = (array: string[] | null, scheme: string | null): string[] =>
@@ -155,7 +168,7 @@ export function ColorControls({ config }: { config: JsonObject }) {
heading="Color scheme" heading="Color scheme"
options={CATEGORICAL_OPTIONS} options={CATEGORICAL_OPTIONS}
value={catScheme ?? undefined} value={catScheme ?? undefined}
onSelect={(name) => set(CATEGORY, name)} onSelect={(name) => set(CATEGORY, schemeRange(name))}
triggerContent={ triggerContent={
<> <>
<span className={styles.triggerPreview} aria-hidden="true"> <span className={styles.triggerPreview} aria-hidden="true">
@@ -252,7 +265,7 @@ export function ColorControls({ config }: { config: JsonObject }) {
heading="Sequential scheme" heading="Sequential scheme"
options={SEQUENTIAL_OPTIONS} options={SEQUENTIAL_OPTIONS}
value={seqScheme ?? undefined} value={seqScheme ?? undefined}
onSelect={(name) => setSeq(name)} onSelect={(name) => setSeq(schemeRange(name))}
triggerContent={ triggerContent={
<> <>
<span> <span>
@@ -315,7 +328,7 @@ export function ColorControls({ config }: { config: JsonObject }) {
heading="Diverging scheme" heading="Diverging scheme"
options={DIVERGING_OPTIONS} options={DIVERGING_OPTIONS}
value={divScheme ?? undefined} value={divScheme ?? undefined}
onSelect={(name) => set(DIVERGING, name)} onSelect={(name) => set(DIVERGING, schemeRange(name))}
triggerContent={ triggerContent={
<> <>
<span> <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); 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) ─────────────────────── */ /* ── Controls + JSON (left) | gallery rail (right) ─────────────────────── */
/* The gallery takes the whole right side, full height; the controls column — /* The gallery takes the whole right side, full height; the controls column —
@@ -322,3 +297,20 @@
font-size: 11px; font-size: 11px;
color: var(--text-secondary); 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 { THEME_PREVIEW_SPECS } from '@core/theme-preview-specs';
import { useAppStore } from '../stores/AppStore'; import { useAppStore } from '../stores/AppStore';
import { useCustomThemeStore } from '../stores/CustomThemeStore'; import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { renderSpec } from '../services/chart-renderer';
import { ThemeBuilderModal } from './ThemeBuilderModal'; import { ThemeBuilderModal } from './ThemeBuilderModal';
vi.mock('../services/chart-renderer', () => ({ vi.mock('../services/chart-renderer', () => ({ renderSpec: vi.fn() }));
renderSpec: vi.fn(() =>
Promise.resolve({ destroy() {}, resize() {}, toImageURL: () => Promise.resolve('') }), const okHandle = () => ({ destroy() {}, resize() {}, toImageURL: () => Promise.resolve('') });
),
}));
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -26,6 +25,8 @@ let root: Root;
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.mocked(renderSpec).mockReset();
vi.mocked(renderSpec).mockImplementation(() => Promise.resolve(okHandle()));
useCustomThemeStore.getState().reset(); useCustomThemeStore.getState().reset();
useAppStore.getState().setChartTheme('astrolabe'); useAppStore.getState().setChartTheme('astrolabe');
container = document.createElement('div'); container = document.createElement('div');
@@ -110,6 +111,20 @@ describe('ThemeBuilderModal', () => {
expect(container.textContent).toContain('Invalid JSON'); 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', () => { test('selecting another theme from the list swaps the draft', () => {
renderModal(); renderModal();
act(() => { act(() => {
+38 -52
View File
@@ -12,9 +12,9 @@
*/ */
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
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';
import { normalizeRangeSchemes } from '@core/theme-controls';
import { chartConfigForSelection, chartThemeOptions } from '@core/vega-themes'; import { chartConfigForSelection, chartThemeOptions } from '@core/vega-themes';
import { renderSpec, type RenderHandle } from '../services/chart-renderer'; import { renderSpec, type RenderHandle } from '../services/chart-renderer';
import { useAppStore } from '../stores/AppStore'; import { useAppStore } from '../stores/AppStore';
@@ -26,29 +26,24 @@ import {
} from '../stores/CustomThemeStore'; } from '../stores/CustomThemeStore';
import { notify } from '../stores/NotificationStore'; import { notify } from '../stores/NotificationStore';
import { resnapshot } from '../modals/ModalCoordinator'; import { resnapshot } from '../modals/ModalCoordinator';
import { AxesControls } from './AxesControls';
import { Button } from './Button'; import { Button } from './Button';
import { ColorControls } from './ColorControls'; 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'; import styles from './ThemeBuilderModal.module.css';
/** Structured-control tabs, in display order (docs/chart-theming-scope.md §5). */ /** Structured-control tabs, in display order (docs/chart-theming-scope.md §5). */
const THEME_TABS = [ const THEME_TABS = [
{ id: 'color', label: 'Color' }, { id: 'color', label: 'Color' },
{ id: 'type', label: 'Type' }, { id: 'type', label: 'Type' },
{ id: 'layout', label: 'Layout' },
{ id: 'axes', label: 'Axes & grid' },
{ id: 'legend', label: 'Legend' },
] as const; ] as const;
type ThemeTab = (typeof THEME_TABS)[number]['id']; 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;
@@ -58,14 +53,17 @@ const GALLERY_DEBOUNCE = 250;
* modal; raster is invisible at swatch size (same trade-off as the Chart * modal; raster is invisible at swatch size (same trade-off as the Chart
* Builder preview). Renders are serialized per card with the LivePreview * 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 * 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 * shared host node. A render failure surfaces in the card as its message (the
* a preview aid; the config editor's parse error is the real feedback channel. * 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 }) { function GalleryCard({ card, config }: { card: ThemePreviewSpec; config: JsonObject }) {
const hostRef = useRef<HTMLDivElement>(null); const hostRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<RenderHandle | null>(null); const handleRef = useRef<RenderHandle | null>(null);
const generationRef = useRef(0); const generationRef = useRef(0);
const chainRef = useRef<Promise<void>>(Promise.resolve()); const chainRef = useRef<Promise<void>>(Promise.resolve());
const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const node = hostRef.current; const node = hostRef.current;
@@ -83,7 +81,10 @@ function GalleryCard({ card, config }: { card: ThemePreviewSpec; config: JsonObj
if (mine !== generationRef.current) return; if (mine !== generationRef.current) return;
handleRef.current?.destroy(); handleRef.current?.destroy();
handleRef.current = null; 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', renderer: 'canvas',
}); });
if (mine !== generationRef.current) { if (mine !== generationRef.current) {
@@ -91,8 +92,12 @@ function GalleryCard({ card, config }: { card: ThemePreviewSpec; config: JsonObj
return; return;
} }
handleRef.current = handle; handleRef.current = handle;
} catch { setError(null);
// Leave the card blank; the config editor reports the actionable error. } 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 { } finally {
release(); release();
} }
@@ -112,7 +117,12 @@ function GalleryCard({ card, config }: { card: ThemePreviewSpec; config: JsonObj
return ( return (
<figure className={styles.card}> <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> <figcaption className={styles.cardCaption}>{card.caption}</figcaption>
</figure> </figure>
); );
@@ -133,12 +143,6 @@ export function ThemeBuilderModal() {
// (it's the only place to fix the JSON). // (it's the only place to fix the JSON).
const [jsonExpanded, setJsonExpanded] = useState(false); const [jsonExpanded, setJsonExpanded] = useState(false);
const jsonOpen = jsonExpanded || parseError !== null; 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 // APG tabs: arrow/Home/End move selection, which follows focus (automatic
// activation — the panel swap is cheap). The portaled SelectControl popovers // activation — the panel swap is cheap). The portaled SelectControl popovers
@@ -302,35 +306,17 @@ export function ThemeBuilderModal() {
<p className={styles.controlsDisabled}> <p className={styles.controlsDisabled}>
Fix the JSON below to use these controls. Fix the JSON below to use these controls.
</p> </p>
) : activeTab === 'color' && draftConfig !== null ? ( ) : draftConfig === null ? null : activeTab === 'color' ? (
<ColorControls config={draftConfig} /> <ColorControls config={draftConfig} />
) : activeTab === 'type' ? ( ) : activeTab === 'type' ? (
<div className={styles.typePanel}> <TypeControls config={draftConfig} />
<h4 className={styles.typeTitle}>Font family</h4> ) : activeTab === 'layout' ? (
<p className={styles.typeHint}> <LayoutControls config={draftConfig} />
Writes one family into every font slot of the config. ) : activeTab === 'axes' ? (
</p> <AxesControls config={draftConfig} />
<SelectControl ) : (
id="theme-builder-font" <LegendControls config={draftConfig} />
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> </div>
{/* Raw JSON — collapsed by default (the structured controls are the {/* 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 { applyFontToConfig, createCustomTheme, type CustomTheme } from '@core/custom-theme';
import { isNameTaken, makeUniqueName } from '@core/naming'; import { isNameTaken, makeUniqueName } from '@core/naming';
import { isJsonObject, type JsonObject } from '@core/spec-config'; import { isJsonObject, type JsonObject } from '@core/spec-config';
import { type ConfigPath, setConfigValue } from '@core/theme-controls';
import { customThemeSelection } from '@core/vega-themes'; import { customThemeSelection } from '@core/vega-themes';
import { useAppStore } from './AppStore'; 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. */ /** Selector: the saved record the builder has open, or null. Derive — never store. */
export const selectSelectedTheme = (s: CustomThemeState): CustomTheme | null => export const selectSelectedTheme = (s: CustomThemeState): CustomTheme | null =>
s.themes.find((t) => t.id === s.selectedId) ?? null; s.themes.find((t) => t.id === s.selectedId) ?? null;
+61
View File
@@ -1,7 +1,11 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
THEME_SCHEMES, THEME_SCHEMES,
asBoolean,
asNumber,
asString,
getConfigValue, getConfigValue,
normalizeRangeSchemes,
schemeColors, schemeColors,
schemesByKind, schemesByKind,
setConfigValue, 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', () => { describe('schemeColors', () => {
it('returns the full fixed palette for a categorical scheme (count ignored)', () => { it('returns the full fixed palette for a categorical scheme (count ignored)', () => {
const colors = schemeColors('tableau10', 3); const colors = schemeColors('tableau10', 3);
+37
View File
@@ -76,6 +76,43 @@ function omitKey(obj: JsonObject, key: string): JsonObject {
return rest; 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 ───────────────────────────────────────────────────── // ── Named color schemes ─────────────────────────────────────────────────────
type SchemeKind = 'categorical' | 'sequential' | 'diverging'; 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. // tree via vega-embed). Pure data — config objects only — so portable for core.
import * as presets from 'vega-themes'; import * as presets from 'vega-themes';
import type { CustomTheme } from './custom-theme'; import type { CustomTheme } from './custom-theme';
import { normalizeRangeSchemes } from './theme-controls';
import type { UiTheme } from './theme'; import type { UiTheme } from './theme';
const PLEX = '"IBM Plex Sans", system-ui, -apple-system, sans-serif'; const PLEX = '"IBM Plex Sans", system-ui, -apple-system, sans-serif';
@@ -298,7 +299,10 @@ export function chartConfigForSelection(
const customId = customThemeIdOf(selection); const customId = customThemeIdOf(selection);
if (customId !== null) { if (customId !== null) {
const theme = customThemes.find((t) => t.id === customId); 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 === 'astrolabe') return CHART_CONFIG[uiTheme];
if (selection === 'stock') return STOCK_CONFIG; if (selection === 'stock') return STOCK_CONFIG;