From d76a7a50144e5cd7cbcba925f2869c7b24b89e2e Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Sun, 21 Jun 2026 17:14:58 +0300 Subject: [PATCH] Theme Builder: accordion panels across the full config surface, vertical-tab nav, reflective gallery --- .../05-rendering-theming-preview.md | 29 +- .../10-interaction-and-feedback.md | 25 + docs/exploration/chart-theming-scope.md | 46 +- docs/spec/04-live-preview.md | 7 +- src/app/components/AxesControls.tsx | 230 ++++++--- src/app/components/ColorControls.module.css | 24 +- src/app/components/ColorControls.tsx | 485 +++++++++--------- src/app/components/FormatControls.tsx | 124 +++++ src/app/components/HeaderControls.tsx | 104 ++++ src/app/components/LayoutControls.tsx | 206 +++++--- src/app/components/LegendControls.tsx | 296 ++++++++--- src/app/components/MarksControls.tsx | 347 +++++++++++++ .../components/ThemeBuilderModal.module.css | 43 +- src/app/components/ThemeBuilderModal.tsx | 125 +++-- .../components/ThemeControlPanels.test.tsx | 159 +++++- src/app/components/ThemeFields.module.css | 122 ++++- src/app/components/ThemeFields.tsx | 254 +++++++-- src/app/components/TitleControls.tsx | 177 +++++++ src/app/components/TypeControls.tsx | 290 +++++------ src/core/custom-theme.test.ts | 30 ++ src/core/theme-controls.test.ts | 37 ++ src/core/theme-controls.ts | 22 + src/core/theme-preview-specs.ts | 75 ++- src/landing/demo-themes.ts | 8 + 24 files changed, 2420 insertions(+), 845 deletions(-) create mode 100644 src/app/components/FormatControls.tsx create mode 100644 src/app/components/HeaderControls.tsx create mode 100644 src/app/components/MarksControls.tsx create mode 100644 src/app/components/TitleControls.tsx diff --git a/docs/architecture/05-rendering-theming-preview.md b/docs/architecture/05-rendering-theming-preview.md index 006057d..432fd4e 100644 --- a/docs/architecture/05-rendering-theming-preview.md +++ b/docs/architecture/05-rendering-theming-preview.md @@ -303,13 +303,19 @@ width rather than a variable font's possibly-condensed default instance. ### Structured controls -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 +The builder's panels — Color, Marks, Type, Title, Layout, Axes & grid, Legend, +Headers, Formats — are accelerators over the same `draftConfig`, one panel per +config domain (each an `XxxControls` component on the shared `ThemeFields` +primitives). `ThemeBuilderModal` holds the single `id → label → Panel` registry +(`THEME_TABS`); panels switch via a **vertical tab list** (APG vertical tabs), +and each panel groups its properties into a **single-expand accordion** +(`ThemeFields` `Accordion` — one section open at a time, each with a set-count +badge so customized sections are scannable while collapsed). Each control 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 raw +JSON below the panels is the full-power escape hatch for the long tail the +structured controls deliberately omit. 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 @@ -339,8 +345,13 @@ reader announces is unambiguous. `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. +- **Do** give every control a visible mirror in the `theme-preview-specs.ts` + gallery, and keep those sample specs on **bare marks** (`mark: 'point'`, not + `{ type: 'point', size: 80 }`): a property hard-coded in a spec overrides the + injected config, making the matching control a no-op in the preview. Mark + styling belongs in the config (a theme), never inline in a card. Default chart + size and tooltips are the unavoidable exceptions — fixed-size swatches, and + hover-only respectively. ### Rules diff --git a/docs/architecture/10-interaction-and-feedback.md b/docs/architecture/10-interaction-and-feedback.md index 9b96308..bca4876 100644 --- a/docs/architecture/10-interaction-and-feedback.md +++ b/docs/architecture/10-interaction-and-feedback.md @@ -660,6 +660,31 @@ without clipping, wrapping, or crowding. --- +## 9. Organizing a large control surface (two levels) + +A control surface too big for one scroll (the Theme Builder spans most of the +Vega-Lite config) is organized in two levels, each with a settled widget so the +choice isn't re-litigated per surface: + +- **Level 1 — switch by domain with tabs.** Mutually-exclusive top-level + categories (Color, Marks, Type, …) are an APG **tab set**, one panel visible at + a time. Past a handful of tabs a horizontal strip wraps raggedly and the active + tab shifts rows; a **vertical tab list** (`aria-orientation="vertical"`, Up/Down + - Home/End) scales without wrapping and keeps the active panel anchored. Never + **nest** tab sets — two roving tablists collide. +- **Level 2 — group within a panel by how it's read.** Sub-groups a user reads in + full get **flat headings** (`role="group"` labelled by the heading). Sub-groups + where a user tunes one or two and skips the rest get a **single-expand + accordion** (APG accordion — heading-button toggles a `role="region"`; Up/Down + between headers) — Carbon's rule: accordion is for content "not crucial to read + in full." A per-section **modified badge** (count of set properties) keeps an + override scannable while collapsed (NN/g #6, recognition). + +_(Consulted via /council → WAI-ARIA APG tabs/accordion/disclosure, IBM Carbon +accordion usage, NN/g #6/#8.)_ + +--- + ## Do / Don't **Do** diff --git a/docs/exploration/chart-theming-scope.md b/docs/exploration/chart-theming-scope.md index 7546eec..da3a9e6 100644 --- a/docs/exploration/chart-theming-scope.md +++ b/docs/exploration/chart-theming-scope.md @@ -205,10 +205,12 @@ schema-typed model, or it silently drops those keys on a round-trip. Same shape **Resolved design points:** -- **Surfacing — inline tab strip** (not popovers, not sub-modals). Tabs (Color / Type / - Layout / Axes & grid / Legend) sit between the toolbar and the JSON+gallery, all in the - one xlarge modal. No nested overlays/focus traps, no contention with the one-open-popover - registry, and panels + JSON + gallery stay visible together. +- **Surfacing — vertical tab list + per-panel accordion** (not popovers, not sub-modals), + all in the one xlarge modal. One panel per config domain, switched by a vertical tab rail + (L1); within a panel, related properties group into a single-expand accordion (L2). No + nested overlays/focus traps, no contention with the one-open-popover registry; panel + JSON + - gallery stay visible together. The two-level grouping rule is recorded in arch 10 + (_Organizing a large control surface_). - **Color model — scheme picker that materializes to swatches.** A `range` family takes 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 @@ -223,24 +225,34 @@ schema-typed model, or it silently drops those keys on a round-trip. Same shape - Controls write **minimal** config — clearing a value deletes the key rather than writing a default, so a theme stays a diff against stock, not a full dump. -**Panels:** Color (`range.category` swatches/scheme, `mark.color`, `range.heatmap`/`ramp`/ -`diverging`) · Type (base `font`, title + axis 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. +**Panels** (each an accordion of the groups below): Color (`range.category`, `mark.color`, +sequential `range.heatmap`/`ramp`, `range.diverging`) · Marks (per type — bars, lines & +areas, points, arc — plus generic opacity/fill/tooltips) · Type (base `font`, axis +title/label size+weight) · Title (anchor, colour, size/weight/style, offset, subtitle block) +· Layout (`background`, `view` fill/border/radius, `padding`, default size) · Axes & grid +(grid, ticks, domain, labels — base `axis` only; the per-channel variants stay JSON) · Legend +(placement/direction, title, labels, symbols, gradient, box) · Headers (facet title/label +colour/size/weight) · Formats (number/date/normalized formats, count title). **Build order:** (a) core foundation — scheme catalog + immutable config path get/set + -`schemeColors` materialize, with tests; (b) Color panel (highest payoff); (c) Type, Layout, -Axes, Legend panels; (d) wire the tab strip into the modal. - -**Not in slice 4b:** the house style's own gaps — no `mark.color` (single-series charts stay -Vega-blue), unset legend/header/padding — are left for a separate house-style redo, not -papered over here. Minor cleanup noted: `theme-preview-specs.ts` declares `$schema` v5 while -the app standardizes on v6. +`schemeColors` materialize, with tests; (b) Color panel (highest payoff); (c) the remaining +per-domain panels; (d) wire the navigation (vertical tabs + per-panel accordion) into the +modal. ## 6. Status log +- **2026-06-21** — **structured-control surface expanded + accordion everywhere.** New panels + — Marks (per mark type), Title & subtitle, Headers (facets), Formats — and deepened + Axes/Legend/Layout, covering the brand-tuning bulk of the config; the raw JSON stays the + escape hatch for the long tail. Every panel is a single-expand accordion with a per-section + set-count badge, switched by a vertical tab list, and the modal holds one + `id → label → Panel` registry. Shared primitives extracted: `WeightRow` (UI), + `enumValue`/`countSet` (core). The gallery is now fully reflective — sample specs use bare + marks so no control is shadowed (guarded by a test), and a normalized area + a temporal + facet were added so `normalizedNumberFormat` and `timeFormat` have mirrors; default chart + size and tooltips are the only non-previewable controls. Recorded in arch 10 (_Organizing a + large control surface_), arch 05 (_Structured controls_), and spec §04. Remaining in §4: + Google Fonts opt-in tier; built-in preset gallery; Color-panel swatch reorder. - **2026-06-16 (slice 7)** — **font export round-trip + SVG embed.** Uploaded faces now survive a workspace transfer and travel inside an exported SVG. Core: `serializeFontAsset`/ `deserializeFontAsset` (+ base64 helpers), `primaryFamilyName`, and `fontDataUri` in diff --git a/docs/spec/04-live-preview.md b/docs/spec/04-live-preview.md index bb12e92..543d90f 100644 --- a/docs/spec/04-live-preview.md +++ b/docs/spec/04-live-preview.md @@ -61,9 +61,10 @@ The **Theme Builder** is a full-size modal for creating and editing custom chart Layout: a saved-theme list on the left; the open theme's editor on the right. - **New theme** creates a theme seeded as a **copy of the chart theme currently selected** in the preview (house style, stock, a preset, or another custom theme), named after its source (e.g. "FiveThirtyEight copy") and auto-suffixed if taken. Duplicating a preset is the expected starting point. The other creation path is the editor's **Extract Config to New Theme** action (see _Spec Editor → Spec ↔ Config Actions_), which turns a pasted spec's `config` block into a theme directly. -- The editor shows the theme's **name** and its **config as editable JSON text**. Invalid JSON is reported inline and blocks saving; the text must parse to a JSON object. -- A **font control** applies a chosen font family across the whole config in one step: it sets the top-level `font` (Vega-Lite's default for every text mark, label, and title) and rewrites every explicit `font`/`labelFont`/`titleFont`/`subtitleFont` slot anywhere in the config — the slots that would otherwise keep overriding the new default. Offered fonts are limited to faces that render without loading (the app's own Plex faces and web-safe/system stacks) until the self-hosted font roster ships. -- A **gallery** of small fixed sample charts (bar with title, multi-series line with subtitle, stacked area, scatter with a gradient legend, heatmap, donut, facets with headers) re-renders live from the draft config — the same config-injection path the preview uses — so one edit is previewed across every chart surface a config styles. While the JSON is invalid, the gallery keeps showing the last valid state. +- **Structured controls** organize the config into panels by domain — Color, Marks, Type, Title, Layout, Axes & grid, Legend, Headers, Formats — navigated by a vertical tab list, each panel's properties grouped into collapsible sections. A control writes one config property, and clearing it removes the key, so a theme stays a minimal diff against stock. The controls cover the common brand-tuning surface, not every Vega-Lite property. +- The theme's **name** and its full **config as editable JSON text** sit below the controls as the escape hatch for anything they don't expose. Invalid JSON is reported inline and blocks saving (and is the only editing surface while invalid); the text must parse to a JSON object. +- A **font control** applies a chosen font family across the whole config in one step: it sets the top-level `font` (Vega-Lite's default for every text mark, label, and title) and rewrites every explicit `font`/`labelFont`/`titleFont`/`subtitleFont` slot anywhere in the config — the slots that would otherwise keep overriding the new default. Offered fonts are the self-hosted roster plus any the user uploads (see _Data Model → FontAsset_), the user's own faces listed first. +- A **gallery** of small fixed sample charts re-renders live from the draft config — the same config-injection path the preview uses — spanning the mark types, the color families, faceting, and titled charts so one edit is previewed across every surface a config styles. **Every structured control has a visible mirror** in at least one card; the default chart size and tooltips are the exceptions (the cards are fixed-size; tooltips are hover-only). While the JSON is invalid, the gallery keeps the last valid state. - **Save** commits the draft (disabled while unchanged or unparseable). Names are unique case-insensitively, like dataset names. **Delete** removes the theme after confirmation. - Closing with unsaved edits prompts for discard, like other form modals. A backdrop click does not dismiss the builder (Escape and the close button do). diff --git a/src/app/components/AxesControls.tsx b/src/app/components/AxesControls.tsx index 0c6863e..e54567c 100644 --- a/src/app/components/AxesControls.tsx +++ b/src/app/components/AxesControls.tsx @@ -1,11 +1,10 @@ /** * 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. + * Structured controls over the base `axis` config only — grid, ticks, the domain + * line, labels, and title. 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'; @@ -14,31 +13,38 @@ import { asNumber, asString, type ConfigPath, + countSet, getConfigValue, } from '@core/theme-controls'; import { useConfigSetter } from '../stores/CustomThemeStore'; -import { ColorRow, ControlSection, NumberRow, SelectRow } from './ThemeFields'; +import { Accordion, type AccordionSection, ColorRow, 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 GRID_WIDTH: ConfigPath = ['axis', 'gridWidth']; const DOMAIN_COLOR: ConfigPath = ['axis', 'domainColor']; +const DOMAIN_WIDTH: ConfigPath = ['axis', 'domainWidth']; const LABEL_COLOR: ConfigPath = ['axis', 'labelColor']; const LABEL_ANGLE: ConfigPath = ['axis', 'labelAngle']; +const LABEL_PADDING: ConfigPath = ['axis', 'labelPadding']; const TITLE_COLOR: ConfigPath = ['axis', 'titleColor']; +const TICKS: ConfigPath = ['axis', 'ticks']; +const TICK_COLOR: ConfigPath = ['axis', 'tickColor']; +const TICK_SIZE: ConfigPath = ['axis', 'tickSize']; const GRID_GREY = '#888888'; -// Grid visibility: tri-state (theme default · shown · hidden) over a boolean. -type GridState = '' | 'true' | 'false'; -const gridOptions: SelectControlOption[] = [ +// Visibility: tri-state (theme default · shown · hidden) over a boolean — shared +// by grid lines and ticks (both `boolean | undefined` config keys). +type ShowState = '' | 'true' | 'false'; +const showOptions: SelectControlOption[] = [ { value: '', label: 'Theme default' }, { value: 'true', label: 'Shown' }, { value: 'false', label: 'Hidden' }, ]; -const gridState = (v: boolean | undefined): GridState => +const showState = (v: boolean | undefined): ShowState => v === undefined ? '' : v ? 'true' : 'false'; // Dash presets, matched by array shape; an unrecognised array reads as no preset @@ -67,73 +73,139 @@ const dashStyle = (v: unknown): DashStyle => { 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)); + const sections: AccordionSection[] = [ + { + id: 'grid', + title: 'Grid', + hint: 'Reference lines behind the marks.', + badge: countSet(config, [GRID, GRID_COLOR, GRID_DASH, GRID_WIDTH]), + children: ( + <> + 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(GRID_WIDTH, n)} + /> + + ), + }, + { + id: 'ticks', + title: 'Ticks', + hint: 'The marks along the axis line.', + badge: countSet(config, [TICKS, TICK_COLOR, TICK_SIZE]), + children: ( + <> + set(TICKS, s === '' ? undefined : s === 'true')} + /> + set(TICK_COLOR, hex)} + onClear={() => set(TICK_COLOR, undefined)} + /> + set(TICK_SIZE, n)} + /> + + ), + }, + { + id: 'domain', + title: 'Domain & labels', + hint: 'The axis line, its tick labels, and title.', + badge: countSet(config, [ + DOMAIN_COLOR, + DOMAIN_WIDTH, + LABEL_COLOR, + LABEL_ANGLE, + LABEL_PADDING, + TITLE_COLOR, + ]), + children: ( + <> + set(DOMAIN_COLOR, hex)} + onClear={() => set(DOMAIN_COLOR, undefined)} + /> + set(DOMAIN_WIDTH, n)} + /> + set(LABEL_COLOR, hex)} + onClear={() => set(LABEL_COLOR, undefined)} + /> + set(LABEL_ANGLE, n)} + /> + set(LABEL_PADDING, n)} + /> + set(TITLE_COLOR, hex)} + onClear={() => set(TITLE_COLOR, undefined)} + /> + + ), + }, + ]; - 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)} - /> - -
- ); + return ; } diff --git a/src/app/components/ColorControls.module.css b/src/app/components/ColorControls.module.css index 44bc0e4..9f02337 100644 --- a/src/app/components/ColorControls.module.css +++ b/src/app/components/ColorControls.module.css @@ -1,23 +1,7 @@ -/* Theme Builder Color panel — structured controls over the draft config. */ - -.panel { - display: grid; - gap: var(--space-6); - padding: var(--space-5); - overflow: auto; -} - -.group { - display: grid; - gap: var(--space-3); -} - -.groupTitle { - margin: 0; - font-size: 13px; - font-weight: 600; - color: var(--text); -} +/* Theme Builder Color panel — structured controls over the draft config. + The container, section headings, and badges come from the shared Accordion + (ThemeFields); this module styles only the color-specific bits — swatch rows, + gradient/scheme previews, and the dropdown-option previews. */ .hint { margin: 0; diff --git a/src/app/components/ColorControls.tsx b/src/app/components/ColorControls.tsx index b58113e..9ae887b 100644 --- a/src/app/components/ColorControls.tsx +++ b/src/app/components/ColorControls.tsx @@ -20,13 +20,20 @@ import type { ReactNode } from 'react'; import type { JsonObject } from '@core/spec-config'; -import { type ConfigPath, getConfigValue, schemeColors, schemesByKind } from '@core/theme-controls'; +import { + type ConfigPath, + countSet, + 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 { Accordion, type AccordionSection } from './ThemeFields'; import styles from './ColorControls.module.css'; const CATEGORY: ConfigPath = ['range', 'category']; @@ -154,236 +161,252 @@ export function ColorControls({ config }: { config: JsonObject }) { const previewStops = (array: string[] | null, scheme: string | null): string[] => array ?? (scheme ? schemeColors(scheme, 9) : []); - return ( -
- {/* ── Categorical palette ─────────────────────────────────────────── */} -
-

Categorical palette

-

Series colors — assigned to discrete categories in order.

- -
- set(CATEGORY, schemeRange(name))} - triggerContent={ - <> -
- - {catArray && ( -
- {catArray.map((color, i) => ( - - set( - CATEGORY, - catArray.map((c, j) => (j === i ? hex : c)), - ) - } - onRemove={() => - set( - CATEGORY, - catArray.length === 1 ? undefined : catArray.filter((_, j) => j !== i), - ) - } - /> - ))} -
- )} -
- - {/* ── Default mark color ──────────────────────────────────────────── */} -
-

Default mark color

-

- Single-series fill — bars, points, and lines with no color encoding. -

-
- set(MARK_COLOR, hex)} - /> - {markColor ? ( - - ) : ( - Unset — Vega default ({VEGA_DEFAULT_MARK}) - )} -
-
- - {/* ── Sequential gradient ─────────────────────────────────────────── */} -
-

Sequential gradient

-

Continuous color — heatmaps and quantitative legends.

-
-
- {seqArray && ( -
- {seqArray.map((color, i) => ( - setSeq(seqArray.map((c, j) => (j === i ? hex : c)))} - onRemove={() => - setSeq(seqArray.length === 1 ? undefined : seqArray.filter((_, j) => j !== i)) - } - /> - ))} -
- )} -
- - {/* ── Diverging gradient ──────────────────────────────────────────── */} -
-

Diverging gradient

-

Two-ended color — values around a meaningful midpoint.

-
-
- {divArray && ( -
- {divArray.map((color, i) => ( - - set( - DIVERGING, - divArray.map((c, j) => (j === i ? hex : c)), - ) - } - onRemove={() => - set( - DIVERGING, - divArray.length === 1 ? undefined : divArray.filter((_, j) => j !== i), - ) - } - /> - ))} + triggerTitle="Pick a named palette" + /> + {catArray ? ( + + ) : ( + + )}
- )} -
-
- ); + + {catArray && ( +
+ {catArray.map((color, i) => ( + + set( + CATEGORY, + catArray.map((c, j) => (j === i ? hex : c)), + ) + } + onRemove={() => + set( + CATEGORY, + catArray.length === 1 ? undefined : catArray.filter((_, j) => j !== i), + ) + } + /> + ))} +
+ )} + + ), + }, + { + id: 'markColor', + title: 'Default mark color', + hint: 'Single-series fill — bars, points, and lines with no color encoding.', + badge: countSet(config, [MARK_COLOR]), + children: ( + <> +
+ set(MARK_COLOR, hex)} + /> + {markColor ? ( + + ) : ( + Unset — Vega default ({VEGA_DEFAULT_MARK}) + )} +
+ + ), + }, + { + id: 'sequential', + title: 'Sequential gradient', + hint: 'Continuous color — heatmaps and quantitative legends.', + badge: countSet(config, [HEATMAP]), + children: ( + <> +
+
+ {seqArray && ( +
+ {seqArray.map((color, i) => ( + setSeq(seqArray.map((c, j) => (j === i ? hex : c)))} + onRemove={() => + setSeq(seqArray.length === 1 ? undefined : seqArray.filter((_, j) => j !== i)) + } + /> + ))} +
+ )} + + ), + }, + { + id: 'diverging', + title: 'Diverging gradient', + hint: 'Two-ended color — values around a meaningful midpoint.', + badge: countSet(config, [DIVERGING]), + children: ( + <> +
+
+ {divArray && ( +
+ {divArray.map((color, i) => ( + + set( + DIVERGING, + divArray.map((c, j) => (j === i ? hex : c)), + ) + } + onRemove={() => + set( + DIVERGING, + divArray.length === 1 ? undefined : divArray.filter((_, j) => j !== i), + ) + } + /> + ))} +
+ )} + + ), + }, + ]; + + return ; } diff --git a/src/app/components/FormatControls.tsx b/src/app/components/FormatControls.tsx new file mode 100644 index 0000000..82c1d11 --- /dev/null +++ b/src/app/components/FormatControls.tsx @@ -0,0 +1,124 @@ +/** + * Theme Builder — Formats panel (docs/chart-theming-scope.md §5). + * + * Default number and date formatting (`config.numberFormat`, + * `normalizedNumberFormat`, `timeFormat`) plus the count-field title — the + * brand-level "always show currency / thousands / short dates" knobs Vega-Lite + * applies to guide labels, text marks, and tooltips. The values are d3-format / + * d3-time-format strings; each field is free text with quick-fill presets for + * the common patterns, since the pattern space is open (the JSON stays the way + * to write any string). Each writes a minimal diff via the shared setter. + */ + +import type { JsonObject } from '@core/spec-config'; +import { asString, type ConfigPath, countSet, getConfigValue } from '@core/theme-controls'; +import { useConfigSetter } from '../stores/CustomThemeStore'; +import { Button } from './Button'; +import { Accordion, type AccordionSection, TextRow } from './ThemeFields'; +import styles from './ThemeFields.module.css'; + +const NUMBER: ConfigPath = ['numberFormat']; +const NORMALIZED: ConfigPath = ['normalizedNumberFormat']; +const TIME: ConfigPath = ['timeFormat']; +const COUNT_TITLE: ConfigPath = ['countTitle']; + +interface Preset { + /** The d3 pattern written into the config. */ + code: string; + /** A rendered example, shown as the chip label. */ + sample: string; +} + +const NUMBER_PRESETS: Preset[] = [ + { code: ',', sample: '1,234' }, + { code: '$,.2f', sample: '$1,234.00' }, + { code: '.0%', sample: '12%' }, + { code: '.2s', sample: '1.2k' }, + { code: '+,', sample: '+1,234' }, +]; + +const TIME_PRESETS: Preset[] = [ + { code: '%b %Y', sample: 'Jan 2026' }, + { code: '%Y-%m-%d', sample: '2026-01-01' }, + { code: '%b %-d', sample: 'Jan 1' }, + { code: '%-d %b %Y', sample: '1 Jan 2026' }, +]; + +/** A row of quick-fill chips that write a preset pattern into a config key. */ +function Presets({ presets, onPick }: { presets: Preset[]; onPick: (code: string) => void }) { + return ( +
+ {presets.map((p) => ( + + ))} +
+ ); +} + +export function FormatControls({ config }: { config: JsonObject }) { + const set = useConfigSetter(); + + const sections: AccordionSection[] = [ + { + id: 'numbers', + title: 'Numbers', + hint: 'd3-format pattern for labels, text, and tooltips.', + badge: countSet(config, [NUMBER, NORMALIZED]), + children: ( + <> + set(NUMBER, v)} + /> + set(NUMBER, code)} /> + set(NORMALIZED, v)} + /> + + ), + }, + { + id: 'dates', + title: 'Dates', + hint: 'd3-time-format pattern for raw time values.', + badge: countSet(config, [TIME]), + children: ( + <> + set(TIME, v)} + /> + set(TIME, code)} /> + + ), + }, + { + id: 'labels', + title: 'Labels', + badge: countSet(config, [COUNT_TITLE]), + children: ( + set(COUNT_TITLE, v)} + /> + ), + }, + ]; + + return ; +} diff --git a/src/app/components/HeaderControls.tsx b/src/app/components/HeaderControls.tsx new file mode 100644 index 0000000..bb7c4d2 --- /dev/null +++ b/src/app/components/HeaderControls.tsx @@ -0,0 +1,104 @@ +/** + * Theme Builder — Headers panel (docs/chart-theming-scope.md §5). + * + * The base facet-header config (`config.header.*`) — the titles and labels that + * caption each panel of a faceted chart (the `row`/`column`/`facet` channels). + * The per-position variants (`headerRow`, `headerColumn`, `headerFacet`) stay in + * the JSON; this is the common surface a brand tunes. Each control writes a + * minimal diff through the shared setter. + */ + +import type { JsonObject } from '@core/spec-config'; +import { + asNumber, + asString, + type ConfigPath, + countSet, + getConfigValue, +} from '@core/theme-controls'; +import { useConfigSetter } from '../stores/CustomThemeStore'; +import { Accordion, type AccordionSection, ColorRow, NumberRow, WeightRow } from './ThemeFields'; + +const TEXT_GREY = '#888888'; + +const TITLE_COLOR: ConfigPath = ['header', 'titleColor']; +const TITLE_SIZE: ConfigPath = ['header', 'titleFontSize']; +const TITLE_WEIGHT: ConfigPath = ['header', 'titleFontWeight']; +const LABEL_COLOR: ConfigPath = ['header', 'labelColor']; +const LABEL_SIZE: ConfigPath = ['header', 'labelFontSize']; +const LABEL_WEIGHT: ConfigPath = ['header', 'labelFontWeight']; + +export function HeaderControls({ config }: { config: JsonObject }) { + const set = useConfigSetter(); + + const sections: AccordionSection[] = [ + { + id: 'titles', + title: 'Facet titles', + hint: "The caption naming each facet's field.", + badge: countSet(config, [TITLE_COLOR, TITLE_SIZE, TITLE_WEIGHT]), + children: ( + <> + set(TITLE_COLOR, hex)} + onClear={() => set(TITLE_COLOR, undefined)} + /> + set(TITLE_SIZE, n)} + /> + set(TITLE_WEIGHT, w)} + /> + + ), + }, + { + id: 'labels', + title: 'Facet labels', + hint: 'The per-panel value labels.', + badge: countSet(config, [LABEL_COLOR, LABEL_SIZE, LABEL_WEIGHT]), + children: ( + <> + set(LABEL_COLOR, hex)} + onClear={() => set(LABEL_COLOR, undefined)} + /> + set(LABEL_SIZE, n)} + /> + set(LABEL_WEIGHT, w)} + /> + + ), + }, + ]; + + return ; +} diff --git a/src/app/components/LayoutControls.tsx b/src/app/components/LayoutControls.tsx index e89db51..783a495 100644 --- a/src/app/components/LayoutControls.tsx +++ b/src/app/components/LayoutControls.tsx @@ -2,18 +2,25 @@ * 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`. + * `background`, the plot area's `view` fill / border / corner radius, outer + * `padding`, and the default chart size. 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 { + asNumber, + asString, + type ConfigPath, + countSet, + getConfigValue, +} from '@core/theme-controls'; import { useConfigSetter } from '../stores/CustomThemeStore'; -import { ColorRow, ControlSection, NumberRow, SelectRow } from './ThemeFields'; +import { Accordion, type AccordionSection, ColorRow, NumberRow, SelectRow } from './ThemeFields'; import type { SelectControlOption } from './SelectControl'; import styles from './ThemeFields.module.css'; @@ -22,6 +29,9 @@ const VIEW_FILL: ConfigPath = ['view', 'fill']; const VIEW_STROKE: ConfigPath = ['view', 'stroke']; const VIEW_RADIUS: ConfigPath = ['view', 'cornerRadius']; const PADDING: ConfigPath = ['padding']; +const VIEW_WIDTH: ConfigPath = ['view', 'continuousWidth']; +const VIEW_HEIGHT: ConfigPath = ['view', 'continuousHeight']; +const VIEW_STEP: ConfigPath = ['view', 'step']; type FillMode = 'default' | 'transparent' | 'custom'; @@ -48,86 +58,134 @@ export function LayoutControls({ config }: { config: JsonObject }) { 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 ( -
- - set(BACKGROUND, fillValue(m, bg, '#ffffff'))} - /> - {bgMode === 'custom' && ( + const sections: AccordionSection[] = [ + { + id: 'background', + title: 'Background', + hint: 'Fill behind the whole chart, padding included.', + badge: countSet(config, [BACKGROUND]), + children: ( + <> + set(BACKGROUND, fillValue(m, bg, '#ffffff'))} + /> + {bgMode === 'custom' && ( + set(BACKGROUND, hex)} + /> + )} + + ), + }, + { + id: 'plot', + title: 'Plot area', + hint: 'The plotting rectangle inside the axes.', + badge: countSet(config, [VIEW_FILL, VIEW_STROKE, VIEW_RADIUS]), + children: ( + <> set(BACKGROUND, hex)} + onChange={(hex) => set(VIEW_FILL, hex)} + onClear={() => set(VIEW_FILL, undefined)} /> - )} - - - - set(VIEW_FILL, hex)} - onClear={() => set(VIEW_FILL, undefined)} - /> - set(VIEW_STROKE, fillValue(m, stroke, '#cccccc'))} - /> - {strokeMode === 'custom' && ( - set(VIEW_STROKE, hex)} + set(VIEW_STROKE, fillValue(m, stroke, '#cccccc'))} /> - )} - set(VIEW_RADIUS, n)} - /> - - - - {isJsonObject(padding) ? ( -

- Padding is set per-side as an object — edit it in the JSON below. -

- ) : ( + {strokeMode === 'custom' && ( + set(VIEW_STROKE, hex)} + /> + )} set(PADDING, n)} + onChange={(n) => set(VIEW_RADIUS, n)} /> - )} -
-
- ); + + ), + }, + { + id: 'spacing', + title: 'Spacing', + hint: 'Margin between the chart and its container edge.', + badge: countSet(config, [PADDING]), + children: isJsonObject(padding) ? ( +

+ Padding is set per-side as an object — edit it in the JSON below. +

+ ) : ( + set(PADDING, n)} + /> + ), + }, + { + id: 'size', + title: 'Default size', + hint: "The plot size when a spec doesn't set its own width/height.", + badge: countSet(config, [VIEW_WIDTH, VIEW_HEIGHT, VIEW_STEP]), + children: ( + <> + set(VIEW_WIDTH, n)} + /> + set(VIEW_HEIGHT, n)} + /> + set(VIEW_STEP, n)} + /> + + ), + }, + ]; + + return ; } diff --git a/src/app/components/LegendControls.tsx b/src/app/components/LegendControls.tsx index f6a6646..fee57ff 100644 --- a/src/app/components/LegendControls.tsx +++ b/src/app/components/LegendControls.tsx @@ -1,25 +1,40 @@ /** * 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. + * Structured controls over the base `legend` config: placement, the title's and + * labels' colour and size, the symbol keys, the continuous gradient bar, and an + * optional box behind the legend. Legend type (size) lives here rather than the + * Type panel so every legend property a brand tunes sits together. */ import type { JsonObject } from '@core/spec-config'; -import { asNumber, asString, type ConfigPath, getConfigValue } from '@core/theme-controls'; +import { + asNumber, + asString, + type ConfigPath, + countSet, + enumValue, + getConfigValue, +} from '@core/theme-controls'; import { useConfigSetter } from '../stores/CustomThemeStore'; -import { ColorRow, ControlSection, NumberRow, SelectRow } from './ThemeFields'; +import { Accordion, type AccordionSection, ColorRow, NumberRow, SelectRow } from './ThemeFields'; import type { SelectControlOption } from './SelectControl'; -import styles from './ThemeFields.module.css'; const ORIENT: ConfigPath = ['legend', 'orient']; +const DIRECTION: ConfigPath = ['legend', 'direction']; +const COLUMNS: ConfigPath = ['legend', 'columns']; 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 SYMBOL_TYPE: ConfigPath = ['legend', 'symbolType']; +const GRADIENT_LENGTH: ConfigPath = ['legend', 'gradientLength']; +const GRADIENT_THICKNESS: ConfigPath = ['legend', 'gradientThickness']; +const FILL_COLOR: ConfigPath = ['legend', 'fillColor']; +const STROKE_COLOR: ConfigPath = ['legend', 'strokeColor']; +const CORNER_RADIUS: ConfigPath = ['legend', 'cornerRadius']; +const PADDING: ConfigPath = ['legend', 'padding']; const TEXT_GREY = '#888888'; @@ -35,80 +50,205 @@ const orientOptions: SelectControlOption[] = [ { 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) : ''; -}; + +type Direction = '' | 'horizontal' | 'vertical'; +const directionOptions: SelectControlOption[] = [ + { value: '', label: 'Theme default' }, + { value: 'vertical', label: 'Vertical' }, + { value: 'horizontal', label: 'Horizontal' }, +]; + +// Discrete-legend key shapes (continuous legends render a gradient bar instead). +type SymbolType = '' | 'circle' | 'square' | 'cross' | 'diamond' | 'triangle-up' | 'stroke'; +const symbolTypeOptions: SelectControlOption[] = [ + { value: '', label: 'Theme default' }, + { value: 'circle', label: 'Circle' }, + { value: 'square', label: 'Square' }, + { value: 'cross', label: 'Cross' }, + { value: 'diamond', label: 'Diamond' }, + { value: 'triangle-up', label: 'Triangle' }, + { value: 'stroke', label: 'Line' }, +]; 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)); + const sections: AccordionSection[] = [ + { + id: 'placement', + title: 'Placement', + hint: 'Where the legend sits relative to the plot.', + badge: countSet(config, [ORIENT, DIRECTION, COLUMNS]), + children: ( + <> + set(ORIENT, o === '' ? undefined : o)} + /> + set(DIRECTION, d === '' ? undefined : d)} + /> + set(COLUMNS, n)} + /> + + ), + }, + { + id: 'title', + title: 'Title', + badge: countSet(config, [TITLE_COLOR, TITLE_SIZE]), + children: ( + <> + set(TITLE_COLOR, hex)} + onClear={() => set(TITLE_COLOR, undefined)} + /> + set(TITLE_SIZE, n)} + /> + + ), + }, + { + id: 'labels', + title: 'Labels', + badge: countSet(config, [LABEL_COLOR, LABEL_SIZE]), + children: ( + <> + set(LABEL_COLOR, hex)} + onClear={() => set(LABEL_COLOR, undefined)} + /> + set(LABEL_SIZE, n)} + /> + + ), + }, + { + id: 'symbols', + title: 'Symbols', + hint: 'The colored keys beside each label.', + badge: countSet(config, [SYMBOL_TYPE, SYMBOL_SIZE]), + children: ( + <> + set(SYMBOL_TYPE, t === '' ? undefined : t)} + /> + set(SYMBOL_SIZE, n)} + /> + + ), + }, + { + id: 'gradient', + title: 'Gradient', + hint: 'The continuous color bar (quantitative legends).', + badge: countSet(config, [GRADIENT_LENGTH, GRADIENT_THICKNESS]), + children: ( + <> + set(GRADIENT_LENGTH, n)} + /> + set(GRADIENT_THICKNESS, n)} + /> + + ), + }, + { + id: 'box', + title: 'Box', + hint: 'An optional filled/bordered panel behind the legend.', + badge: countSet(config, [FILL_COLOR, STROKE_COLOR, CORNER_RADIUS, PADDING]), + children: ( + <> + set(FILL_COLOR, hex)} + onClear={() => set(FILL_COLOR, undefined)} + /> + set(STROKE_COLOR, hex)} + onClear={() => set(STROKE_COLOR, undefined)} + /> + set(CORNER_RADIUS, n)} + /> + set(PADDING, n)} + /> + + ), + }, + ]; - return ( -
- - set(ORIENT, o === '' ? undefined : o)} - /> - - - - set(TITLE_COLOR, hex)} - onClear={() => set(TITLE_COLOR, undefined)} - /> - set(TITLE_SIZE, n)} - /> - - - - set(LABEL_COLOR, hex)} - onClear={() => set(LABEL_COLOR, undefined)} - /> - set(LABEL_SIZE, n)} - /> - - - - set(SYMBOL_SIZE, n)} - /> - -
- ); + return ; } diff --git a/src/app/components/MarksControls.tsx b/src/app/components/MarksControls.tsx new file mode 100644 index 0000000..a9622b6 --- /dev/null +++ b/src/app/components/MarksControls.tsx @@ -0,0 +1,347 @@ +/** + * Theme Builder — Marks panel (docs/chart-theming-scope.md §5). + * + * The mark defaults a brand tunes, grouped by mark TYPE: writing + * `config.bar.cornerRadiusEnd` rounds only bars, where `config.mark.cornerRadius` + * would round everything — the honest brand model is per-type. The default + * single-series mark color stays in the Color panel (it sits with the palette); + * everything else mark-shaped lives here. + * + * Unlike the scalar panels, this one is an Accordion (one mark type open at a + * time): a brand styles one or two mark types, not all five, so the per-type + * groups are "not crucial to read in full" — Carbon's accordion rule (arch 10 → + * L2 grouping). Each control writes one config path through the shared + * `useConfigSetter`, deleting the key (and pruning the emptied per-type object) + * when cleared, so a theme stays a minimal diff against stock. + */ + +import type { JsonObject } from '@core/spec-config'; +import { + asBoolean, + asNumber, + type ConfigPath, + countSet, + enumValue, + getConfigValue, +} from '@core/theme-controls'; +import { useConfigSetter } from '../stores/CustomThemeStore'; +import { Accordion, type AccordionSection, NumberRow, SelectRow } from './ThemeFields'; +import type { SelectControlOption } from './SelectControl'; + +// ── Enum option sets ──────────────────────────────────────────────────────── +// '' is the unset (theme default) sentinel for every enum; a config value the +// control doesn't enumerate (a hand-edit) reads back as '' so the control shows +// the default rather than misreporting — same convention as the Axes panel. + +type Interpolate = + | '' + | 'linear' + | 'monotone' + | 'basis' + | 'cardinal' + | 'natural' + | 'step' + | 'step-before' + | 'step-after'; +const interpolateOptions: SelectControlOption[] = [ + { value: '', label: 'Theme default' }, + { value: 'linear', label: 'Linear' }, + { value: 'monotone', label: 'Monotone' }, + { value: 'basis', label: 'Basis (smooth)' }, + { value: 'cardinal', label: 'Cardinal' }, + { value: 'natural', label: 'Natural' }, + { value: 'step', label: 'Step' }, + { value: 'step-before', label: 'Step before' }, + { value: 'step-after', label: 'Step after' }, +]; + +type Shape = '' | 'circle' | 'square' | 'cross' | 'diamond' | 'triangle-up' | 'triangle-down'; +const shapeOptions: SelectControlOption[] = [ + { value: '', label: 'Theme default' }, + { value: 'circle', label: 'Circle' }, + { value: 'square', label: 'Square' }, + { value: 'cross', label: 'Cross' }, + { value: 'diamond', label: 'Diamond' }, + { value: 'triangle-up', label: 'Triangle up' }, + { value: 'triangle-down', label: 'Triangle down' }, +]; + +// ── Boolean tri-state row ─────────────────────────────────────────────────── + +/** + * A boolean config key as a tri-state select (theme default · true · false). + * "Unset" is a distinct, meaningful state from either boolean here — filled vs. + * outlined marks, tooltips on vs. off — so a plain checkbox (which can't express + * "inherit") would lie. Same shape as the Axes panel's grid-visibility control. + */ +function BoolRow({ + id, + label, + value, + onChange, + trueLabel, + falseLabel, +}: { + id: string; + label: string; + value: boolean | undefined; + onChange: (value: boolean | undefined) => void; + trueLabel: string; + falseLabel: string; +}) { + type S = '' | 'true' | 'false'; + const options: SelectControlOption[] = [ + { value: '', label: 'Theme default' }, + { value: 'true', label: trueLabel }, + { value: 'false', label: falseLabel }, + ]; + const s: S = value === undefined ? '' : value ? 'true' : 'false'; + return ( + onChange(v === '' ? undefined : v === 'true')} + /> + ); +} + +// Each section's badge counts how many of *its own* controls are set (the uniform +// `countSet` contract — "fields set here"). Listed explicitly, not `Object.keys` +// on the per-type object: `mark.color` lives under `mark` but is owned by the +// Color panel, so a key count would inflate the "All marks" badge. +const MARK_PATHS: ConfigPath[] = [ + ['mark', 'opacity'], + ['mark', 'filled'], + ['mark', 'tooltip'], +]; +const BAR_PATHS: ConfigPath[] = [ + ['bar', 'cornerRadiusEnd'], + ['bar', 'discreteBandSize'], + ['bar', 'opacity'], +]; +const LINE_AREA_PATHS: ConfigPath[] = [ + ['line', 'interpolate'], + ['line', 'strokeWidth'], + ['line', 'point'], + ['area', 'opacity'], + ['area', 'line'], +]; +const POINT_PATHS: ConfigPath[] = [ + ['point', 'size'], + ['point', 'shape'], + ['point', 'filled'], + ['point', 'opacity'], +]; +const ARC_PATHS: ConfigPath[] = [ + ['arc', 'innerRadius'], + ['arc', 'cornerRadius'], + ['arc', 'padAngle'], +]; + +export function MarksControls({ config }: { config: JsonObject }) { + const set = useConfigSetter(); + + const num = (path: ConfigPath) => asNumber(getConfigValue(config, path)); + const bool = (path: ConfigPath) => asBoolean(getConfigValue(config, path)); + + const sections: AccordionSection[] = [ + { + id: 'all', + title: 'All marks', + badge: countSet(config, MARK_PATHS), + children: ( + <> + set(['mark', 'opacity'], n)} + /> + set(['mark', 'filled'], b)} + trueLabel="Filled" + falseLabel="Outlined" + /> + set(['mark', 'tooltip'], b)} + trueLabel="On" + falseLabel="Off" + /> + + ), + }, + { + id: 'bar', + title: 'Bars', + badge: countSet(config, BAR_PATHS), + children: ( + <> + set(['bar', 'cornerRadiusEnd'], n)} + /> + set(['bar', 'discreteBandSize'], n)} + /> + set(['bar', 'opacity'], n)} + /> + + ), + }, + { + id: 'lineArea', + title: 'Lines & areas', + badge: countSet(config, LINE_AREA_PATHS), + children: ( + <> + set(['line', 'interpolate'], v === '' ? undefined : v)} + /> + set(['line', 'strokeWidth'], n)} + /> + set(['line', 'point'], b)} + trueLabel="Show" + falseLabel="Hide" + /> + set(['area', 'opacity'], n)} + /> + set(['area', 'line'], b)} + trueLabel="Show" + falseLabel="Hide" + /> + + ), + }, + { + id: 'point', + title: 'Points', + badge: countSet(config, POINT_PATHS), + children: ( + <> + set(['point', 'size'], n)} + /> + set(['point', 'shape'], v === '' ? undefined : v)} + /> + set(['point', 'filled'], b)} + trueLabel="Filled" + falseLabel="Outlined" + /> + set(['point', 'opacity'], n)} + /> + + ), + }, + { + id: 'arc', + title: 'Arc (pie & donut)', + badge: countSet(config, ARC_PATHS), + children: ( + <> + set(['arc', 'innerRadius'], n)} + /> + set(['arc', 'cornerRadius'], n)} + /> + set(['arc', 'padAngle'], n)} + /> + + ), + }, + ]; + + return ; +} diff --git a/src/app/components/ThemeBuilderModal.module.css b/src/app/components/ThemeBuilderModal.module.css index 156e311..ee0aa65 100644 --- a/src/app/components/ThemeBuilderModal.module.css +++ b/src/app/components/ThemeBuilderModal.module.css @@ -141,27 +141,43 @@ border-bottom: var(--border-width) solid var(--border); } -/* ── Structured-control tabs ───────────────────────────────────────────── */ +/* ── Structured-control tabs (vertical rail | panel) ───────────────────── */ -.tabs { - flex: 0 0 auto; - display: flex; - gap: 0; - padding: 0 var(--space-5); +/* The rail of panel names beside the active panel; the divider above the JSON + section spans both, so it sits on the vsplit, not the panel. */ +.vsplit { + flex: 1 1 auto; + display: grid; + grid-template-columns: 118px minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); + min-height: 0; + min-width: 0; border-bottom: var(--border-width) solid var(--border); } +/* Vertical tab list — a tinted strip, distinct from the (untinted) themes list + pane on the far left. */ +.tabs { + display: flex; + flex-direction: column; + overflow: auto; + min-height: 0; + background: var(--layer-01); + border-right: var(--border-width) solid var(--border); +} + .tab { appearance: none; background: none; border: none; - border-bottom: 2px solid transparent; - margin-bottom: -1px; + border-left: 2px solid transparent; padding: var(--space-3) var(--space-4); font: inherit; font-size: 13px; font-weight: 500; color: var(--text-secondary); + text-align: left; + white-space: nowrap; cursor: pointer; transition: color var(--dur-fast) var(--ease); } @@ -172,16 +188,17 @@ .tabActive { color: var(--text); - border-bottom-color: var(--accent); + border-left-color: var(--accent); + background: var(--bg); + font-weight: 600; } -/* The structured controls are the first-screen surface: they fill the column - and scroll, with the collapsible JSON section docked below. */ +/* The active panel scrolls within its grid cell; the JSON section is docked + below the whole vsplit. */ .tabpanel { - flex: 1 1 auto; overflow: auto; min-height: 0; - border-bottom: var(--border-width) solid var(--border); + min-width: 0; } .controlsDisabled { diff --git a/src/app/components/ThemeBuilderModal.tsx b/src/app/components/ThemeBuilderModal.tsx index 5404b46..c30e967 100644 --- a/src/app/components/ThemeBuilderModal.tsx +++ b/src/app/components/ThemeBuilderModal.tsx @@ -26,22 +26,39 @@ import { } from '../stores/CustomThemeStore'; import { notify } from '../stores/NotificationStore'; import { resnapshot } from '../modals/ModalCoordinator'; +import type { ComponentType } from 'react'; import { AxesControls } from './AxesControls'; import { Button } from './Button'; import { ColorControls } from './ColorControls'; +import { FormatControls } from './FormatControls'; +import { HeaderControls } from './HeaderControls'; import { LayoutControls } from './LayoutControls'; +import { MarksControls } from './MarksControls'; import { LegendControls } from './LegendControls'; +import { TitleControls } from './TitleControls'; import { TypeControls } from './TypeControls'; 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). + * Each panel takes the parsed draft config and writes through the shared + * `useConfigSetter`; the map is the single registry of id → label → panel. + */ const THEME_TABS = [ - { id: 'color', label: 'Color' }, - { id: 'type', label: 'Type' }, - { id: 'layout', label: 'Layout' }, - { id: 'axes', label: 'Axes & grid' }, - { id: 'legend', label: 'Legend' }, -] as const; + { id: 'color', label: 'Color', Panel: ColorControls }, + { id: 'marks', label: 'Marks', Panel: MarksControls }, + { id: 'type', label: 'Type', Panel: TypeControls }, + { id: 'title', label: 'Title', Panel: TitleControls }, + { id: 'layout', label: 'Layout', Panel: LayoutControls }, + { id: 'axes', label: 'Axes & grid', Panel: AxesControls }, + { id: 'legend', label: 'Legend', Panel: LegendControls }, + { id: 'headers', label: 'Headers', Panel: HeaderControls }, + { id: 'formats', label: 'Formats', Panel: FormatControls }, +] as const satisfies ReadonlyArray<{ + id: string; + label: string; + Panel: ComponentType<{ config: JsonObject }>; +}>; type ThemeTab = (typeof THEME_TABS)[number]['id']; /** Debounce for gallery re-renders while the config text is edited (ms). */ @@ -143,16 +160,17 @@ export function ThemeBuilderModal() { // (it's the only place to fix the JSON). const [jsonExpanded, setJsonExpanded] = useState(false); const jsonOpen = jsonExpanded || parseError !== null; + const ActivePanel = THEME_TABS.find((t) => t.id === activeTab)!.Panel; - // APG tabs: arrow/Home/End move selection, which follows focus (automatic - // activation — the panel swap is cheap). The portaled SelectControl popovers - // inside a panel manage their own focus. + // APG tabs (vertical orientation): Up/Down + Home/End move selection, which + // follows focus (automatic activation — the panel swap is cheap). The portaled + // SelectControl popovers inside a panel manage their own focus. const onTabKeyDown = (e: React.KeyboardEvent) => { const ids = THEME_TABS.map((t) => t.id); const i = ids.indexOf(activeTab); let next = -1; - if (e.key === 'ArrowRight') next = (i + 1) % ids.length; - else if (e.key === 'ArrowLeft') next = (i - 1 + ids.length) % ids.length; + if (e.key === 'ArrowDown') next = (i + 1) % ids.length; + else if (e.key === 'ArrowUp') next = (i - 1 + ids.length) % ids.length; else if (e.key === 'Home') next = 0; else if (e.key === 'End') next = ids.length - 1; if (next < 0) return; @@ -267,56 +285,51 @@ export function ThemeBuilderModal() {
-
- {THEME_TABS.map((t) => ( - - ))} -
- {(saveError ?? parseError) !== null && (

{saveError ?? parseError}

)} -
- {parseError !== null ? ( -

- Fix the JSON below to use these controls. -

- ) : draftConfig === null ? null : activeTab === 'color' ? ( - - ) : activeTab === 'type' ? ( - - ) : activeTab === 'layout' ? ( - - ) : activeTab === 'axes' ? ( - - ) : ( - - )} +
+
+ {THEME_TABS.map((t) => ( + + ))} +
+ +
+ {parseError !== null ? ( +

+ Fix the JSON below to use these controls. +

+ ) : draftConfig === null ? null : ( + + )} +
{/* Raw JSON — collapsed by default (the structured controls are the diff --git a/src/app/components/ThemeControlPanels.test.tsx b/src/app/components/ThemeControlPanels.test.tsx index d2d5800..e65fe4b 100644 --- a/src/app/components/ThemeControlPanels.test.tsx +++ b/src/app/components/ThemeControlPanels.test.tsx @@ -143,6 +143,13 @@ describe('AxesControls', () => { act(() => setNativeValue(numberInput('Label angle'), '-45')); expect(at('axis', 'labelAngle')).toBe(-45); }); + + test('ticks "Hidden" writes axis.ticks false', () => { + render(); + open({}, 'Axes & grid'); + pick('Ticks', 'Hidden'); + expect(at('axis', 'ticks')).toBe(false); + }); }); describe('LegendControls', () => { @@ -159,20 +166,158 @@ describe('LegendControls', () => { act(() => setNativeValue(numberInput('Symbol size'), '120')); expect(at('legend', 'symbolSize')).toBe(120); }); + + test('direction "Horizontal" writes legend.direction', () => { + render(); + open({}, 'Legend'); + pick('Direction', 'Horizontal'); + expect(at('legend', 'direction')).toBe('horizontal'); + }); +}); + +describe('accordion panels', () => { + // Every panel groups its controls into the single-expand accordion. Verify the + // structure on the Color panel: each section is a collapsible header, the first + // is open, the rest collapsed. + const headers = () => + [...container.querySelectorAll('button')].filter((b) => + b.hasAttribute('data-accordion-header'), + ); + + test('a converted panel renders collapsible sections, first open', () => { + render(); + open({}, 'Color'); + const hs = headers(); + expect(hs).toHaveLength(4); // categorical / mark color / sequential / diverging + expect(hs[0].textContent).toContain('Categorical palette'); + expect(hs[3].textContent).toContain('Diverging gradient'); + expect(hs[0].getAttribute('aria-expanded')).toBe('true'); + expect(hs[1].getAttribute('aria-expanded')).toBe('false'); + }); + + test('a section header shows a count badge of the properties set within it', () => { + render(); + // Two axis-grid keys set → the Grid section badge reads 2. + open({ axis: { grid: false, gridColor: '#fff' } }, 'Axes & grid'); + const grid = headers().find((h) => h.textContent?.includes('Grid'))!; + expect(grid.querySelector('[aria-label="2 set"]')?.textContent).toBe('2'); + }); +}); + +describe('MarksControls', () => { + // The per-type accordion sections start collapsed except the first ("All + // marks"); open a section by clicking its header before reaching its controls. + const openSection = (title: string) => { + const header = [...container.querySelectorAll('button')].find( + (b) => b.hasAttribute('data-accordion-header') && b.textContent?.includes(title), + )!; + act(() => header.click()); + }; + + test('bar corner radius writes the bar-only path, not the generic mark', () => { + render(); + open({}, 'Marks'); + openSection('Bars'); + act(() => setNativeValue(numberInput('Bar corner radius'), '4')); + expect(at('bar', 'cornerRadiusEnd')).toBe(4); + expect(at('mark', 'cornerRadius')).toBeUndefined(); + }); + + test('clearing a bar control prunes the emptied bar object (minimal diff)', () => { + render(); + open({ bar: { cornerRadiusEnd: 4 } }, 'Marks'); + openSection('Bars'); + act(() => setNativeValue(numberInput('Bar corner radius'), '')); + expect(config().bar).toBeUndefined(); + }); + + test('line curve writes line.interpolate', () => { + render(); + open({}, 'Marks'); + openSection('Lines & areas'); + pick('Line curve', 'Monotone'); + expect(at('line', 'interpolate')).toBe('monotone'); + }); + + test('arc donut hole writes arc.innerRadius', () => { + render(); + open({}, 'Marks'); + openSection('Arc'); + act(() => setNativeValue(numberInput('Donut hole radius'), '40')); + expect(at('arc', 'innerRadius')).toBe(40); + }); + + test('tooltips tri-state "Off" writes mark.tooltip false', () => { + render(); + open({}, 'Marks'); + pick('Tooltips', 'Off'); // "All marks" section is open by default + expect(at('mark', 'tooltip')).toBe(false); + }); }); 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); }); + + test('axis title weight "Bold" (shared WeightRow) writes 700', () => { + render(); + open({}, 'Type'); + pick('Axis title weight', 'Bold'); + expect(at('axis', 'titleFontWeight')).toBe(700); + }); +}); + +describe('TitleControls', () => { + test('alignment "Left" writes title.anchor start', () => { + render(); + open({}, 'Title'); + pick('Alignment', 'Left'); + expect(at('title', 'anchor')).toBe('start'); + }); + + test('title weight "Bold" writes title.fontWeight 700 (relocated from Type)', () => { + render(); + open({}, 'Title'); + pick('Title weight', 'Bold'); + expect(at('title', 'fontWeight')).toBe(700); + }); + + test('subtitle size writes title.subtitleFontSize', () => { + render(); + open({}, 'Title'); + act(() => setNativeValue(numberInput('Subtitle size'), '11')); + expect(at('title', 'subtitleFontSize')).toBe(11); + }); +}); + +describe('FormatControls', () => { + test('a number preset chip fills numberFormat; clearing the field removes it', () => { + render(); + open({}, 'Formats'); + act(() => button('$1,234.00')!.click()); + expect(config().numberFormat).toBe('$,.2f'); + + act(() => setNativeValue(container.querySelector('input[aria-label="Number format"]')!, '')); + expect(config().numberFormat).toBeUndefined(); + }); + + test('date format typed free-text writes timeFormat', () => { + render(); + open({}, 'Formats'); + act(() => setNativeValue(container.querySelector('input[aria-label="Date format"]')!, '%Y')); + expect(config().timeFormat).toBe('%Y'); + }); +}); + +describe('HeaderControls', () => { + test('facet title size writes header.titleFontSize', () => { + render(); + open({}, 'Headers'); + act(() => setNativeValue(numberInput('Header title size'), '13')); + expect(at('header', 'titleFontSize')).toBe(13); + }); }); diff --git a/src/app/components/ThemeFields.module.css b/src/app/components/ThemeFields.module.css index a8d8bfe..ea2f601 100644 --- a/src/app/components/ThemeFields.module.css +++ b/src/app/components/ThemeFields.module.css @@ -1,23 +1,5 @@ -/* 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); -} +/* Theme Builder structured-control field primitives — shared by the panels + (docs/chart-theming-scope.md §5). */ .hint { margin: 0; @@ -25,12 +7,6 @@ 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; @@ -59,11 +35,28 @@ font-size: 13px; } +.text { + flex: 1 1 140px; + max-width: 220px; + height: var(--control-height); + padding: 0 var(--space-3); + font-size: 13px; + font-variant-numeric: tabular-nums; +} + .unit { font-size: 12px; color: var(--text-secondary); } +/* Quick-fill format presets (Formats panel) — a wrapping row of chips. */ +.presets { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + padding-left: 132px; /* align under the control column, past the label */ +} + .defaultNote { font-size: 12px; color: var(--text-secondary); @@ -125,3 +118,80 @@ color: var(--text-secondary); font-variant-numeric: tabular-nums; } + +/* Accordion (Marks panel) — single-expand per-mark-type sections. */ +.accordion { + display: grid; + padding: var(--space-5); + gap: 0; +} + +.accSection { + border-bottom: var(--border-width) solid var(--border); +} + +.accSection:first-child { + border-top: var(--border-width) solid var(--border); +} + +.accHeading { + margin: 0; + font-size: 13px; + font-weight: 600; +} + +.accHeader { + display: flex; + align-items: center; + gap: var(--space-3); + width: 100%; + padding: var(--space-3) var(--space-1); + appearance: none; + background: none; + border: none; + font: inherit; + font-weight: 600; + color: var(--text); + text-align: left; + cursor: pointer; +} + +.accHeader:hover { + color: var(--accent); +} + +.accCaret { + flex: none; + width: 1em; + font-size: 10px; + color: var(--text-secondary); +} + +.accTitle { + flex: 1 1 auto; +} + +/* Count of set properties — bordered chip, passive chrome (arch 09 §4). */ +.accBadge { + flex: none; + min-width: 18px; + padding: 0 var(--space-2); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-secondary); + font-size: 11px; + font-weight: 500; + font-variant-numeric: tabular-nums; + text-align: center; +} + +.accPanel { + padding: var(--space-2) 0 var(--space-4) var(--space-5); +} + +/* Grid only when open: author `display` would otherwise beat the UA + `[hidden] { display: none }` rule and leave a collapsed panel visible. */ +.accPanel:not([hidden]) { + display: grid; + gap: var(--space-3); +} diff --git a/src/app/components/ThemeFields.tsx b/src/app/components/ThemeFields.tsx index 4d900a4..fea0dd4 100644 --- a/src/app/components/ThemeFields.tsx +++ b/src/app/components/ThemeFields.tsx @@ -2,61 +2,28 @@ * 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: + * The structured-control panels are forms of scalar controls over the draft + * config — a labelled color, number, enum, or text 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. + * - `Accordion` — the single-expand sections a panel groups its controls into. + * - `ColorRow` / `NumberRow` / `SelectRow` / `TextRow` / `WeightRow` — 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. + * key for a minimal diff. The Color panel keeps its own swatch/gradient controls + * for the color families. Leaf coercion (`asString`/`asNumber`/`asBoolean`) plus + * `enumValue`/`countSet` live in core `theme-controls.ts` with the path get/set. */ -import { useState, type ReactNode } from 'react'; +import { useRef, 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 ( -
-

- {title} -

- {hint &&

{hint}

} -
{children}
-
- ); -} - /** * 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 @@ -159,6 +126,113 @@ export function NumberRow({ ); } +/** + * Progressive-disclosure accordion: single-expand (one section open at a time, or + * none), grouping a panel's controls by sub-domain. The Theme Builder + * standardizes on it across every panel for consistency; flat `role="group"` + * headings remain the documented alternative for groups read in full (arch 10 → + * Organizing a large control surface). + * + * APG accordion: each header is a ` + + +
+ ); + })} +
+ ); +} + /** * 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 @@ -193,3 +267,95 @@ export function SelectRow({
); } + +/** + * A labelled free-text field — for config leaves with no closed option set (d3 + * number/time format strings, the count title). Bound straight to the value + * (strings round-trip cleanly, so no transient-entry buffering like NumberRow); + * an empty field clears the key. + */ +export function TextRow({ + label, + name, + value, + placeholder, + onChange, +}: { + label: string; + name?: string; + value: string | undefined; + placeholder?: string; + onChange: (value: string | undefined) => void; +}) { + return ( +
+ {label} +
+ onChange(e.target.value === '' ? undefined : e.target.value)} + /> +
+
+ ); +} + +/** + * Font weight as a tri-state-ish select shared by the Type / Title / Headers + * panels: "Theme default" plus the nine numeric weights. A static face + * faux-renders weights it doesn't carry; a variable font's weight axis is fully + * selectable. A config weight outside the nine (a hand-edit) reads back as + * "Theme default" rather than a blank trigger. + */ +const WEIGHT_OPTIONS: SelectControlOption[] = [ + { value: '', label: 'Theme default' }, + { value: '100', label: 'Thin' }, + { value: '200', label: 'Extra Light' }, + { value: '300', label: 'Light' }, + { value: '400', label: 'Normal' }, + { value: '500', label: 'Medium' }, + { value: '600', label: 'Semibold' }, + { value: '700', label: 'Bold' }, + { value: '800', label: 'Extra Bold' }, + { value: '900', label: 'Black' }, +]; + +function weightOption(v: unknown): string { + if (typeof v === 'number') { + const s = String(v); + return WEIGHT_OPTIONS.some((o) => o.value === s) ? s : ''; + } + if (v === 'normal') return '400'; + if (v === 'bold') return '700'; + return ''; +} + +export function WeightRow({ + id, + label, + name, + value, + onChange, +}: { + id: string; + label: string; + name?: string; + value: unknown; + onChange: (weight: number | undefined) => void; +}) { + return ( + onChange(w === '' ? undefined : Number(w))} + /> + ); +} diff --git a/src/app/components/TitleControls.tsx b/src/app/components/TitleControls.tsx new file mode 100644 index 0000000..73e5b6d --- /dev/null +++ b/src/app/components/TitleControls.tsx @@ -0,0 +1,177 @@ +/** + * Theme Builder — Title & subtitle panel (docs/chart-theming-scope.md §5). + * + * The full title block (`config.title.*`), the editorial signature the Type + * panel couldn't reach: the anchor (left-aligned titles are the FT/Economist + * hallmark), color, italic, and offset, plus a subtitle group. Title size and + * weight moved here from the Type panel so every title property sits together; + * Type keeps font family and the axis type scale. + * + * Vega keeps title and subtitle as siblings under `config.title` (subtitle* + * keys), so both groups write into the one object; each control writes a minimal + * diff through the shared `useConfigSetter`. + */ + +import type { JsonObject } from '@core/spec-config'; +import { + asNumber, + asString, + type ConfigPath, + countSet, + enumValue, + getConfigValue, +} from '@core/theme-controls'; +import { useConfigSetter } from '../stores/CustomThemeStore'; +import { + Accordion, + type AccordionSection, + ColorRow, + NumberRow, + SelectRow, + WeightRow, +} from './ThemeFields'; +import type { SelectControlOption } from './SelectControl'; + +const TEXT_GREY = '#888888'; + +const ANCHOR: ConfigPath = ['title', 'anchor']; +const COLOR: ConfigPath = ['title', 'color']; +const SIZE: ConfigPath = ['title', 'fontSize']; +const WEIGHT: ConfigPath = ['title', 'fontWeight']; +const STYLE: ConfigPath = ['title', 'fontStyle']; +const OFFSET: ConfigPath = ['title', 'offset']; +const SUB_COLOR: ConfigPath = ['title', 'subtitleColor']; +const SUB_SIZE: ConfigPath = ['title', 'subtitleFontSize']; +const SUB_WEIGHT: ConfigPath = ['title', 'subtitleFontWeight']; +const SUB_STYLE: ConfigPath = ['title', 'subtitleFontStyle']; +const SUB_PADDING: ConfigPath = ['title', 'subtitlePadding']; + +// Title `anchor` positions the title across the chart width; "Left" (start) is +// the editorial default a brand most often wants. +type Anchor = '' | 'start' | 'middle' | 'end'; +const anchorOptions: SelectControlOption[] = [ + { value: '', label: 'Theme default' }, + { value: 'start', label: 'Left' }, + { value: 'middle', label: 'Center' }, + { value: 'end', label: 'Right' }, +]; + +type FontStyle = '' | 'normal' | 'italic'; +const styleOptions: SelectControlOption[] = [ + { value: '', label: 'Theme default' }, + { value: 'normal', label: 'Normal' }, + { value: 'italic', label: 'Italic' }, +]; + +export function TitleControls({ config }: { config: JsonObject }) { + const set = useConfigSetter(); + + const sections: AccordionSection[] = [ + { + id: 'title', + title: 'Title', + badge: countSet(config, [ANCHOR, COLOR, SIZE, WEIGHT, STYLE, OFFSET]), + children: ( + <> + set(ANCHOR, a === '' ? undefined : a)} + /> + set(COLOR, hex)} + onClear={() => set(COLOR, undefined)} + /> + set(SIZE, n)} + /> + set(WEIGHT, w)} + /> + set(STYLE, s === '' ? undefined : s)} + /> + set(OFFSET, n)} + /> + + ), + }, + { + id: 'subtitle', + title: 'Subtitle', + hint: 'Styles the subtitle when a spec sets one.', + badge: countSet(config, [SUB_COLOR, SUB_SIZE, SUB_WEIGHT, SUB_STYLE, SUB_PADDING]), + children: ( + <> + set(SUB_COLOR, hex)} + onClear={() => set(SUB_COLOR, undefined)} + /> + set(SUB_SIZE, n)} + /> + set(SUB_WEIGHT, w)} + /> + set(SUB_STYLE, s === '' ? undefined : s)} + /> + set(SUB_PADDING, n)} + /> + + ), + }, + ]; + + return ; +} diff --git a/src/app/components/TypeControls.tsx b/src/app/components/TypeControls.tsx index 0194dd4..877e1f8 100644 --- a/src/app/components/TypeControls.tsx +++ b/src/app/components/TypeControls.tsx @@ -2,10 +2,11 @@ * 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. + * transform that walks the whole config) plus the axis type scale a brand tunes: + * axis title and label size/weight. The title block (size, weight, anchor, + * colour, subtitle) lives in its own Title panel so every title property sits + * together; legend type lives in the Legend panel. Colour of text lives in the + * per-domain panels (Axes, Legend, Headers); this panel is family, size, weight. */ import { useMemo, useRef } from 'react'; @@ -13,18 +14,17 @@ import { THEME_FONT_OPTIONS } from '@core/custom-theme'; import { type FontAxis, fontFamilyStack, isVariableFont } from '@core/font-asset'; import type { JsonObject } from '@core/spec-config'; import { humanizeBytes } from '@core/storage-estimate'; -import { asNumber, type ConfigPath, getConfigValue } from '@core/theme-controls'; +import { asNumber, type ConfigPath, countSet, getConfigValue } from '@core/theme-controls'; import { removeFont, uploadFontFiles } from '../services/fonts'; import { confirm } from '../stores/ConfirmStore'; import { useConfigSetter, useCustomThemeStore } from '../stores/CustomThemeStore'; import { useFontStore } from '../stores/FontStore'; import { Button } from './Button'; import { SelectControl, type SelectControlOption } from './SelectControl'; -import { ControlSection, NumberRow, SelectRow } from './ThemeFields'; +import { Accordion, type AccordionSection, NumberRow, WeightRow } from './ThemeFields'; import styles from './ThemeFields.module.css'; -const TITLE_SIZE: ConfigPath = ['title', 'fontSize']; -const TITLE_WEIGHT: ConfigPath = ['title', 'fontWeight']; +const FONT: ConfigPath = ['font']; const AXIS_TITLE_SIZE: ConfigPath = ['axis', 'titleFontSize']; const AXIS_TITLE_WEIGHT: ConfigPath = ['axis', 'titleFontWeight']; const AXIS_LABEL_SIZE: ConfigPath = ['axis', 'labelFontSize']; @@ -46,34 +46,6 @@ function variableBadge(axes: FontAxis[] | undefined): string { return wght ? `Variable ${wght.min}–${wght.max}` : 'Variable'; } -// Numeric font weights as an enum; '' is the unset (default) sentinel. The full -// 100–900 range is offered so a variable font's weight axis is fully selectable; -// a static face simply faux-renders the weights it doesn't physically carry. -type Weight = '' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900' | 'custom'; -const weightOptions: SelectControlOption[] = [ - { value: '', label: 'Theme default' }, - { value: '100', label: 'Thin' }, - { value: '200', label: 'Extra Light' }, - { value: '300', label: 'Light' }, - { value: '400', label: 'Normal' }, - { value: '500', label: 'Medium' }, - { value: '600', label: 'Semibold' }, - { value: '700', label: 'Bold' }, - { value: '800', label: 'Extra Bold' }, - { value: '900', label: 'Black' }, -]; -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 fonts = useFontStore((s) => s.fonts); @@ -96,9 +68,6 @@ export function TypeControls({ config }: { config: JsonObject }) { const currentFont = typeof config.font === 'string' ? fontOptions.find((o) => o.value === config.font) : undefined; - const setWeight = (path: ConfigPath) => (w: Weight) => - set(path, w === '' ? undefined : Number(w)); - const onPickFiles = (e: React.ChangeEvent) => { const picked = e.target.files; if (picked && picked.length > 0) void uploadFontFiles(picked); @@ -117,129 +86,128 @@ export function TypeControls({ config }: { config: JsonObject }) { if (ok) removeFont(id); }; - return ( -
- -
- Font -
- useCustomThemeStore.getState().applyDraftFont(family)} - triggerContent={ - <> - - {currentFont?.label ?? 'Apply font…'} - - - - } - triggerTitle="Write one font family into every font slot of the config" - /> - - + const sections: AccordionSection[] = [ + { + id: 'font', + title: 'Font family', + hint: 'Applied to every text slot in the config.', + badge: countSet(config, [FONT]), + children: ( + <> +
+ Font +
+ useCustomThemeStore.getState().applyDraftFont(family)} + triggerContent={ + <> + + {currentFont?.label ?? 'Apply font…'} + + + + } + triggerTitle="Write one font family into every font slot of the config" + /> + + +
-
- {fonts.length > 0 && ( -
    - {fonts.map((f) => ( -
  • - - - {f.family} + {fonts.length > 0 && ( +
      + {fonts.map((f) => ( +
    • + + + {f.family} + + {isVariableFont(f.axes) && ( + {variableBadge(f.axes)} + )} - {isVariableFont(f.axes) && ( - {variableBadge(f.axes)} - )} - - {humanizeBytes(f.size)} - -
    • - ))} -
    - )} - + {humanizeBytes(f.size)} + +
  • + ))} +
+ )} + + ), + }, + { + id: 'axisTitles', + title: 'Axis titles', + badge: countSet(config, [AXIS_TITLE_SIZE, AXIS_TITLE_WEIGHT]), + children: ( + <> + set(AXIS_TITLE_SIZE, n)} + /> + set(AXIS_TITLE_WEIGHT, w)} + /> + + ), + }, + { + id: 'axisLabels', + title: 'Axis labels', + badge: countSet(config, [AXIS_LABEL_SIZE, AXIS_LABEL_WEIGHT]), + children: ( + <> + set(AXIS_LABEL_SIZE, n)} + /> + set(AXIS_LABEL_WEIGHT, w)} + /> + + ), + }, + ]; - - set(TITLE_SIZE, n)} - /> - - - - - set(AXIS_TITLE_SIZE, n)} - /> - - - - - set(AXIS_LABEL_SIZE, n)} - /> - - -
- ); + return ; } diff --git a/src/core/custom-theme.test.ts b/src/core/custom-theme.test.ts index 42e5e8c..92d9acb 100644 --- a/src/core/custom-theme.test.ts +++ b/src/core/custom-theme.test.ts @@ -155,4 +155,34 @@ describe('THEME_PREVIEW_SPECS', () => { ); expect(colors.some((c) => c?.scale?.domainMid !== undefined)).toBe(true); }); + + it('uses bare marks so config controls are not shadowed in the preview', () => { + // A visual mark property hard-coded in a card overrides the same key in the + // injected config, making the matching Marks control a no-op in the gallery. + // Mark styling belongs in the config (a theme), never inline here. + const SHADOWING = [ + 'size', + 'filled', + 'point', + 'innerRadius', + 'outerRadius', + 'cornerRadius', + 'cornerRadiusEnd', + 'opacity', + 'interpolate', + 'shape', + 'strokeWidth', + 'padAngle', + 'discreteBandSize', + 'continuousBandSize', + ]; + for (const card of THEME_PREVIEW_SPECS) { + const mark: unknown = card.spec.mark; + if (mark && typeof mark === 'object') { + for (const key of SHADOWING) { + expect(mark, `${card.id} hard-codes mark.${key}`).not.toHaveProperty(key); + } + } + } + }); }); diff --git a/src/core/theme-controls.test.ts b/src/core/theme-controls.test.ts index a8cee66..7d0c4ae 100644 --- a/src/core/theme-controls.test.ts +++ b/src/core/theme-controls.test.ts @@ -4,6 +4,8 @@ import { asBoolean, asNumber, asString, + countSet, + enumValue, getConfigValue, normalizeRangeSchemes, schemeColors, @@ -182,3 +184,38 @@ describe('schemeColors', () => { expect(schemeColors('not-a-scheme')).toEqual([]); }); }); + +describe('enumValue', () => { + const options = [{ value: '' }, { value: 'start' }, { value: 'end' }] as const; + + it('passes an enumerated value through', () => { + expect(enumValue('start', options)).toBe('start'); + }); + + it('falls back to the "" sentinel for a value the control does not enumerate', () => { + // A hand-edited config holding a richer value must not blank the trigger. + expect(enumValue('middle', options)).toBe(''); + expect(enumValue(42, options)).toBe(''); + expect(enumValue(undefined, options)).toBe(''); + }); +}); + +describe('countSet', () => { + const config = { axis: { grid: false, gridColor: '#fff' }, background: 'transparent' }; + + it('counts how many of the given paths are set', () => { + expect( + countSet(config, [ + ['axis', 'grid'], + ['axis', 'gridColor'], + ['axis', 'gridWidth'], + ]), + ).toBe(2); + }); + + it('counts a false/transparent value as set (only undefined is unset)', () => { + expect(countSet(config, [['axis', 'grid']])).toBe(1); // false is a set value + expect(countSet(config, [['background']])).toBe(1); + expect(countSet(config, [['missing']])).toBe(0); + }); +}); diff --git a/src/core/theme-controls.ts b/src/core/theme-controls.ts index f8609ad..93ff420 100644 --- a/src/core/theme-controls.ts +++ b/src/core/theme-controls.ts @@ -113,6 +113,28 @@ export const asNumber = (v: unknown): number | undefined => export const asBoolean = (v: unknown): boolean | undefined => typeof v === 'boolean' ? v : undefined; +/** + * Read an enum config leaf back to one of `options`' values, or `''` (the + * conventional "Theme default" sentinel a structured-control option set carries) + * when it holds something the control doesn't enumerate — a hand-edit, a preset's + * richer form. Keeps a select from showing a blank trigger for an unrecognized + * value. `options` is matched structurally, so the UI's `SelectControlOption` + * satisfies it without core depending on the component. + */ +export function enumValue(v: unknown, options: ReadonlyArray<{ value: V }>): V { + return typeof v === 'string' && options.some((o) => o.value === v) ? (v as V) : ('' as V); +} + +/** + * How many of `paths` are set (non-undefined) in `config` — a control section's + * "modified" count, shown as its accordion badge so overrides are scannable + * while a section is collapsed (NN/g recognition). Counts only the listed + * control paths, so it reads as "fields you've set here", not "every key". + */ +export function countSet(config: JsonObject, paths: ReadonlyArray): number { + return paths.reduce((n, p) => (getConfigValue(config, p) !== undefined ? n + 1 : n), 0); +} + // ── Named color schemes ───────────────────────────────────────────────────── type SchemeKind = 'categorical' | 'sequential' | 'diverging'; diff --git a/src/core/theme-preview-specs.ts b/src/core/theme-preview-specs.ts index b05cdc5..b8771ae 100644 --- a/src/core/theme-preview-specs.ts +++ b/src/core/theme-preview-specs.ts @@ -3,12 +3,22 @@ * * A fixed set of small, self-contained Vega-Lite specs the Theme Builder * renders side by side with the draft config, so an edit is previewed across - * every chart surface a config styles: titles and subtitles, axes and grids, - * facet headers, the major mark types, and — so every color control has a - * mirror — each color family: categorical (`range.category`), sequential - * (`range.heatmap` for rect, `range.ramp` for a continuous legend), and - * diverging (`range.diverging`, selected by a quantitative color scale with a - * `domainMid`). Inline data only, compact fixed sizes — swatches, not analyses. + * every chart surface a config styles. Every structured control should have a + * live mirror here: titles + subtitles, axes/grids/ticks, facet headers, the + * mark types (bar, line, area, point, arc), and each color family — categorical + * (`range.category`), sequential (`range.heatmap` for rect, `range.ramp` for a + * continuous legend), and diverging (`range.diverging`, a quantitative color + * scale with a `domainMid`). + * + * The specs deliberately use BARE mark strings (`mark: 'point'`, not + * `{ type: 'point', size: 80 }`): a property hard-coded in the spec overrides + * the same key in the injected config, which would make the corresponding Marks + * control a no-op in the preview. Add visual mark properties to the config (a + * theme), never inline here. Two controls have no static mirror by nature — the + * default chart SIZE (`view.continuousWidth/Height`: the cards are fixed-size + * swatches) and TOOLTIPS (`mark.tooltip`: hover-only) — and `countTitle` has + * none (no count aggregation in the samples). Inline data only, compact fixed + * sizes — swatches, not analyses. */ import type { JsonObject } from './spec-config'; @@ -50,7 +60,7 @@ const bar: ThemePreviewSpec = { const line: ThemePreviewSpec = { id: 'line', - caption: 'Line — subtitle, series legend', + caption: 'Line — subtitle, curve & markers', spec: { $schema: SCHEMA, title: { text: 'Signups over time', subtitle: 'Weekly, by plan' }, @@ -72,7 +82,10 @@ const line: ThemePreviewSpec = { { week: 4, plan: 'Team', n: 12 }, ], }, - mark: { type: 'line', point: true }, + // Bare `mark: 'line'` — the curve, stroke width, and point markers come from + // the draft config (the Marks panel's Lines & areas group), not the spec, so + // those controls have a live mirror. Same for every card below. + mark: 'line', encoding: { x: { field: 'week', type: 'quantitative', axis: { tickCount: 4 } }, y: { field: 'n', type: 'quantitative' }, @@ -83,7 +96,7 @@ const line: ThemePreviewSpec = { const area: ThemePreviewSpec = { id: 'area', - caption: 'Stacked area — categorical palette', + caption: 'Normalized area — palette, %', spec: { $schema: SCHEMA, width: 200, @@ -107,7 +120,9 @@ const area: ThemePreviewSpec = { mark: 'area', encoding: { x: { field: 'q', type: 'quantitative', axis: { tickCount: 4 } }, - y: { field: 'v', type: 'quantitative' }, + // `stack: 'normalize'` makes the y-axis a 0–100% scale, so its labels use + // `config.normalizedNumberFormat` — the mirror for that Formats control. + y: { field: 'v', type: 'quantitative', stack: 'normalize' }, color: { field: 'channel', type: 'nominal' }, }, }, @@ -115,7 +130,7 @@ const area: ThemePreviewSpec = { const scatter: ThemePreviewSpec = { id: 'scatter', - caption: 'Scatter — gradient legend', + caption: 'Points — size, shape, gradient legend', spec: { $schema: SCHEMA, width: 200, @@ -133,7 +148,10 @@ const scatter: ThemePreviewSpec = { { x: 36, y: 20, z: 88 }, ], }, - mark: { type: 'point', filled: true, size: 80 }, + // Bare `mark: 'point'` — size, shape, and fill come from the Marks panel's + // Points group, so those controls have a mirror (a hard-coded size/filled + // here would shadow them). + mark: 'point', encoding: { x: { field: 'x', type: 'quantitative' }, y: { field: 'y', type: 'quantitative' }, @@ -198,7 +216,7 @@ const diverging: ThemePreviewSpec = { const donut: ThemePreviewSpec = { id: 'donut', - caption: 'Donut — palette, symbol legend', + caption: 'Pie & donut — palette, legend', spec: { $schema: SCHEMA, width: 200, @@ -211,7 +229,9 @@ const donut: ThemePreviewSpec = { { browser: 'Other', share: 9 }, ], }, - mark: { type: 'arc', innerRadius: 32 }, + // Bare `mark: 'arc'` renders a pie; the Marks panel's Arc group (donut hole, + // corner radius, pad angle) reshapes it — so those controls have a mirror. + mark: 'arc', encoding: { theta: { field: 'share', type: 'quantitative' }, color: { field: 'browser', type: 'nominal' }, @@ -221,29 +241,30 @@ const donut: ThemePreviewSpec = { const facet: ThemePreviewSpec = { id: 'facet', - caption: 'Facets — header labels', + caption: 'Facets — headers, date format', spec: { $schema: SCHEMA, - width: 70, + width: 80, height: 110, + // Faceted by a raw temporal field (no time unit) so the header labels are + // dates formatted by `config.timeFormat` — the mirror for that Formats + // control (which does NOT affect axes, only text/legend/header labels) — on + // top of the header colour/size/weight the Headers panel styles. data: { values: [ - { team: 'Alpha', month: 'Jan', v: 14 }, - { team: 'Alpha', month: 'Feb', v: 21 }, - { team: 'Alpha', month: 'Mar', v: 17 }, - { team: 'Beta', month: 'Jan', v: 9 }, - { team: 'Beta', month: 'Feb', v: 13 }, - { team: 'Beta', month: 'Mar', v: 19 }, - { team: 'Gamma', month: 'Jan', v: 11 }, - { team: 'Gamma', month: 'Feb', v: 8 }, - { team: 'Gamma', month: 'Mar', v: 15 }, + { period: '2026-01-15', team: 'Alpha', v: 14 }, + { period: '2026-01-15', team: 'Beta', v: 9 }, + { period: '2026-01-15', team: 'Gamma', v: 11 }, + { period: '2026-06-15', team: 'Alpha', v: 21 }, + { period: '2026-06-15', team: 'Beta', v: 13 }, + { period: '2026-06-15', team: 'Gamma', v: 15 }, ], }, mark: 'bar', encoding: { - x: { field: 'month', type: 'nominal', axis: { labelAngle: 0 } }, + x: { field: 'team', type: 'nominal', axis: { labelAngle: 0 } }, y: { field: 'v', type: 'quantitative' }, - column: { field: 'team', type: 'nominal' }, + column: { field: 'period', type: 'temporal' }, }, }, }; diff --git a/src/landing/demo-themes.ts b/src/landing/demo-themes.ts index e7053fd..ac3b989 100644 --- a/src/landing/demo-themes.ts +++ b/src/landing/demo-themes.ts @@ -43,6 +43,10 @@ const EDITORIAL: JsonObject = { titleColor: '#3a3326', }, view: { stroke: 'transparent' }, + // Mark styling lives in the theme (not the shared specs), so the demo charts + // stay rich while the Theme Builder gallery reflects its own config. + point: { filled: true, size: 55 }, + line: { strokeWidth: 2.5 }, range: { category: ['#7a5c3e', '#b07d3e', '#c9a14a', '#5e6b3f', '#9a5a44', '#46544c'], ramp: ['#f0e6d2', '#b07d3e', '#5e3a1e'], @@ -77,6 +81,8 @@ const BLUEPRINT: JsonObject = { titleColor: '#10314f', }, view: { stroke: '#cdd9e5' }, + point: { filled: false, size: 45, strokeWidth: 1.5 }, + line: { strokeWidth: 2, point: true }, range: { category: ['#0d6fb8', '#3aa0d1', '#7cc4e0', '#0d3b66', '#5a8fb3', '#9ec9e0'], ramp: ['#e3eef7', '#3aa0d1', '#0d3b66'], @@ -109,6 +115,8 @@ const SUNSET: JsonObject = { titleColor: '#3a2233', }, view: { stroke: 'transparent' }, + point: { filled: true, size: 70 }, + line: { strokeWidth: 3 }, range: { category: ['#ff6b6b', '#f06595', '#cc5de8', '#845ef7', '#ff922b', '#fcc419'], ramp: ['#ffe3c9', '#ff922b', '#cc5de8'],