diff --git a/docs/architecture/05-rendering-theming-preview.md b/docs/architecture/05-rendering-theming-preview.md index 41bf52b..2e69198 100644 --- a/docs/architecture/05-rendering-theming-preview.md +++ b/docs/architecture/05-rendering-theming-preview.md @@ -139,6 +139,17 @@ async function rerender(node: HTMLElement, spec: TopLevelSpec, config: Config) { **render-size** limit (the chart is physically too big), distinct from the readability cardinality warnings — don't conflate them. Only an _unbounded_ axis overflows: a `width: 'container'` axis is bounded, so it's the deleted (natural-height) axis to watch. +- **Load fonts before rendering.** Vega measures every text label via canvas + `measureText` **regardless of renderer** (even the `'none'` probe runs layout), + so a face that finishes loading after embed lays the whole chart out with + fallback metrics. `renderSpec` therefore gates on `document.fonts.load` for the + families a spec+config reference (`collectFontFamilies`, core) before any layout + pass. This is a non-critical enhancement, so it waits on `allSettled` + a + timeout: a face failing (offline, 404, a system family with no `@font-face`) + degrades to fallback metrics rather than failing the chart (the sanctioned + swallow under §7's fail-loud rule). Chart fonts are self-hosted in + `styles/chart-fonts.css` (offered by the Theme Builder's font control); only + their latin subsets are precached, the rest runtime-cached (vite.config Workbox). - **Do** call `view.finalize()` on every previous view before rendering a new one, and on component unmount. - **Do** keep exactly one live view per preview node. @@ -218,7 +229,9 @@ The gallery (`core/theme-preview-specs.ts`, fixed inline-data swatch specs) renders `draftConfig` per card through the shared `renderSpec` with the **canvas** renderer and a per-card debounce + chain-lock (the LivePreview serialization pattern, one lock per card) — so invalid JSON mid-edit never -blanks the preview, and seven concurrent embeds never interleave on a node. +blanks the preview, and seven concurrent embeds never interleave on a node. A +card whose render throws shows the error message in place of the chart (the same +fail-loud treatment as LivePreview, §7), never a silent blank. `applyFontToConfig(config, family)` is the font control's transform: it sets the top-level `font` and rewrites every `font`/`*Font` string slot at any depth — explicit slots would otherwise keep overriding the new default. @@ -238,25 +251,42 @@ that boundary deliberately: merge bakes the selected theme into `spec.config` ### Structured controls -The builder's panels (`ColorControls`; the Type tab's font control) are -accelerators over the same `draftConfig`: each reads a value and writes one back -through `CustomThemeStore.mutateDraftConfig(fn)` — the single transform path, -which reparses, reformats, and updates `draftConfig` so the JSON editor and -gallery follow (a parse error disables the controls). The pure transforms live -in `core/theme-controls.ts`: immutable config path get/set, the named-scheme -catalog (`THEME_SCHEMES`), and `schemeColors` (scheme name → hex swatches, from -the `vega-scale` registry — a focused vega sub-package). A color family holds -**either** a named scheme string **or** an explicit color array; the picker -materializes one to the other. Family by scale: `range.category` (nominal), -`range.ramp` (continuous; `range.heatmap` for `rect`), `range.diverging` -(continuous color with a `domainMid`). +The builder's panels — Color, Type, Layout, Axes & grid, Legend +(`ColorControls` + `TypeControls`/`LayoutControls`/`AxesControls`/`LegendControls` +on the shared `ThemeFields` field primitives) — are accelerators over the same +`draftConfig`: each reads a value and writes one back through +`CustomThemeStore.mutateDraftConfig(fn)` — the single transform path, which +reparses, reformats, and updates `draftConfig` so the JSON editor and gallery +follow (a parse error disables the controls). The pure transforms live in +`core/theme-controls.ts`: immutable config path get/set, leaf coercion, the +named-scheme catalog (`THEME_SCHEMES`), and `schemeColors` (scheme name → hex +swatches, from the `vega-scale` registry — a focused vega sub-package). A color +family holds **either** a named scheme as Vega's range-scheme **object** +`{ scheme: name }` **or** an explicit color array; the picker materializes one to +the other. Family by scale: `range.category` (nominal), `range.ramp` (continuous; +`range.heatmap` for `rect`), `range.diverging` (continuous color with a +`domainMid`). -- **Do** route structured edits through `mutateDraftConfig` + `setConfigValue`, - which sets a value at a path **immutably, preserving sibling keys**, and - deletes (pruning emptied ancestors) on `undefined` so a theme stays a diff. +Repeated control labels across panels ("Size", "Color", "Weight") get a +qualified accessible name while keeping the short visible label; the section is a +`role="group"` labelled by its heading (APG group pattern), so the name a screen +reader announces is unambiguous. + +- **Do** bind a panel's writes to the shared `useConfigSetter()` hook (in + `CustomThemeStore` — beside `mutateDraftConfig`, not the JSX field module, which + stays component-only for fast refresh). It is `mutateDraftConfig` + `setConfigValue`: + sets a value at a path **immutably, preserving sibling keys**, and deletes + (pruning emptied ancestors) on `undefined` so a theme stays a diff. A new panel + uses it rather than re-inlining the pair. - **Don't** rebuild the config from a fixed schema: vega-themes presets carry Vega-_layer_ keys (`symbol`/`shape`/`path`/`group`) absent from the Vega-Lite `Config` schema but forwarded to Vega — a rebuild drops them. Merge in place. +- **Do** write a named scheme into `range.*` as the object `{ scheme: name }`. A + bare scheme-name string passes vega-lite _compile_ but Vega rejects it at + _render_ ("Unrecognized scale range value"), blanking the chart. + `normalizeRangeSchemes` (core) heals the bare form at the render-resolution + points (`chartConfigForSelection`; the builder gallery) for configs authored or + saved before this was enforced. - **Do** add a `theme-preview-specs.ts` gallery card for any new color family, so no control ships without a visible mirror. diff --git a/docs/chart-theming-scope.md b/docs/chart-theming-scope.md index 9c53f26..13eb9e9 100644 --- a/docs/chart-theming-scope.md +++ b/docs/chart-theming-scope.md @@ -154,9 +154,14 @@ ships, it is an explicit per-font user action, never automatic. active one falls back to Astrolabe. Custom themes travel in the §08 workspace export/import envelope (additive `themes` array, name auto-suffix on clash, ids reassigned by the store, rolled back with datasets on a failed import). -5. **Shipped font roster** — fontsource packages, `@font-face` registration, selector - metadata (which themes/fonts pair), `document.fonts.load` gate in the render path, - precache strategy above. Roster finalized via visual specimen. +5. **Shipped font roster** ✅ (2026-06-14) — 11 self-hosted families via @fontsource + (`styles/chart-fonts.css`, full subsets bundled) extending `THEME_FONT_OPTIONS` to 17 + entries; `collectFontFamilies` (core) + a `document.fonts.load` gate at the top of + `renderSpec` (before the layout/probe pass, which measures text regardless of + renderer); Workbox precaches the `latin` subset of the roster (~520KB) plus every + subset of the UI Plex Sans/Mono, and runtime-caches the rest (latin-ext + non-latin) + CacheFirst so a script works offline after first use. Roster picked from a visual + specimen. Not done here: theme↔font pairing metadata (a suggestion nicety, deferred). 6. **User font upload** — FontFace-from-IndexedDB tier; theme entity's `fonts` field carries `{ family, source: 'file' }`. 7. **Deferred** — Google Fonts opt-in tier; SVG export font embedding; built-in @@ -188,7 +193,10 @@ schema-typed model, or it silently drops those keys on a round-trip. Same shape one xlarge modal. No nested overlays/focus traps, no contention with the one-open-popover registry, and panels + JSON + gallery stay visible together. - **Color model — scheme picker that materializes to swatches.** A `range` family takes - either an explicit color array or a named Vega scheme string (both verified to compile). + either an explicit color array or a named scheme written as Vega's range-scheme object + `{ scheme: name }`. (A bare scheme-name _string_ passes vega-lite compile but Vega + rejects it at render — "Unrecognized scale range value" — so the controls write the + object form, read either, and `normalizeRangeSchemes` heals the bare form at render.) Pick a named scheme for the quick path; "materialize" expands it to an editable swatch array for brand tuning. Catalog ships 15 categorical + 24 sequential + 10 diverging schemes; categorical schemes resolve to arrays, continuous ones to interpolators sampled @@ -199,11 +207,11 @@ schema-typed model, or it silently drops those keys on a round-trip. Same shape a default, so a theme stays a diff against stock, not a full dump. **Panels:** Color (`range.category` swatches/scheme, `mark.color`, `range.heatmap`/`ramp`/ -`diverging`) · Type (base `font`, title/axis/legend size+weight) · Layout (`background` incl. -transparent, `padding`, `view.stroke`/`fill`/cornerRadius) · Axes & grid (grid on/off + color - -- dash, domain, label color/angle — base `axis` only; the 25 variants stay JSON) · Legend - (orient, label/title color+size, symbol size). +`diverging`) · Type (base `font`, title + axis title/label size+weight) · Layout (`background` +incl. transparent, `padding`, `view.stroke`/`fill`/cornerRadius) · Axes & grid (grid on/off + +color + dash, domain, label color/angle, title color — base `axis` only; the 25 variants stay +JSON) · Legend (orient, title/label color+size, symbol size). Legend _type_ (size) lives in +the Legend panel rather than Type, so every legend property a brand tunes sits together. **Build order:** (a) core foundation — scheme catalog + immutable config path get/set + `schemeColors` materialize, with tests; (b) Color panel (highest payoff); (c) Type, Layout, @@ -216,6 +224,60 @@ the app standardizes on v6. ## 6. Status log +- **2026-06-14 (Color panel bugfix)** — **scheme picks rendered blank.** A named scheme + was written into `config.range.*` as a bare string, which vega-lite compiles but Vega + rejects at render ("Unrecognized scale range value") — silently caught by the gallery's + per-card try/catch, so the categorical/sequential/diverging charts blanked the moment a + scheme was picked. Predates this session's panels/fonts (shipped with the Color panel). + Fix: the controls write Vega's range-scheme object `{ scheme: name }` and read either + form; `normalizeRangeSchemes` (core) heals a bare-form config at the render-resolution + points (`chartConfigForSelection` for the live preview/export, and the builder gallery), + so themes saved/imported with the old form self-heal. The gallery's catch now surfaces the + error message in the card (fail-loud, arch 02) so a render failure on valid JSON isn't + invisible again. Regression cover: a real + vega-lite→vega compile/parse/run asserting `{ scheme }` renders and the bare string + throws, plus `normalizeRangeSchemes` unit tests. Verified: typecheck, lint, tests (983). + +- **2026-06-14 (slice 5)** — **shipped font roster.** 11 self-hosted families + (`styles/chart-fonts.css`, imported in main.tsx, separate from the UI Plex in base.css): + Inter · Libre Franklin · Roboto Condensed · IBM Plex Sans Condensed · IBM Plex Serif · + Source Serif 4 · Spectral · Space Grotesk · Playfair Display · Caveat · Space Mono, at + 400 + 600 (Space Mono 400 + 700). `THEME_FONT_OPTIONS` grew to 17 (roster grouped by + role, then the system stacks); each roster stack carries a category fallback. The render + path now gates on fonts: `collectFontFamilies` (core, the read-counterpart of + `applyFontToConfig`; skips `data`/`datasets`) gathers the families a spec+config use and + `renderSpec` awaits `document.fonts.load` for them before the first layout pass — Vega + measures text via canvas `measureText` regardless of renderer, so a face loading after + embed would lay out with fallback metrics. Best-effort + 3s-capped so a slow first fetch + never freezes the preview. Precache strategy (vite.config Workbox): the `latin` subset + of every family (~520KB for the roster) + all Plex Sans/Mono subsets (UI capability) are + precached; latin-ext and non-latin scripts are runtime-cached CacheFirst (`*-latin-[0-9]*` + excludes latin-ext; the Plex Sans brace-list avoids matching the condensed roster font). + Verified: typecheck, lint, full tests (976), production build + precache-manifest + inspection. Note: @fontsource ships legacy `.woff` beside `.woff2`; modern browsers use + woff2, so the `.woff` sit unused in dist (pre-existing for Plex — neither precached nor + runtime-cached). + +- **2026-06-14 (slice 4b complete)** — **Layout / Axes & grid / Legend panels + Type + size/weight.** The remaining structured-control panels, built on a small shared + primitives module `ThemeFields.tsx` (`ControlSection`, `ColorRow`, `NumberRow`, + `SelectRow`) so the panels read declaratively and match the Color panel's look. Each + control writes one config path through the same inline `mutateDraftConfig` + + `setConfigValue` the Color panel uses, with the minimal-diff delete (clearing a value + removes the key, pruning emptied objects). Leaf coercion (`asString`/`asNumber`/ + `asBoolean`) moved into core `theme-controls.ts` beside the path get/set, tested there. + Panels: **Layout** (background and `view` fill/border as tri-state default·transparent/ + none·custom, corner radius, scalar padding with a JSON hint when it's a per-side object); + **Axes & grid** (grid visibility/color/dash-preset, domain/label/title color, label + angle — base `axis` only); **Legend** (orient, title/label color+size, symbol size); + **Type** rounded out with title and axis title/label size+weight (font family relocated + into the extracted `TypeControls`). Resolved while building: each generic row label + ("Size", "Color", "Weight") repeats across sections, so `ControlSection` is a + `role="group"` labelled by its heading and rows take an accessible-name override — the + visible label stays short, the control's announced name is qualified ("Title size"). The + font-roster decision (slice 5) was teed up with a throwaway visual specimen. Verified: + typecheck, lint, full tests (969). Remaining in 4b: swatch reorder (Color panel). + - **2026-06-14 (slice 4b, first increment)** — **structured-control foundation + Color panel.** Core `theme-controls.ts`: immutable config path get/set (preserves siblings — the Vega-layer-key guarantee — and prunes on delete) + the named-scheme catalog (15 diff --git a/src/app/components/AxesControls.tsx b/src/app/components/AxesControls.tsx new file mode 100644 index 0000000..0c6863e --- /dev/null +++ b/src/app/components/AxesControls.tsx @@ -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[] = [ + { 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[] = [ + { value: '', label: 'Theme default' }, + { value: 'solid', label: 'Solid' }, + { value: 'dotted', label: 'Dotted' }, + { value: 'dashed', label: 'Dashed' }, +]; +const DASH_VALUES: Record, 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 ( +
+ + set(GRID, s === '' ? undefined : s === 'true')} + /> + set(GRID_COLOR, hex)} + onClear={() => set(GRID_COLOR, undefined)} + /> + + set(GRID_DASH, s === '' ? undefined : DASH_VALUES[s as keyof typeof DASH_VALUES]) + } + /> + + + + set(DOMAIN_COLOR, hex)} + onClear={() => set(DOMAIN_COLOR, undefined)} + /> + set(LABEL_COLOR, hex)} + onClear={() => set(LABEL_COLOR, undefined)} + /> + set(LABEL_ANGLE, n)} + /> + set(TITLE_COLOR, hex)} + onClear={() => set(TITLE_COLOR, undefined)} + /> + +
+ ); +} diff --git a/src/app/components/ColorControls.test.tsx b/src/app/components/ColorControls.test.tsx index d7dbb76..8edc590 100644 --- a/src/app/components/ColorControls.test.tsx +++ b/src/app/components/ColorControls.test.tsx @@ -170,25 +170,26 @@ describe('ColorControls', () => { expect(tableau.querySelectorAll('span span').length).toBeGreaterThan(0); }); - test('picking a scheme writes range.category as a string', () => { + test('picking a scheme writes range.category as a Vega scheme object', () => { render(); open({}); act(() => picker('Categorical color scheme')!.click()); act(() => button('Category 10')!.click()); - expect(range().category).toBe('category10'); + // The `{ scheme }` object — a bare scheme-name string is rejected at render. + expect(range().category).toEqual({ scheme: 'category10' }); }); - test('a sequential pick sets both heatmap and ramp', () => { + test('a sequential pick sets both heatmap and ramp to the scheme object', () => { render(); open({}); act(() => picker('Sequential color scheme')!.click()); act(() => button('Viridis')!.click()); - expect(range().heatmap).toBe('viridis'); - expect(range().ramp).toBe('viridis'); + expect(range().heatmap).toEqual({ scheme: 'viridis' }); + expect(range().ramp).toEqual({ scheme: 'viridis' }); }); test('invalid JSON disables the controls', () => { diff --git a/src/app/components/ColorControls.tsx b/src/app/components/ColorControls.tsx index 0b7056e..b58113e 100644 --- a/src/app/components/ColorControls.tsx +++ b/src/app/components/ColorControls.tsx @@ -8,29 +8,25 @@ * and the gallery follow; reads come from the parsed draft config, so a JSON * hand-edit reflects straight back into the controls. * - * Color model (§5): every family holds either a named Vega scheme (compact, the - * quick path) or an explicit color array (custom tuning). Picking a scheme from - * the preview-bearing dropdown writes the name; "Materialize" expands it to an - * editable array of swatches — categorical, sequential, and diverging alike. + * Color model (§5): every family holds either a named Vega scheme as the + * range-scheme object `{ scheme: name }` (compact, the quick path) or an explicit + * color array (custom tuning). Picking a scheme from the preview-bearing dropdown + * writes that object (a bare string is rejected by Vega at render); "Materialize" + * expands it to an editable array of swatches — categorical, sequential, and + * diverging alike. * Each swatch pairs the native color picker with a hex text field, so a value * can be read, copied, and retyped anywhere. */ import type { ReactNode } from 'react'; import type { JsonObject } from '@core/spec-config'; -import { - type ConfigPath, - getConfigValue, - schemeColors, - schemesByKind, - setConfigValue, -} from '@core/theme-controls'; -import { useCustomThemeStore } from '../stores/CustomThemeStore'; +import { type ConfigPath, getConfigValue, schemeColors, schemesByKind } from '@core/theme-controls'; import { Button } from './Button'; import { ColorField } from './ColorField'; import { Icon } from './Icon'; import { IconButton } from './IconButton'; import { SelectControl, type SelectControlOption } from './SelectControl'; +import { useConfigSetter } from '../stores/CustomThemeStore'; import styles from './ColorControls.module.css'; const CATEGORY: ConfigPath = ['range', 'category']; @@ -53,6 +49,22 @@ const asArray = (v: unknown): string[] | null => Array.isArray(v) && v.every((c) => typeof c === 'string') ? v : null; const asString = (v: unknown): string | null => (typeof v === 'string' ? v : null); +/** + * A named scheme is stored in a `range` family as Vega's range-scheme object + * `{ scheme: name }`. A bare scheme-name string compiles but is rejected by Vega + * at render ("Unrecognized scale range value: …"), blanking the chart — so reads + * accept either the object or a legacy/hand-authored bare string, while writes + * (`schemeRange`) always use the object form. + */ +const asScheme = (v: unknown): string | null => { + if (typeof v === 'string') return v; + if (v && typeof v === 'object' && typeof (v as { scheme?: unknown }).scheme === 'string') { + return (v as { scheme: string }).scheme; + } + return null; +}; +const schemeRange = (name: string): { scheme: string } => ({ scheme: name }); + const gradientCss = (colors: string[]): string => colors.length ? `linear-gradient(90deg, ${colors.join(', ')})` : 'transparent'; @@ -117,25 +129,26 @@ function SwatchRow({ } export function ColorControls({ config }: { config: JsonObject }) { - const mutate = useCustomThemeStore((s) => s.mutateDraftConfig); - const set = (path: ConfigPath, value: unknown) => mutate((c) => setConfigValue(c, path, value)); + const set = useConfigSetter(); // Sequential color lives in two slots (heatmaps + continuous legends); keep them together. - const setSeq = (value: unknown) => - mutate((c) => setConfigValue(setConfigValue(c, HEATMAP, value), RAMP, value)); + const setSeq = (value: unknown) => { + set(HEATMAP, value); + set(RAMP, value); + }; const catValue = getConfigValue(config, CATEGORY); const catArray = asArray(catValue); - const catScheme = asString(catValue); + const catScheme = asScheme(catValue); const markColor = asString(getConfigValue(config, MARK_COLOR)); const seqValue = getConfigValue(config, HEATMAP); const seqArray = asArray(seqValue); - const seqScheme = asString(seqValue); + const seqScheme = asScheme(seqValue); const divValue = getConfigValue(config, DIVERGING); const divArray = asArray(divValue); - const divScheme = asString(divValue); + const divScheme = asScheme(divValue); /** Stops to drive a gradient preview for a family in any of its states. */ const previewStops = (array: string[] | null, scheme: string | null): string[] => @@ -155,7 +168,7 @@ export function ColorControls({ config }: { config: JsonObject }) { heading="Color scheme" options={CATEGORICAL_OPTIONS} value={catScheme ?? undefined} - onSelect={(name) => set(CATEGORY, name)} + onSelect={(name) => set(CATEGORY, schemeRange(name))} triggerContent={ <>