Theme Builder: accordion panels across the full config surface, vertical-tab nav, reflective gallery

This commit is contained in:
2026-06-21 17:14:58 +03:00
parent 318a0919c6
commit d76a7a5014
24 changed files with 2420 additions and 845 deletions
+151 -79
View File
@@ -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<GridState>[] = [
// 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<ShowState>[] = [
{ 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: (
<>
<SelectRow
id="axes-grid"
label="Grid lines"
options={showOptions}
value={showState(asBoolean(getConfigValue(config, GRID)))}
onSelect={(s) => set(GRID, s === '' ? undefined : s === 'true')}
/>
<ColorRow
label="Grid color"
value={asString(getConfigValue(config, GRID_COLOR))}
fallback={GRID_GREY}
onChange={(hex) => set(GRID_COLOR, hex)}
onClear={() => set(GRID_COLOR, undefined)}
/>
<SelectRow
id="axes-grid-dash"
label="Grid style"
options={dashOptions}
value={dashStyle(getConfigValue(config, GRID_DASH))}
onSelect={(s) =>
set(GRID_DASH, s === '' ? undefined : DASH_VALUES[s as keyof typeof DASH_VALUES])
}
/>
<NumberRow
label="Grid width"
value={asNumber(getConfigValue(config, GRID_WIDTH))}
min={0}
unit="px"
onChange={(n) => set(GRID_WIDTH, n)}
/>
</>
),
},
{
id: 'ticks',
title: 'Ticks',
hint: 'The marks along the axis line.',
badge: countSet(config, [TICKS, TICK_COLOR, TICK_SIZE]),
children: (
<>
<SelectRow
id="axes-ticks"
label="Ticks"
options={showOptions}
value={showState(asBoolean(getConfigValue(config, TICKS)))}
onSelect={(s) => set(TICKS, s === '' ? undefined : s === 'true')}
/>
<ColorRow
label="Tick color"
value={asString(getConfigValue(config, TICK_COLOR))}
fallback={GRID_GREY}
onChange={(hex) => set(TICK_COLOR, hex)}
onClear={() => set(TICK_COLOR, undefined)}
/>
<NumberRow
label="Tick size"
value={asNumber(getConfigValue(config, TICK_SIZE))}
min={0}
unit="px"
onChange={(n) => 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: (
<>
<ColorRow
label="Domain line"
value={asString(getConfigValue(config, DOMAIN_COLOR))}
fallback={GRID_GREY}
onChange={(hex) => set(DOMAIN_COLOR, hex)}
onClear={() => set(DOMAIN_COLOR, undefined)}
/>
<NumberRow
label="Domain width"
value={asNumber(getConfigValue(config, DOMAIN_WIDTH))}
min={0}
unit="px"
onChange={(n) => set(DOMAIN_WIDTH, n)}
/>
<ColorRow
label="Label color"
value={asString(getConfigValue(config, LABEL_COLOR))}
fallback={GRID_GREY}
onChange={(hex) => set(LABEL_COLOR, hex)}
onClear={() => set(LABEL_COLOR, undefined)}
/>
<NumberRow
label="Label angle"
value={asNumber(getConfigValue(config, LABEL_ANGLE))}
min={-90}
max={90}
unit="°"
onChange={(n) => set(LABEL_ANGLE, n)}
/>
<NumberRow
label="Label padding"
value={asNumber(getConfigValue(config, LABEL_PADDING))}
min={0}
unit="px"
onChange={(n) => set(LABEL_PADDING, n)}
/>
<ColorRow
label="Title color"
value={asString(getConfigValue(config, TITLE_COLOR))}
fallback={GRID_GREY}
onChange={(hex) => set(TITLE_COLOR, hex)}
onClear={() => set(TITLE_COLOR, undefined)}
/>
</>
),
},
];
return (
<div className={styles.panel}>
<ControlSection title="Grid" hint="Reference lines behind the marks.">
<SelectRow
id="axes-grid"
label="Grid lines"
options={gridOptions}
value={gridState(grid)}
onSelect={(s) => set(GRID, s === '' ? undefined : s === 'true')}
/>
<ColorRow
label="Grid color"
value={gridColor}
fallback={GRID_GREY}
onChange={(hex) => set(GRID_COLOR, hex)}
onClear={() => set(GRID_COLOR, undefined)}
/>
<SelectRow
id="axes-grid-dash"
label="Grid style"
options={dashOptions}
value={dash}
onSelect={(s) =>
set(GRID_DASH, s === '' ? undefined : DASH_VALUES[s as keyof typeof DASH_VALUES])
}
/>
</ControlSection>
<ControlSection title="Domain & labels" hint="The axis line, its tick labels, and title.">
<ColorRow
label="Domain line"
value={domainColor}
fallback={GRID_GREY}
onChange={(hex) => set(DOMAIN_COLOR, hex)}
onClear={() => set(DOMAIN_COLOR, undefined)}
/>
<ColorRow
label="Label color"
value={labelColor}
fallback={GRID_GREY}
onChange={(hex) => set(LABEL_COLOR, hex)}
onClear={() => set(LABEL_COLOR, undefined)}
/>
<NumberRow
label="Label angle"
value={labelAngle}
min={-90}
max={90}
unit="°"
onChange={(n) => set(LABEL_ANGLE, n)}
/>
<ColorRow
label="Title color"
value={titleColor}
fallback={GRID_GREY}
onChange={(hex) => set(TITLE_COLOR, hex)}
onClear={() => set(TITLE_COLOR, undefined)}
/>
</ControlSection>
</div>
);
return <Accordion sections={sections} idPrefix="axes" />;
}
+4 -20
View File
@@ -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;
+254 -231
View File
@@ -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 (
<div className={styles.panel}>
{/* ── Categorical palette ─────────────────────────────────────────── */}
<section className={styles.group}>
<h4 className={styles.groupTitle}>Categorical palette</h4>
<p className={styles.hint}>Series colors assigned to discrete categories in order.</p>
<div className={styles.row}>
<SelectControl
id="color-categorical-scheme"
label="Categorical color scheme"
heading="Color scheme"
options={CATEGORICAL_OPTIONS}
value={catScheme ?? undefined}
onSelect={(name) => set(CATEGORY, schemeRange(name))}
triggerContent={
<>
<span className={styles.triggerPreview} aria-hidden="true">
{(catArray ?? (catScheme ? schemeColors(catScheme) : []))
.slice(0, 6)
.map((c, i) => (
<span key={i} className={styles.optSwatch} style={{ background: c }} />
))}
</span>
<span>
{catArray ? `Custom (${catArray.length})` : (catScheme ?? 'Theme default')}
</span>
<span className={styles.caret} aria-hidden="true">
</span>
</>
}
triggerTitle="Pick a named palette"
/>
{catArray ? (
<Button variant="ghost" onClick={() => set(CATEGORY, [...catArray, NEW_SWATCH])}>
Add color
</Button>
) : (
<Button
variant="ghost"
onClick={() => set(CATEGORY, schemeColors(catScheme ?? DEFAULT_CATEGORICAL))}
>
Materialize to edit
</Button>
)}
</div>
{catArray && (
<div className={styles.swatches}>
{catArray.map((color, i) => (
<SwatchRow
key={i}
color={color}
label={`Palette color ${i + 1}`}
onChange={(hex) =>
set(
CATEGORY,
catArray.map((c, j) => (j === i ? hex : c)),
)
}
onRemove={() =>
set(
CATEGORY,
catArray.length === 1 ? undefined : catArray.filter((_, j) => j !== i),
)
}
/>
))}
</div>
)}
</section>
{/* ── Default mark color ──────────────────────────────────────────── */}
<section className={styles.group}>
<h4 className={styles.groupTitle}>Default mark color</h4>
<p className={styles.hint}>
Single-series fill bars, points, and lines with no color encoding.
</p>
<div className={styles.row}>
<SwatchRow
color={markColor || VEGA_DEFAULT_MARK}
label="Default mark color"
onChange={(hex) => set(MARK_COLOR, hex)}
/>
{markColor ? (
<Button variant="ghost" onClick={() => set(MARK_COLOR, undefined)}>
Clear
</Button>
) : (
<span className={styles.hint}>Unset Vega default ({VEGA_DEFAULT_MARK})</span>
)}
</div>
</section>
{/* ── Sequential gradient ─────────────────────────────────────────── */}
<section className={styles.group}>
<h4 className={styles.groupTitle}>Sequential gradient</h4>
<p className={styles.hint}>Continuous color heatmaps and quantitative legends.</p>
<div className={styles.row}>
<span
className={styles.gradientPreview}
aria-hidden="true"
style={{ background: gradientCss(previewStops(seqArray, seqScheme)) }}
/>
<SelectControl
id="color-sequential-scheme"
label="Sequential color scheme"
heading="Sequential scheme"
options={SEQUENTIAL_OPTIONS}
value={seqScheme ?? undefined}
onSelect={(name) => setSeq(schemeRange(name))}
triggerContent={
<>
<span>
{seqArray ? `Custom (${seqArray.length})` : (seqScheme ?? 'Theme default')}
</span>
<span className={styles.caret} aria-hidden="true">
</span>
</>
}
/>
{seqArray ? (
<Button variant="ghost" onClick={() => setSeq([...seqArray, NEW_SWATCH])}>
Add stop
</Button>
) : (
<Button
variant="ghost"
onClick={() => setSeq(schemeColors(seqScheme ?? DEFAULT_SEQUENTIAL, GRADIENT_STOPS))}
>
Materialize to edit
</Button>
)}
{(seqArray || seqScheme) && (
<Button variant="ghost" onClick={() => setSeq(undefined)}>
Clear
</Button>
)}
</div>
{seqArray && (
<div className={styles.swatches}>
{seqArray.map((color, i) => (
<SwatchRow
key={i}
color={color}
label={`Sequential stop ${i + 1}`}
onChange={(hex) => setSeq(seqArray.map((c, j) => (j === i ? hex : c)))}
onRemove={() =>
setSeq(seqArray.length === 1 ? undefined : seqArray.filter((_, j) => j !== i))
}
/>
))}
</div>
)}
</section>
{/* ── Diverging gradient ──────────────────────────────────────────── */}
<section className={styles.group}>
<h4 className={styles.groupTitle}>Diverging gradient</h4>
<p className={styles.hint}>Two-ended color values around a meaningful midpoint.</p>
<div className={styles.row}>
<span
className={styles.gradientPreview}
aria-hidden="true"
style={{ background: gradientCss(previewStops(divArray, divScheme)) }}
/>
<SelectControl
id="color-diverging-scheme"
label="Diverging color scheme"
heading="Diverging scheme"
options={DIVERGING_OPTIONS}
value={divScheme ?? undefined}
onSelect={(name) => set(DIVERGING, schemeRange(name))}
triggerContent={
<>
<span>
{divArray ? `Custom (${divArray.length})` : (divScheme ?? 'Theme default')}
</span>
<span className={styles.caret} aria-hidden="true">
</span>
</>
}
/>
{divArray ? (
<Button variant="ghost" onClick={() => set(DIVERGING, [...divArray, NEW_SWATCH])}>
Add stop
</Button>
) : (
<Button
variant="ghost"
onClick={() =>
set(DIVERGING, schemeColors(divScheme ?? DEFAULT_DIVERGING, GRADIENT_STOPS))
const sections: AccordionSection[] = [
{
id: 'categorical',
title: 'Categorical palette',
hint: 'Series colors — assigned to discrete categories in order.',
badge: countSet(config, [CATEGORY]),
children: (
<>
<div className={styles.row}>
<SelectControl
id="color-categorical-scheme"
label="Categorical color scheme"
heading="Color scheme"
options={CATEGORICAL_OPTIONS}
value={catScheme ?? undefined}
onSelect={(name) => set(CATEGORY, schemeRange(name))}
triggerContent={
<>
<span className={styles.triggerPreview} aria-hidden="true">
{(catArray ?? (catScheme ? schemeColors(catScheme) : []))
.slice(0, 6)
.map((c, i) => (
<span key={i} className={styles.optSwatch} style={{ background: c }} />
))}
</span>
<span>
{catArray ? `Custom (${catArray.length})` : (catScheme ?? 'Theme default')}
</span>
<span className={styles.caret} aria-hidden="true">
</span>
</>
}
>
Materialize to edit
</Button>
)}
{(divArray || divScheme) && (
<Button variant="ghost" onClick={() => set(DIVERGING, undefined)}>
Clear
</Button>
)}
</div>
{divArray && (
<div className={styles.swatches}>
{divArray.map((color, i) => (
<SwatchRow
key={i}
color={color}
label={`Diverging stop ${i + 1}`}
onChange={(hex) =>
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 ? (
<Button variant="ghost" onClick={() => set(CATEGORY, [...catArray, NEW_SWATCH])}>
Add color
</Button>
) : (
<Button
variant="ghost"
onClick={() => set(CATEGORY, schemeColors(catScheme ?? DEFAULT_CATEGORICAL))}
>
Materialize to edit
</Button>
)}
</div>
)}
</section>
</div>
);
{catArray && (
<div className={styles.swatches}>
{catArray.map((color, i) => (
<SwatchRow
key={i}
color={color}
label={`Palette color ${i + 1}`}
onChange={(hex) =>
set(
CATEGORY,
catArray.map((c, j) => (j === i ? hex : c)),
)
}
onRemove={() =>
set(
CATEGORY,
catArray.length === 1 ? undefined : catArray.filter((_, j) => j !== i),
)
}
/>
))}
</div>
)}
</>
),
},
{
id: 'markColor',
title: 'Default mark color',
hint: 'Single-series fill — bars, points, and lines with no color encoding.',
badge: countSet(config, [MARK_COLOR]),
children: (
<>
<div className={styles.row}>
<SwatchRow
color={markColor || VEGA_DEFAULT_MARK}
label="Default mark color"
onChange={(hex) => set(MARK_COLOR, hex)}
/>
{markColor ? (
<Button variant="ghost" onClick={() => set(MARK_COLOR, undefined)}>
Clear
</Button>
) : (
<span className={styles.hint}>Unset Vega default ({VEGA_DEFAULT_MARK})</span>
)}
</div>
</>
),
},
{
id: 'sequential',
title: 'Sequential gradient',
hint: 'Continuous color — heatmaps and quantitative legends.',
badge: countSet(config, [HEATMAP]),
children: (
<>
<div className={styles.row}>
<span
className={styles.gradientPreview}
aria-hidden="true"
style={{ background: gradientCss(previewStops(seqArray, seqScheme)) }}
/>
<SelectControl
id="color-sequential-scheme"
label="Sequential color scheme"
heading="Sequential scheme"
options={SEQUENTIAL_OPTIONS}
value={seqScheme ?? undefined}
onSelect={(name) => setSeq(schemeRange(name))}
triggerContent={
<>
<span>
{seqArray ? `Custom (${seqArray.length})` : (seqScheme ?? 'Theme default')}
</span>
<span className={styles.caret} aria-hidden="true">
</span>
</>
}
/>
{seqArray ? (
<Button variant="ghost" onClick={() => setSeq([...seqArray, NEW_SWATCH])}>
Add stop
</Button>
) : (
<Button
variant="ghost"
onClick={() =>
setSeq(schemeColors(seqScheme ?? DEFAULT_SEQUENTIAL, GRADIENT_STOPS))
}
>
Materialize to edit
</Button>
)}
{(seqArray || seqScheme) && (
<Button variant="ghost" onClick={() => setSeq(undefined)}>
Clear
</Button>
)}
</div>
{seqArray && (
<div className={styles.swatches}>
{seqArray.map((color, i) => (
<SwatchRow
key={i}
color={color}
label={`Sequential stop ${i + 1}`}
onChange={(hex) => setSeq(seqArray.map((c, j) => (j === i ? hex : c)))}
onRemove={() =>
setSeq(seqArray.length === 1 ? undefined : seqArray.filter((_, j) => j !== i))
}
/>
))}
</div>
)}
</>
),
},
{
id: 'diverging',
title: 'Diverging gradient',
hint: 'Two-ended color — values around a meaningful midpoint.',
badge: countSet(config, [DIVERGING]),
children: (
<>
<div className={styles.row}>
<span
className={styles.gradientPreview}
aria-hidden="true"
style={{ background: gradientCss(previewStops(divArray, divScheme)) }}
/>
<SelectControl
id="color-diverging-scheme"
label="Diverging color scheme"
heading="Diverging scheme"
options={DIVERGING_OPTIONS}
value={divScheme ?? undefined}
onSelect={(name) => set(DIVERGING, schemeRange(name))}
triggerContent={
<>
<span>
{divArray ? `Custom (${divArray.length})` : (divScheme ?? 'Theme default')}
</span>
<span className={styles.caret} aria-hidden="true">
</span>
</>
}
/>
{divArray ? (
<Button variant="ghost" onClick={() => set(DIVERGING, [...divArray, NEW_SWATCH])}>
Add stop
</Button>
) : (
<Button
variant="ghost"
onClick={() =>
set(DIVERGING, schemeColors(divScheme ?? DEFAULT_DIVERGING, GRADIENT_STOPS))
}
>
Materialize to edit
</Button>
)}
{(divArray || divScheme) && (
<Button variant="ghost" onClick={() => set(DIVERGING, undefined)}>
Clear
</Button>
)}
</div>
{divArray && (
<div className={styles.swatches}>
{divArray.map((color, i) => (
<SwatchRow
key={i}
color={color}
label={`Diverging stop ${i + 1}`}
onChange={(hex) =>
set(
DIVERGING,
divArray.map((c, j) => (j === i ? hex : c)),
)
}
onRemove={() =>
set(
DIVERGING,
divArray.length === 1 ? undefined : divArray.filter((_, j) => j !== i),
)
}
/>
))}
</div>
)}
</>
),
},
];
return <Accordion sections={sections} idPrefix="color" />;
}
+124
View File
@@ -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 (
<div className={styles.presets}>
{presets.map((p) => (
<Button key={p.code} variant="ghost" onClick={() => onPick(p.code)} title={p.code}>
{p.sample}
</Button>
))}
</div>
);
}
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: (
<>
<TextRow
label="Format"
name="Number format"
value={asString(getConfigValue(config, NUMBER))}
placeholder="e.g. $,.2f"
onChange={(v) => set(NUMBER, v)}
/>
<Presets presets={NUMBER_PRESETS} onPick={(code) => set(NUMBER, code)} />
<TextRow
label="Stacked %"
name="Normalized number format"
value={asString(getConfigValue(config, NORMALIZED))}
placeholder="e.g. .0%"
onChange={(v) => set(NORMALIZED, v)}
/>
</>
),
},
{
id: 'dates',
title: 'Dates',
hint: 'd3-time-format pattern for raw time values.',
badge: countSet(config, [TIME]),
children: (
<>
<TextRow
label="Format"
name="Date format"
value={asString(getConfigValue(config, TIME))}
placeholder="e.g. %b %Y"
onChange={(v) => set(TIME, v)}
/>
<Presets presets={TIME_PRESETS} onPick={(code) => set(TIME, code)} />
</>
),
},
{
id: 'labels',
title: 'Labels',
badge: countSet(config, [COUNT_TITLE]),
children: (
<TextRow
label="Count title"
name="Count title"
value={asString(getConfigValue(config, COUNT_TITLE))}
placeholder="Count of Records"
onChange={(v) => set(COUNT_TITLE, v)}
/>
),
},
];
return <Accordion sections={sections} idPrefix="formats" />;
}
+104
View File
@@ -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: (
<>
<ColorRow
label="Color"
name="Header title color"
value={asString(getConfigValue(config, TITLE_COLOR))}
fallback={TEXT_GREY}
onChange={(hex) => set(TITLE_COLOR, hex)}
onClear={() => set(TITLE_COLOR, undefined)}
/>
<NumberRow
label="Size"
name="Header title size"
value={asNumber(getConfigValue(config, TITLE_SIZE))}
min={0}
unit="px"
onChange={(n) => set(TITLE_SIZE, n)}
/>
<WeightRow
id="header-title-weight"
label="Weight"
name="Header title weight"
value={getConfigValue(config, TITLE_WEIGHT)}
onChange={(w) => 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: (
<>
<ColorRow
label="Color"
name="Header label color"
value={asString(getConfigValue(config, LABEL_COLOR))}
fallback={TEXT_GREY}
onChange={(hex) => set(LABEL_COLOR, hex)}
onClear={() => set(LABEL_COLOR, undefined)}
/>
<NumberRow
label="Size"
name="Header label size"
value={asNumber(getConfigValue(config, LABEL_SIZE))}
min={0}
unit="px"
onChange={(n) => set(LABEL_SIZE, n)}
/>
<WeightRow
id="header-label-weight"
label="Weight"
name="Header label weight"
value={getConfigValue(config, LABEL_WEIGHT)}
onChange={(w) => set(LABEL_WEIGHT, w)}
/>
</>
),
},
];
return <Accordion sections={sections} idPrefix="headers" />;
}
+132 -74
View File
@@ -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 (
<div className={styles.panel}>
<ControlSection title="Background" hint="Fill behind the whole chart, padding included.">
<SelectRow
id="layout-bg-mode"
label="Background"
options={bgOptions}
value={bgMode}
onSelect={(m) => set(BACKGROUND, fillValue(m, bg, '#ffffff'))}
/>
{bgMode === 'custom' && (
const sections: AccordionSection[] = [
{
id: 'background',
title: 'Background',
hint: 'Fill behind the whole chart, padding included.',
badge: countSet(config, [BACKGROUND]),
children: (
<>
<SelectRow
id="layout-bg-mode"
label="Background"
options={bgOptions}
value={bgMode}
onSelect={(m) => set(BACKGROUND, fillValue(m, bg, '#ffffff'))}
/>
{bgMode === 'custom' && (
<ColorRow
label="Color"
name="Background color"
value={bg}
fallback="#ffffff"
onChange={(hex) => set(BACKGROUND, hex)}
/>
)}
</>
),
},
{
id: 'plot',
title: 'Plot area',
hint: 'The plotting rectangle inside the axes.',
badge: countSet(config, [VIEW_FILL, VIEW_STROKE, VIEW_RADIUS]),
children: (
<>
<ColorRow
label="Color"
name="Background color"
value={bg}
label="Fill"
name="Plot area fill"
value={viewFill}
fallback="#ffffff"
onChange={(hex) => set(BACKGROUND, hex)}
onChange={(hex) => set(VIEW_FILL, hex)}
onClear={() => set(VIEW_FILL, undefined)}
/>
)}
</ControlSection>
<ControlSection title="Plot area" hint="The plotting rectangle inside the axes.">
<ColorRow
label="Fill"
name="Plot area fill"
value={viewFill}
fallback="#ffffff"
onChange={(hex) => set(VIEW_FILL, hex)}
onClear={() => set(VIEW_FILL, undefined)}
/>
<SelectRow
id="layout-stroke-mode"
label="Border"
options={strokeOptions}
value={strokeMode}
onSelect={(m) => set(VIEW_STROKE, fillValue(m, stroke, '#cccccc'))}
/>
{strokeMode === 'custom' && (
<ColorRow
label="Border color"
value={stroke}
fallback="#cccccc"
onChange={(hex) => set(VIEW_STROKE, hex)}
<SelectRow
id="layout-stroke-mode"
label="Border"
options={strokeOptions}
value={strokeMode}
onSelect={(m) => set(VIEW_STROKE, fillValue(m, stroke, '#cccccc'))}
/>
)}
<NumberRow
label="Corner radius"
value={radius}
min={0}
unit="px"
onChange={(n) => set(VIEW_RADIUS, n)}
/>
</ControlSection>
<ControlSection title="Spacing" hint="Margin between the chart and its container edge.">
{isJsonObject(padding) ? (
<p className={styles.hint}>
Padding is set per-side as an object edit it in the JSON below.
</p>
) : (
{strokeMode === 'custom' && (
<ColorRow
label="Border color"
value={stroke}
fallback="#cccccc"
onChange={(hex) => set(VIEW_STROKE, hex)}
/>
)}
<NumberRow
label="Padding"
value={paddingNum}
label="Corner radius"
value={radius}
min={0}
unit="px"
onChange={(n) => set(PADDING, n)}
onChange={(n) => set(VIEW_RADIUS, n)}
/>
)}
</ControlSection>
</div>
);
</>
),
},
{
id: 'spacing',
title: 'Spacing',
hint: 'Margin between the chart and its container edge.',
badge: countSet(config, [PADDING]),
children: isJsonObject(padding) ? (
<p className={styles.hint}>
Padding is set per-side as an object edit it in the JSON below.
</p>
) : (
<NumberRow
label="Padding"
value={paddingNum}
min={0}
unit="px"
onChange={(n) => set(PADDING, n)}
/>
),
},
{
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: (
<>
<NumberRow
label="Width"
name="Default width"
value={asNumber(getConfigValue(config, VIEW_WIDTH))}
min={0}
unit="px"
onChange={(n) => set(VIEW_WIDTH, n)}
/>
<NumberRow
label="Height"
name="Default height"
value={asNumber(getConfigValue(config, VIEW_HEIGHT))}
min={0}
unit="px"
onChange={(n) => set(VIEW_HEIGHT, n)}
/>
<NumberRow
label="Step"
name="Discrete step"
value={asNumber(getConfigValue(config, VIEW_STEP))}
min={0}
unit="px"
onChange={(n) => set(VIEW_STEP, n)}
/>
</>
),
},
];
return <Accordion sections={sections} idPrefix="layout" />;
}
+218 -78
View File
@@ -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<Orient>[] = [
{ 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<Direction>[] = [
{ 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<SymbolType>[] = [
{ 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: (
<>
<SelectRow
id="legend-orient"
label="Position"
options={orientOptions}
value={enumValue(getConfigValue(config, ORIENT), orientOptions)}
onSelect={(o) => set(ORIENT, o === '' ? undefined : o)}
/>
<SelectRow
id="legend-direction"
label="Direction"
options={directionOptions}
value={enumValue(getConfigValue(config, DIRECTION), directionOptions)}
onSelect={(d) => set(DIRECTION, d === '' ? undefined : d)}
/>
<NumberRow
label="Columns"
name="Legend columns"
value={asNumber(getConfigValue(config, COLUMNS))}
min={0}
onChange={(n) => set(COLUMNS, n)}
/>
</>
),
},
{
id: 'title',
title: 'Title',
badge: countSet(config, [TITLE_COLOR, TITLE_SIZE]),
children: (
<>
<ColorRow
label="Color"
name="Legend title color"
value={asString(getConfigValue(config, TITLE_COLOR))}
fallback={TEXT_GREY}
onChange={(hex) => set(TITLE_COLOR, hex)}
onClear={() => set(TITLE_COLOR, undefined)}
/>
<NumberRow
label="Size"
name="Legend title size"
value={asNumber(getConfigValue(config, TITLE_SIZE))}
min={0}
unit="px"
onChange={(n) => set(TITLE_SIZE, n)}
/>
</>
),
},
{
id: 'labels',
title: 'Labels',
badge: countSet(config, [LABEL_COLOR, LABEL_SIZE]),
children: (
<>
<ColorRow
label="Color"
name="Legend label color"
value={asString(getConfigValue(config, LABEL_COLOR))}
fallback={TEXT_GREY}
onChange={(hex) => set(LABEL_COLOR, hex)}
onClear={() => set(LABEL_COLOR, undefined)}
/>
<NumberRow
label="Size"
name="Legend label size"
value={asNumber(getConfigValue(config, LABEL_SIZE))}
min={0}
unit="px"
onChange={(n) => set(LABEL_SIZE, n)}
/>
</>
),
},
{
id: 'symbols',
title: 'Symbols',
hint: 'The colored keys beside each label.',
badge: countSet(config, [SYMBOL_TYPE, SYMBOL_SIZE]),
children: (
<>
<SelectRow
id="legend-symbol-type"
label="Shape"
name="Legend symbol shape"
options={symbolTypeOptions}
value={enumValue(getConfigValue(config, SYMBOL_TYPE), symbolTypeOptions)}
onSelect={(t) => set(SYMBOL_TYPE, t === '' ? undefined : t)}
/>
<NumberRow
label="Symbol size"
value={asNumber(getConfigValue(config, SYMBOL_SIZE))}
min={0}
unit="px²"
onChange={(n) => set(SYMBOL_SIZE, n)}
/>
</>
),
},
{
id: 'gradient',
title: 'Gradient',
hint: 'The continuous color bar (quantitative legends).',
badge: countSet(config, [GRADIENT_LENGTH, GRADIENT_THICKNESS]),
children: (
<>
<NumberRow
label="Length"
name="Gradient length"
value={asNumber(getConfigValue(config, GRADIENT_LENGTH))}
min={0}
unit="px"
onChange={(n) => set(GRADIENT_LENGTH, n)}
/>
<NumberRow
label="Thickness"
name="Gradient thickness"
value={asNumber(getConfigValue(config, GRADIENT_THICKNESS))}
min={0}
unit="px"
onChange={(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: (
<>
<ColorRow
label="Fill"
name="Legend fill"
value={asString(getConfigValue(config, FILL_COLOR))}
fallback="#ffffff"
onChange={(hex) => set(FILL_COLOR, hex)}
onClear={() => set(FILL_COLOR, undefined)}
/>
<ColorRow
label="Border"
name="Legend border"
value={asString(getConfigValue(config, STROKE_COLOR))}
fallback="#cccccc"
onChange={(hex) => set(STROKE_COLOR, hex)}
onClear={() => set(STROKE_COLOR, undefined)}
/>
<NumberRow
label="Corner radius"
name="Legend corner radius"
value={asNumber(getConfigValue(config, CORNER_RADIUS))}
min={0}
unit="px"
onChange={(n) => set(CORNER_RADIUS, n)}
/>
<NumberRow
label="Padding"
name="Legend padding"
value={asNumber(getConfigValue(config, PADDING))}
min={0}
unit="px"
onChange={(n) => set(PADDING, n)}
/>
</>
),
},
];
return (
<div className={styles.panel}>
<ControlSection title="Placement" hint="Where the legend sits relative to the plot.">
<SelectRow
id="legend-orient"
label="Position"
options={orientOptions}
value={orient}
onSelect={(o) => set(ORIENT, o === '' ? undefined : o)}
/>
</ControlSection>
<ControlSection title="Title">
<ColorRow
label="Color"
name="Legend title color"
value={titleColor}
fallback={TEXT_GREY}
onChange={(hex) => set(TITLE_COLOR, hex)}
onClear={() => set(TITLE_COLOR, undefined)}
/>
<NumberRow
label="Size"
name="Legend title size"
value={titleSize}
min={0}
unit="px"
onChange={(n) => set(TITLE_SIZE, n)}
/>
</ControlSection>
<ControlSection title="Labels">
<ColorRow
label="Color"
name="Legend label color"
value={labelColor}
fallback={TEXT_GREY}
onChange={(hex) => set(LABEL_COLOR, hex)}
onClear={() => set(LABEL_COLOR, undefined)}
/>
<NumberRow
label="Size"
name="Legend label size"
value={labelSize}
min={0}
unit="px"
onChange={(n) => set(LABEL_SIZE, n)}
/>
</ControlSection>
<ControlSection title="Symbols" hint="The colored keys beside each label.">
<NumberRow
label="Symbol size"
value={symbolSize}
min={0}
unit="px²"
onChange={(n) => set(SYMBOL_SIZE, n)}
/>
</ControlSection>
</div>
);
return <Accordion sections={sections} idPrefix="legend" />;
}
+347
View File
@@ -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<Interpolate>[] = [
{ 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<Shape>[] = [
{ 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<S>[] = [
{ value: '', label: 'Theme default' },
{ value: 'true', label: trueLabel },
{ value: 'false', label: falseLabel },
];
const s: S = value === undefined ? '' : value ? 'true' : 'false';
return (
<SelectRow
id={id}
label={label}
options={options}
value={s}
onSelect={(v) => 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: (
<>
<NumberRow
label="Opacity"
name="Mark opacity"
value={num(['mark', 'opacity'])}
min={0}
max={1}
step={0.1}
onChange={(n) => set(['mark', 'opacity'], n)}
/>
<BoolRow
id="marks-filled"
label="Fill style"
value={bool(['mark', 'filled'])}
onChange={(b) => set(['mark', 'filled'], b)}
trueLabel="Filled"
falseLabel="Outlined"
/>
<BoolRow
id="marks-tooltip"
label="Tooltips"
value={bool(['mark', 'tooltip'])}
onChange={(b) => set(['mark', 'tooltip'], b)}
trueLabel="On"
falseLabel="Off"
/>
</>
),
},
{
id: 'bar',
title: 'Bars',
badge: countSet(config, BAR_PATHS),
children: (
<>
<NumberRow
label="Corner radius"
name="Bar corner radius"
value={num(['bar', 'cornerRadiusEnd'])}
min={0}
unit="px"
onChange={(n) => set(['bar', 'cornerRadiusEnd'], n)}
/>
<NumberRow
label="Bar width"
name="Bar width"
// discreteBandSize = bar thickness on a categorical axis (the common
// case, and what the gallery's nominal-axis bars show); continuous-axis
// bars (continuousBandSize) stay in the JSON.
value={num(['bar', 'discreteBandSize'])}
min={0}
unit="px"
onChange={(n) => set(['bar', 'discreteBandSize'], n)}
/>
<NumberRow
label="Opacity"
name="Bar opacity"
value={num(['bar', 'opacity'])}
min={0}
max={1}
step={0.1}
onChange={(n) => set(['bar', 'opacity'], n)}
/>
</>
),
},
{
id: 'lineArea',
title: 'Lines & areas',
badge: countSet(config, LINE_AREA_PATHS),
children: (
<>
<SelectRow
id="marks-line-interpolate"
label="Line curve"
options={interpolateOptions}
value={enumValue(getConfigValue(config, ['line', 'interpolate']), interpolateOptions)}
onSelect={(v) => set(['line', 'interpolate'], v === '' ? undefined : v)}
/>
<NumberRow
label="Line width"
name="Line width"
value={num(['line', 'strokeWidth'])}
min={0}
unit="px"
onChange={(n) => set(['line', 'strokeWidth'], n)}
/>
<BoolRow
id="marks-line-point"
label="Line markers"
value={bool(['line', 'point'])}
onChange={(b) => set(['line', 'point'], b)}
trueLabel="Show"
falseLabel="Hide"
/>
<NumberRow
label="Area opacity"
name="Area opacity"
value={num(['area', 'opacity'])}
min={0}
max={1}
step={0.1}
onChange={(n) => set(['area', 'opacity'], n)}
/>
<BoolRow
id="marks-area-line"
label="Area outline"
value={bool(['area', 'line'])}
onChange={(b) => set(['area', 'line'], b)}
trueLabel="Show"
falseLabel="Hide"
/>
</>
),
},
{
id: 'point',
title: 'Points',
badge: countSet(config, POINT_PATHS),
children: (
<>
<NumberRow
label="Size"
name="Point size"
value={num(['point', 'size'])}
min={0}
onChange={(n) => set(['point', 'size'], n)}
/>
<SelectRow
id="marks-point-shape"
label="Shape"
name="Point shape"
options={shapeOptions}
value={enumValue(getConfigValue(config, ['point', 'shape']), shapeOptions)}
onSelect={(v) => set(['point', 'shape'], v === '' ? undefined : v)}
/>
<BoolRow
id="marks-point-filled"
label="Fill style"
value={bool(['point', 'filled'])}
onChange={(b) => set(['point', 'filled'], b)}
trueLabel="Filled"
falseLabel="Outlined"
/>
<NumberRow
label="Opacity"
name="Point opacity"
value={num(['point', 'opacity'])}
min={0}
max={1}
step={0.1}
onChange={(n) => set(['point', 'opacity'], n)}
/>
</>
),
},
{
id: 'arc',
title: 'Arc (pie & donut)',
badge: countSet(config, ARC_PATHS),
children: (
<>
<NumberRow
label="Donut hole"
name="Donut hole radius"
value={num(['arc', 'innerRadius'])}
min={0}
unit="px"
onChange={(n) => set(['arc', 'innerRadius'], n)}
/>
<NumberRow
label="Corner radius"
name="Arc corner radius"
value={num(['arc', 'cornerRadius'])}
min={0}
unit="px"
onChange={(n) => set(['arc', 'cornerRadius'], n)}
/>
<NumberRow
label="Pad angle"
name="Arc pad angle"
value={num(['arc', 'padAngle'])}
min={0}
step={0.01}
onChange={(n) => set(['arc', 'padAngle'], n)}
/>
</>
),
},
];
return <Accordion sections={sections} idPrefix="marks" />;
}
+30 -13
View File
@@ -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 {
+69 -56
View File
@@ -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<HTMLDivElement>) => {
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() {
<div className={styles.body}>
<div className={styles.controlsCol}>
<div
className={styles.tabs}
role="tablist"
aria-label="Theme controls"
onKeyDown={onTabKeyDown}
>
{THEME_TABS.map((t) => (
<button
key={t.id}
type="button"
role="tab"
id={`theme-tab-${t.id}`}
aria-selected={activeTab === t.id}
aria-controls={`theme-tabpanel-${t.id}`}
tabIndex={activeTab === t.id ? 0 : -1}
className={`${styles.tab} ${activeTab === t.id ? styles.tabActive : ''}`}
onClick={() => setActiveTab(t.id)}
>
{t.label}
</button>
))}
</div>
{(saveError ?? parseError) !== null && (
<p className={styles.errorMessage} role="alert">
{saveError ?? parseError}
</p>
)}
<div
className={styles.tabpanel}
role="tabpanel"
id={`theme-tabpanel-${activeTab}`}
aria-labelledby={`theme-tab-${activeTab}`}
>
{parseError !== null ? (
<p className={styles.controlsDisabled}>
Fix the JSON below to use these controls.
</p>
) : draftConfig === null ? null : activeTab === 'color' ? (
<ColorControls config={draftConfig} />
) : activeTab === 'type' ? (
<TypeControls config={draftConfig} />
) : activeTab === 'layout' ? (
<LayoutControls config={draftConfig} />
) : activeTab === 'axes' ? (
<AxesControls config={draftConfig} />
) : (
<LegendControls config={draftConfig} />
)}
<div className={styles.vsplit}>
<div
className={styles.tabs}
role="tablist"
aria-label="Theme controls"
aria-orientation="vertical"
onKeyDown={onTabKeyDown}
>
{THEME_TABS.map((t) => (
<button
key={t.id}
type="button"
role="tab"
id={`theme-tab-${t.id}`}
aria-selected={activeTab === t.id}
aria-controls={`theme-tabpanel-${t.id}`}
tabIndex={activeTab === t.id ? 0 : -1}
className={`${styles.tab} ${activeTab === t.id ? styles.tabActive : ''}`}
onClick={() => setActiveTab(t.id)}
>
{t.label}
</button>
))}
</div>
<div
className={styles.tabpanel}
role="tabpanel"
id={`theme-tabpanel-${activeTab}`}
aria-labelledby={`theme-tab-${activeTab}`}
>
{parseError !== null ? (
<p className={styles.controlsDisabled}>
Fix the JSON below to use these controls.
</p>
) : draftConfig === null ? null : (
<ActivePanel config={draftConfig} />
)}
</div>
</div>
{/* Raw JSON — collapsed by default (the structured controls are the
+152 -7
View File
@@ -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);
});
});
+96 -26
View File
@@ -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);
}
+210 -44
View File
@@ -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 (
<section className={styles.group} role="group" aria-labelledby={headingId}>
<h4 id={headingId} className={styles.groupTitle}>
{title}
</h4>
{hint && <p className={styles.hint}>{hint}</p>}
<div className={styles.fields}>{children}</div>
</section>
);
}
/**
* A labelled color. When unset, the swatch shows `fallback` (the rendered
* default) with a "Default" note rather than a blank chip; once set, a Clear
@@ -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 `<button>` inside a heading, toggling a
* `role="region"` panel via `aria-expanded`/`aria-controls`; Up/Down move between
* headers, Home/End jump to the ends. An optional `badge` (count of set
* properties) makes overrides scannable while collapsed (NN/g recognition).
*/
export interface AccordionSection {
id: string;
title: string;
/** Count of set properties in this section, shown collapsed as a recognition cue. */
badge?: number;
/** One-line description, shown at the top of the open panel. */
hint?: string;
children: ReactNode;
}
export function Accordion({
sections,
idPrefix,
defaultOpenId,
}: {
sections: AccordionSection[];
/** Namespaces the header/panel ids so multiple accordions can't collide. */
idPrefix: string;
/** Section open on first render; defaults to the first. */
defaultOpenId?: string;
}) {
const [openId, setOpenId] = useState<string | null>(defaultOpenId ?? sections[0]?.id ?? null);
const containerRef = useRef<HTMLDivElement>(null);
const focusHeader = (index: number) => {
const headers =
containerRef.current?.querySelectorAll<HTMLButtonElement>('[data-accordion-header]');
if (!headers || headers.length === 0) return;
const n = headers.length;
headers[((index % n) + n) % n].focus();
};
const onHeaderKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>, i: number) => {
const move =
e.key === 'ArrowDown'
? i + 1
: e.key === 'ArrowUp'
? i - 1
: e.key === 'Home'
? 0
: e.key === 'End'
? sections.length - 1
: null;
if (move === null) return;
e.preventDefault();
focusHeader(move);
};
return (
<div className={styles.accordion} ref={containerRef}>
{sections.map((section, i) => {
const open = section.id === openId;
const headerId = `${idPrefix}-acc-h-${section.id}`;
const panelId = `${idPrefix}-acc-p-${section.id}`;
return (
<div key={section.id} className={styles.accSection}>
<h4 className={styles.accHeading}>
<button
type="button"
data-accordion-header
id={headerId}
className={styles.accHeader}
aria-expanded={open}
aria-controls={panelId}
onClick={() => setOpenId(open ? null : section.id)}
onKeyDown={(e) => onHeaderKeyDown(e, i)}
>
<span className={styles.accCaret} aria-hidden="true">
{open ? '▾' : '▸'}
</span>
<span className={styles.accTitle}>{section.title}</span>
{section.badge ? (
<span className={styles.accBadge} aria-label={`${section.badge} set`}>
{section.badge}
</span>
) : null}
</button>
</h4>
<div
id={panelId}
role="region"
aria-labelledby={headerId}
className={styles.accPanel}
hidden={!open}
>
{section.hint && <p className={styles.hint}>{section.hint}</p>}
{section.children}
</div>
</div>
);
})}
</div>
);
}
/**
* A labelled enum, on the app's SelectControl. The caller maps the config value
* to/from option strings; an unset config key is conventionally the `''` option
@@ -193,3 +267,95 @@ export function SelectRow<V extends string>({
</div>
);
}
/**
* 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 (
<div className={styles.field}>
<span className={styles.fieldLabel}>{label}</span>
<div className={styles.control}>
<input
type="text"
className={styles.text}
aria-label={name ?? label}
value={value ?? ''}
placeholder={placeholder}
spellCheck={false}
onChange={(e) => onChange(e.target.value === '' ? undefined : e.target.value)}
/>
</div>
</div>
);
}
/**
* 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<string>[] = [
{ 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 (
<SelectRow
id={id}
label={label}
name={name}
options={WEIGHT_OPTIONS}
value={weightOption(value)}
onSelect={(w) => onChange(w === '' ? undefined : Number(w))}
/>
);
}
+177
View File
@@ -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<Anchor>[] = [
{ value: '', label: 'Theme default' },
{ value: 'start', label: 'Left' },
{ value: 'middle', label: 'Center' },
{ value: 'end', label: 'Right' },
];
type FontStyle = '' | 'normal' | 'italic';
const styleOptions: SelectControlOption<FontStyle>[] = [
{ 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: (
<>
<SelectRow
id="title-anchor"
label="Alignment"
options={anchorOptions}
value={enumValue(getConfigValue(config, ANCHOR), anchorOptions)}
onSelect={(a) => set(ANCHOR, a === '' ? undefined : a)}
/>
<ColorRow
label="Color"
name="Title color"
value={asString(getConfigValue(config, COLOR))}
fallback={TEXT_GREY}
onChange={(hex) => set(COLOR, hex)}
onClear={() => set(COLOR, undefined)}
/>
<NumberRow
label="Size"
name="Title size"
value={asNumber(getConfigValue(config, SIZE))}
min={0}
unit="px"
onChange={(n) => set(SIZE, n)}
/>
<WeightRow
id="title-weight"
label="Weight"
name="Title weight"
value={getConfigValue(config, WEIGHT)}
onChange={(w) => set(WEIGHT, w)}
/>
<SelectRow
id="title-style"
label="Style"
name="Title style"
options={styleOptions}
value={enumValue(getConfigValue(config, STYLE), styleOptions)}
onSelect={(s) => set(STYLE, s === '' ? undefined : s)}
/>
<NumberRow
label="Offset"
name="Title offset"
value={asNumber(getConfigValue(config, OFFSET))}
min={0}
unit="px"
onChange={(n) => 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: (
<>
<ColorRow
label="Color"
name="Subtitle color"
value={asString(getConfigValue(config, SUB_COLOR))}
fallback={TEXT_GREY}
onChange={(hex) => set(SUB_COLOR, hex)}
onClear={() => set(SUB_COLOR, undefined)}
/>
<NumberRow
label="Size"
name="Subtitle size"
value={asNumber(getConfigValue(config, SUB_SIZE))}
min={0}
unit="px"
onChange={(n) => set(SUB_SIZE, n)}
/>
<WeightRow
id="subtitle-weight"
label="Weight"
name="Subtitle weight"
value={getConfigValue(config, SUB_WEIGHT)}
onChange={(w) => set(SUB_WEIGHT, w)}
/>
<SelectRow
id="subtitle-style"
label="Style"
name="Subtitle style"
options={styleOptions}
value={enumValue(getConfigValue(config, SUB_STYLE), styleOptions)}
onSelect={(s) => set(SUB_STYLE, s === '' ? undefined : s)}
/>
<NumberRow
label="Gap"
name="Subtitle gap"
value={asNumber(getConfigValue(config, SUB_PADDING))}
min={0}
unit="px"
onChange={(n) => set(SUB_PADDING, n)}
/>
</>
),
},
];
return <Accordion sections={sections} idPrefix="title" />;
}
+129 -161
View File
@@ -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
// 100900 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<Weight>[] = [
{ 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<HTMLInputElement>) => {
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 (
<div className={styles.panel}>
<ControlSection title="Font family" hint="Applied to every text slot in the config.">
<div className={styles.field}>
<span className={styles.fieldLabel}>Font</span>
<div className={styles.control}>
<SelectControl
id="theme-builder-font"
label="Font family"
heading="Apply font"
options={fontOptions}
value={currentFont?.value}
onSelect={(family) => useCustomThemeStore.getState().applyDraftFont(family)}
triggerContent={
<>
<span style={currentFont ? { fontFamily: currentFont.value } : undefined}>
{currentFont?.label ?? 'Apply font…'}
</span>
<span className={styles.caret} aria-hidden="true">
</span>
</>
}
triggerTitle="Write one font family into every font slot of the config"
/>
<Button variant="ghost" onClick={() => fileRef.current?.click()}>
Upload font
</Button>
<input
ref={fileRef}
type="file"
accept=".woff2,.woff,.ttf,.otf"
multiple
hidden
onChange={onPickFiles}
/>
const sections: AccordionSection[] = [
{
id: 'font',
title: 'Font family',
hint: 'Applied to every text slot in the config.',
badge: countSet(config, [FONT]),
children: (
<>
<div className={styles.field}>
<span className={styles.fieldLabel}>Font</span>
<div className={styles.control}>
<SelectControl
id="theme-builder-font"
label="Font family"
heading="Apply font"
options={fontOptions}
value={currentFont?.value}
onSelect={(family) => useCustomThemeStore.getState().applyDraftFont(family)}
triggerContent={
<>
<span style={currentFont ? { fontFamily: currentFont.value } : undefined}>
{currentFont?.label ?? 'Apply font…'}
</span>
<span className={styles.caret} aria-hidden="true">
</span>
</>
}
triggerTitle="Write one font family into every font slot of the config"
/>
<Button variant="ghost" onClick={() => fileRef.current?.click()}>
Upload font
</Button>
<input
ref={fileRef}
type="file"
accept=".woff2,.woff,.ttf,.otf"
multiple
hidden
onChange={onPickFiles}
/>
</div>
</div>
</div>
{fonts.length > 0 && (
<ul className={styles.fontList}>
{fonts.map((f) => (
<li key={f.id} className={styles.fontItem}>
<span className={styles.fontNameWrap}>
<span
className={styles.fontName}
style={{ fontFamily: fontFamilyStack(f.family) }}
>
{f.family}
{fonts.length > 0 && (
<ul className={styles.fontList}>
{fonts.map((f) => (
<li key={f.id} className={styles.fontItem}>
<span className={styles.fontNameWrap}>
<span
className={styles.fontName}
style={{ fontFamily: fontFamilyStack(f.family) }}
>
{f.family}
</span>
{isVariableFont(f.axes) && (
<span className={styles.fontBadge}>{variableBadge(f.axes)}</span>
)}
</span>
{isVariableFont(f.axes) && (
<span className={styles.fontBadge}>{variableBadge(f.axes)}</span>
)}
</span>
<span className={styles.fontMeta}>{humanizeBytes(f.size)}</span>
<Button
variant="ghost"
onClick={() => void onRemoveFont(f.id, f.family)}
aria-label={`Remove ${f.family}`}
>
Remove
</Button>
</li>
))}
</ul>
)}
</ControlSection>
<span className={styles.fontMeta}>{humanizeBytes(f.size)}</span>
<Button
variant="ghost"
onClick={() => void onRemoveFont(f.id, f.family)}
aria-label={`Remove ${f.family}`}
>
Remove
</Button>
</li>
))}
</ul>
)}
</>
),
},
{
id: 'axisTitles',
title: 'Axis titles',
badge: countSet(config, [AXIS_TITLE_SIZE, AXIS_TITLE_WEIGHT]),
children: (
<>
<NumberRow
label="Size"
name="Axis title size"
value={asNumber(getConfigValue(config, AXIS_TITLE_SIZE))}
min={0}
unit="px"
onChange={(n) => set(AXIS_TITLE_SIZE, n)}
/>
<WeightRow
id="type-axis-title-weight"
label="Weight"
name="Axis title weight"
value={getConfigValue(config, AXIS_TITLE_WEIGHT)}
onChange={(w) => set(AXIS_TITLE_WEIGHT, w)}
/>
</>
),
},
{
id: 'axisLabels',
title: 'Axis labels',
badge: countSet(config, [AXIS_LABEL_SIZE, AXIS_LABEL_WEIGHT]),
children: (
<>
<NumberRow
label="Size"
name="Axis label size"
value={asNumber(getConfigValue(config, AXIS_LABEL_SIZE))}
min={0}
unit="px"
onChange={(n) => set(AXIS_LABEL_SIZE, n)}
/>
<WeightRow
id="type-axis-label-weight"
label="Weight"
name="Axis label weight"
value={getConfigValue(config, AXIS_LABEL_WEIGHT)}
onChange={(w) => set(AXIS_LABEL_WEIGHT, w)}
/>
</>
),
},
];
<ControlSection title="Title">
<NumberRow
label="Size"
name="Title size"
value={asNumber(getConfigValue(config, TITLE_SIZE))}
min={0}
unit="px"
onChange={(n) => set(TITLE_SIZE, n)}
/>
<SelectRow
id="type-title-weight"
label="Weight"
name="Title weight"
options={weightOptions}
value={weightValue(getConfigValue(config, TITLE_WEIGHT))}
onSelect={setWeight(TITLE_WEIGHT)}
/>
</ControlSection>
<ControlSection title="Axis titles">
<NumberRow
label="Size"
name="Axis title size"
value={asNumber(getConfigValue(config, AXIS_TITLE_SIZE))}
min={0}
unit="px"
onChange={(n) => set(AXIS_TITLE_SIZE, n)}
/>
<SelectRow
id="type-axis-title-weight"
label="Weight"
name="Axis title weight"
options={weightOptions}
value={weightValue(getConfigValue(config, AXIS_TITLE_WEIGHT))}
onSelect={setWeight(AXIS_TITLE_WEIGHT)}
/>
</ControlSection>
<ControlSection title="Axis labels">
<NumberRow
label="Size"
name="Axis label size"
value={asNumber(getConfigValue(config, AXIS_LABEL_SIZE))}
min={0}
unit="px"
onChange={(n) => set(AXIS_LABEL_SIZE, n)}
/>
<SelectRow
id="type-axis-label-weight"
label="Weight"
name="Axis label weight"
options={weightOptions}
value={weightValue(getConfigValue(config, AXIS_LABEL_WEIGHT))}
onSelect={setWeight(AXIS_LABEL_WEIGHT)}
/>
</ControlSection>
</div>
);
return <Accordion sections={sections} idPrefix="type" />;
}
+30
View File
@@ -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);
}
}
}
});
});
+37
View File
@@ -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);
});
});
+22
View File
@@ -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 extends string>(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<ConfigPath>): number {
return paths.reduce((n, p) => (getConfigValue(config, p) !== undefined ? n + 1 : n), 0);
}
// ── Named color schemes ─────────────────────────────────────────────────────
type SchemeKind = 'categorical' | 'sequential' | 'diverging';
+48 -27
View File
@@ -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 0100% 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' },
},
},
};
+8
View File
@@ -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'],