mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Theme Builder: Layout/Axes/Legend/Type panels, scheme-render fix, fail-loud gallery
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user