mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Theme Builder: structured color/type controls, scheme catalog, diverging preview
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
/* 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);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.caret {
|
||||
color: var(--text-secondary);
|
||||
margin-left: var(--space-3);
|
||||
}
|
||||
|
||||
/* Editable swatch rows (palette colors / gradient stops) wrap into a grid. */
|
||||
.swatches {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3) var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* One swatch: color picker + copyable hex field, with a hover remove. */
|
||||
.swatchUnit {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.hexInput {
|
||||
width: 8ch;
|
||||
height: var(--control-height);
|
||||
padding: 0 var(--space-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Native color input, sized to a swatch and stripped of its chrome. */
|
||||
.colorInput {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: var(--border-width) solid var(--border-strong);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
.colorInput::-webkit-color-swatch-wrapper {
|
||||
padding: 2px;
|
||||
}
|
||||
.colorInput::-webkit-color-swatch {
|
||||
border: none;
|
||||
}
|
||||
.colorInput::-moz-color-swatch {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.swatchRemove {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
color: var(--text);
|
||||
background: var(--layer-02);
|
||||
border: var(--border-width) solid var(--border-strong);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity var(--dur-fast) var(--ease);
|
||||
}
|
||||
.swatchUnit:hover .swatchRemove,
|
||||
.swatchRemove:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.gradientPreview {
|
||||
width: 96px;
|
||||
height: 24px;
|
||||
flex: 0 0 auto;
|
||||
border: var(--border-width) solid var(--border-strong);
|
||||
}
|
||||
|
||||
/* Dropdown-option previews: a swatch strip (categorical) / gradient bar. */
|
||||
.optStrip {
|
||||
display: inline-flex;
|
||||
width: 84px;
|
||||
}
|
||||
.optSwatch {
|
||||
flex: 1 1 0;
|
||||
height: 14px;
|
||||
min-width: 4px;
|
||||
}
|
||||
.optGradient {
|
||||
display: inline-block;
|
||||
width: 84px;
|
||||
height: 14px;
|
||||
border: var(--border-width) solid var(--border);
|
||||
}
|
||||
|
||||
/* Mini current-value preview inside a SelectControl trigger. */
|
||||
.triggerPreview {
|
||||
display: inline-flex;
|
||||
margin-right: var(--space-2);
|
||||
}
|
||||
.triggerPreview .optSwatch {
|
||||
width: 6px;
|
||||
flex: 0 0 6px;
|
||||
height: 14px;
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Color panel — behavioural wiring through the live modal. The pure config
|
||||
* transforms are covered in core/theme-controls.test.ts; these confirm the
|
||||
* controls read the draft config and write back through `mutateDraftConfig`
|
||||
* (scheme pick, materialize, swatch add/remove, mark color, the JSON gate).
|
||||
* vega-embed is mocked (the gallery is integration-heavy).
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import type { JsonObject } from '@core/spec-config';
|
||||
import { useCustomThemeStore } from '../stores/CustomThemeStore';
|
||||
import { usePopoverStore } from '../stores/PopoverStore';
|
||||
import { ThemeBuilderModal } from './ThemeBuilderModal';
|
||||
|
||||
vi.mock('../services/chart-renderer', () => ({
|
||||
renderSpec: vi.fn(() =>
|
||||
Promise.resolve({ destroy() {}, resize() {}, toImageURL: () => Promise.resolve('') }),
|
||||
),
|
||||
}));
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
useCustomThemeStore.getState().reset();
|
||||
usePopoverStore.getState().close(); // the open-popover registry is global; isolate tests
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const render = () => act(() => root.render(<ThemeBuilderModal />));
|
||||
|
||||
/** Open a draft seeded with `config` (block body so `act` returns void). */
|
||||
const open = (config: JsonObject) =>
|
||||
act(() => {
|
||||
useCustomThemeStore.getState().createTheme('Brand', config);
|
||||
});
|
||||
|
||||
const draftConfig = () => useCustomThemeStore.getState().draftConfig;
|
||||
const range = () => (draftConfig()?.range ?? {}) as Record<string, unknown>;
|
||||
|
||||
/** Drive an input's value through the native setter so React's value tracker
|
||||
* registers the change and fires onChange (a bare `input.value =` doesn't). */
|
||||
function setNativeValue(el: HTMLInputElement, value: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method -- invoked immediately via .call
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
|
||||
setter.call(el, value);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
/** A button anywhere in the document (popovers portal to <body>) by exact label. */
|
||||
const button = (label: string) =>
|
||||
[...document.querySelectorAll('button')].find((b) => b.textContent === label);
|
||||
|
||||
/** The trigger of a SelectControl by its `label`-derived aria-label prefix. */
|
||||
const picker = (labelPrefix: string) =>
|
||||
[...container.querySelectorAll('button')].find((b) =>
|
||||
b.getAttribute('aria-label')?.startsWith(labelPrefix),
|
||||
);
|
||||
|
||||
describe('ColorControls', () => {
|
||||
test('materialize expands a named scheme into an editable array', () => {
|
||||
render();
|
||||
open({ range: { category: 'tableau10' } });
|
||||
|
||||
expect(range().category).toBe('tableau10');
|
||||
act(() => button('Materialize to edit')!.click());
|
||||
|
||||
expect(Array.isArray(range().category)).toBe(true);
|
||||
expect((range().category as string[]).length).toBe(10);
|
||||
});
|
||||
|
||||
test('add and remove palette colors', () => {
|
||||
render();
|
||||
open({ range: { category: ['#111111', '#222222'] } });
|
||||
|
||||
act(() => button('Add color')!.click());
|
||||
expect((range().category as string[]).length).toBe(3);
|
||||
|
||||
const remove = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Remove Palette color 1"]',
|
||||
)!;
|
||||
act(() => remove.click());
|
||||
expect(range().category).toEqual(['#222222', '#888888']);
|
||||
});
|
||||
|
||||
test('removing the last swatch deletes range.category', () => {
|
||||
render();
|
||||
open({ range: { category: ['#111111'] } });
|
||||
|
||||
const remove = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Remove Palette color 1"]',
|
||||
)!;
|
||||
act(() => remove.click());
|
||||
expect('category' in range()).toBe(false);
|
||||
});
|
||||
|
||||
test('setting a default mark color writes mark.color; clear removes it', () => {
|
||||
render();
|
||||
open({});
|
||||
|
||||
const input = container.querySelector<HTMLInputElement>(
|
||||
'input[aria-label="Default mark color"]',
|
||||
)!;
|
||||
act(() => setNativeValue(input, '#ff0000'));
|
||||
expect((draftConfig()?.mark as Record<string, unknown>).color).toBe('#ff0000');
|
||||
|
||||
act(() => button('Clear')!.click());
|
||||
expect(draftConfig()?.mark).toBeUndefined();
|
||||
});
|
||||
|
||||
test('typing in a swatch hex field updates that color', () => {
|
||||
render();
|
||||
open({ range: { category: ['#111111', '#222222'] } });
|
||||
|
||||
const hex = container.querySelector<HTMLInputElement>(
|
||||
'input[aria-label="Palette color 1 hex value"]',
|
||||
)!;
|
||||
act(() => setNativeValue(hex, '#abcdef'));
|
||||
expect((range().category as string[])[0]).toBe('#abcdef');
|
||||
});
|
||||
|
||||
test('a sequential scheme materializes to editable stops written to heatmap and ramp', () => {
|
||||
render();
|
||||
// Seed the other families as arrays so the only "Materialize" button is the
|
||||
// sequential one (categorical/diverging show "Add color"/"Add stop" instead).
|
||||
open({
|
||||
range: {
|
||||
category: ['#111111'],
|
||||
heatmap: 'viridis',
|
||||
ramp: 'viridis',
|
||||
diverging: ['#aa0000', '#0000aa'],
|
||||
},
|
||||
});
|
||||
|
||||
act(() => button('Materialize to edit')!.click());
|
||||
expect(Array.isArray(range().heatmap)).toBe(true);
|
||||
expect(range().ramp).toEqual(range().heatmap);
|
||||
|
||||
const remove = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Remove Sequential stop 1"]',
|
||||
)!;
|
||||
const before = (range().heatmap as string[]).length;
|
||||
act(() => remove.click());
|
||||
expect((range().heatmap as string[]).length).toBe(before - 1);
|
||||
expect(range().ramp).toEqual(range().heatmap);
|
||||
});
|
||||
|
||||
test('scheme dropdown options carry a color preview', () => {
|
||||
render();
|
||||
open({});
|
||||
act(() => picker('Categorical color scheme')!.click());
|
||||
|
||||
const tableau = button('Tableau 10')!;
|
||||
// The decorative swatch strip renders as spans inside the option button.
|
||||
expect(tableau.querySelectorAll('span span').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('picking a scheme writes range.category as a string', () => {
|
||||
render();
|
||||
open({});
|
||||
|
||||
act(() => picker('Categorical color scheme')!.click());
|
||||
act(() => button('Category 10')!.click());
|
||||
|
||||
expect(range().category).toBe('category10');
|
||||
});
|
||||
|
||||
test('a sequential pick sets both heatmap and ramp', () => {
|
||||
render();
|
||||
open({});
|
||||
|
||||
act(() => picker('Sequential color scheme')!.click());
|
||||
act(() => button('Viridis')!.click());
|
||||
|
||||
expect(range().heatmap).toBe('viridis');
|
||||
expect(range().ramp).toBe('viridis');
|
||||
});
|
||||
|
||||
test('invalid JSON disables the controls', () => {
|
||||
render();
|
||||
open({});
|
||||
act(() => useCustomThemeStore.getState().updateDraft({ configText: '{oops' }));
|
||||
|
||||
expect(container.textContent).toContain('Fix the JSON below to use these controls');
|
||||
expect(button('Materialize to edit')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('the Type tab exposes the font-apply control', () => {
|
||||
render();
|
||||
open({});
|
||||
|
||||
const typeTab = [...container.querySelectorAll('button')].find(
|
||||
(b) => b.getAttribute('role') === 'tab' && b.textContent === 'Type',
|
||||
)!;
|
||||
act(() => typeTab.click());
|
||||
expect(picker('Font family')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('font options render their name in their own family', () => {
|
||||
render();
|
||||
open({});
|
||||
|
||||
const typeTab = [...container.querySelectorAll('button')].find(
|
||||
(b) => b.getAttribute('role') === 'tab' && b.textContent === 'Type',
|
||||
)!;
|
||||
act(() => typeTab.click());
|
||||
act(() => picker('Font family')!.click());
|
||||
|
||||
const georgia = button('Georgia')!;
|
||||
expect(georgia.querySelector<HTMLElement>('span')!.style.fontFamily).toContain('Georgia');
|
||||
});
|
||||
|
||||
test('the raw JSON is collapsed by default and toggles open', () => {
|
||||
render();
|
||||
open({});
|
||||
|
||||
expect(container.querySelector('#theme-config')).toBeNull();
|
||||
const toggle = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-controls="theme-config"]',
|
||||
)!;
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false');
|
||||
|
||||
act(() => toggle.click());
|
||||
expect(container.querySelector('#theme-config')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('a parse error forces the JSON open', () => {
|
||||
render();
|
||||
open({});
|
||||
expect(container.querySelector('#theme-config')).toBeNull();
|
||||
|
||||
act(() => useCustomThemeStore.getState().updateDraft({ configText: '{oops' }));
|
||||
expect(container.querySelector('#theme-config')).toBeTruthy();
|
||||
expect(
|
||||
container
|
||||
.querySelector('button[aria-controls="theme-config"]')!
|
||||
.getAttribute('aria-expanded'),
|
||||
).toBe('true');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* Theme Builder — Color panel (docs/chart-theming-scope.md §5).
|
||||
*
|
||||
* Structured controls over the draft config's color surface: the categorical
|
||||
* palette (`range.category`), the default single-series mark color
|
||||
* (`mark.color`), and the sequential/diverging gradients (`range.heatmap`+`ramp`
|
||||
* and `range.diverging`). Each writes through `mutateDraftConfig` so the JSON
|
||||
* and the gallery follow; reads come from the parsed draft config, so a JSON
|
||||
* hand-edit reflects straight back into the controls.
|
||||
*
|
||||
* Color model (§5): every family holds either a named Vega scheme (compact, the
|
||||
* quick path) or an explicit color array (custom tuning). Picking a scheme from
|
||||
* the preview-bearing dropdown writes the name; "Materialize" expands it to an
|
||||
* editable array of swatches — categorical, sequential, and diverging alike.
|
||||
* Each swatch pairs the native color picker with a hex text field, so a value
|
||||
* can be read, copied, and retyped anywhere.
|
||||
*/
|
||||
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import type { JsonObject } from '@core/spec-config';
|
||||
import {
|
||||
type ConfigPath,
|
||||
getConfigValue,
|
||||
schemeColors,
|
||||
schemesByKind,
|
||||
setConfigValue,
|
||||
} from '@core/theme-controls';
|
||||
import { useCustomThemeStore } from '../stores/CustomThemeStore';
|
||||
import { Button } from './Button';
|
||||
import { SelectControl, type SelectControlOption } from './SelectControl';
|
||||
import styles from './ColorControls.module.css';
|
||||
|
||||
const CATEGORY: ConfigPath = ['range', 'category'];
|
||||
const MARK_COLOR: ConfigPath = ['mark', 'color'];
|
||||
const HEATMAP: ConfigPath = ['range', 'heatmap'];
|
||||
const RAMP: ConfigPath = ['range', 'ramp'];
|
||||
const DIVERGING: ConfigPath = ['range', 'diverging'];
|
||||
|
||||
/** Vega-Lite's default mark color — shown as the starting value when unset. */
|
||||
const VEGA_DEFAULT_MARK = '#4c78a8';
|
||||
/** Seeded into a new swatch and the materialize-from-default paths. */
|
||||
const NEW_SWATCH = '#888888';
|
||||
const DEFAULT_CATEGORICAL = 'tableau10';
|
||||
const DEFAULT_SEQUENTIAL = 'viridis';
|
||||
const DEFAULT_DIVERGING = 'redblue';
|
||||
/** Stops a materialized gradient starts with (continuous schemes are sampled). */
|
||||
const GRADIENT_STOPS = 7;
|
||||
|
||||
const isHex = (c: string): boolean => /^#[0-9a-f]{6}$/i.test(c);
|
||||
const asArray = (v: unknown): string[] | null =>
|
||||
Array.isArray(v) && v.every((c) => typeof c === 'string') ? v : null;
|
||||
const asString = (v: unknown): string | null => (typeof v === 'string' ? v : null);
|
||||
|
||||
/** `#rrggbb` (lowercased) from loose input, or null if not six hex digits. */
|
||||
function normalizeHex(raw: string): string | null {
|
||||
const v = raw.trim().replace(/^#/, '');
|
||||
return /^[0-9a-f]{6}$/i.test(v) ? `#${v.toLowerCase()}` : null;
|
||||
}
|
||||
|
||||
const gradientCss = (colors: string[]): string =>
|
||||
colors.length ? `linear-gradient(90deg, ${colors.join(', ')})` : 'transparent';
|
||||
|
||||
// Dropdown previews, built once: a swatch strip for categorical, a gradient bar
|
||||
// for the continuous families.
|
||||
const swatchStrip = (colors: string[]): ReactNode => (
|
||||
<span className={styles.optStrip}>
|
||||
{colors.slice(0, 12).map((c, i) => (
|
||||
<span key={i} className={styles.optSwatch} style={{ background: c }} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
const gradientBar = (colors: string[]): ReactNode => (
|
||||
<span className={styles.optGradient} style={{ background: gradientCss(colors) }} />
|
||||
);
|
||||
|
||||
const CATEGORICAL_OPTIONS: SelectControlOption<string>[] = schemesByKind('categorical').map(
|
||||
(s) => ({
|
||||
value: s.name,
|
||||
label: s.label,
|
||||
preview: swatchStrip(schemeColors(s.name)),
|
||||
}),
|
||||
);
|
||||
const SEQUENTIAL_OPTIONS: SelectControlOption<string>[] = schemesByKind('sequential').map((s) => ({
|
||||
value: s.name,
|
||||
label: s.label,
|
||||
preview: gradientBar(schemeColors(s.name, 12)),
|
||||
}));
|
||||
const DIVERGING_OPTIONS: SelectControlOption<string>[] = schemesByKind('diverging').map((s) => ({
|
||||
value: s.name,
|
||||
label: s.label,
|
||||
preview: gradientBar(schemeColors(s.name, 12)),
|
||||
}));
|
||||
|
||||
/** A color picker paired with a copyable, editable hex field; optional remove. */
|
||||
function SwatchRow({
|
||||
color,
|
||||
label,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
color: string;
|
||||
label: string;
|
||||
onChange: (hex: string) => void;
|
||||
onRemove?: () => void;
|
||||
}) {
|
||||
// Local text so a partially-typed hex isn't rejected mid-keystroke; commits on
|
||||
// a valid value, and reverts to the committed color on blur. Resync to the
|
||||
// committed color when it changes from the outside (color picker, materialize,
|
||||
// scheme) — but not from this field's own commit, so an in-progress edit isn't
|
||||
// clobbered. The render-time adjustment is React's documented alternative to a
|
||||
// sync effect.
|
||||
const [text, setText] = useState(color);
|
||||
const [synced, setSynced] = useState(color);
|
||||
if (color !== synced) {
|
||||
setSynced(color);
|
||||
if (normalizeHex(text) !== color) setText(color);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={styles.swatchUnit}>
|
||||
<input
|
||||
type="color"
|
||||
className={styles.colorInput}
|
||||
aria-label={label}
|
||||
value={isHex(color) ? color : '#000000'}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.hexInput}
|
||||
aria-label={`${label} hex value`}
|
||||
spellCheck={false}
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
const norm = normalizeHex(e.target.value);
|
||||
if (norm) onChange(norm);
|
||||
}}
|
||||
onBlur={() => setText(color)}
|
||||
/>
|
||||
{onRemove && (
|
||||
// TODO: icon-only control as a raw <button> with a literal "×" rather
|
||||
// than IconButton + <Icon name="close"/> (the controlled glyph vocabulary
|
||||
// arch 09 §5 uses everywhere else). The corner badge is 16px — below
|
||||
// IconButton's 24px sm and tighter than a 16px Icon fits — so neither
|
||||
// primitive drops in unchanged. Reconcile the badge size with the
|
||||
// primitive in the batched control-consistency pass.
|
||||
<button
|
||||
type="button"
|
||||
className={styles.swatchRemove}
|
||||
aria-label={`Remove ${label}`}
|
||||
title="Remove"
|
||||
onClick={onRemove}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ColorControls({ config }: { config: JsonObject }) {
|
||||
const mutate = useCustomThemeStore((s) => s.mutateDraftConfig);
|
||||
const set = (path: ConfigPath, value: unknown) => mutate((c) => setConfigValue(c, path, value));
|
||||
// Sequential color lives in two slots (heatmaps + continuous legends); keep them together.
|
||||
const setSeq = (value: unknown) =>
|
||||
mutate((c) => setConfigValue(setConfigValue(c, HEATMAP, value), RAMP, value));
|
||||
|
||||
const catValue = getConfigValue(config, CATEGORY);
|
||||
const catArray = asArray(catValue);
|
||||
const catScheme = asString(catValue);
|
||||
|
||||
const markColor = asString(getConfigValue(config, MARK_COLOR));
|
||||
|
||||
const seqValue = getConfigValue(config, HEATMAP);
|
||||
const seqArray = asArray(seqValue);
|
||||
const seqScheme = asString(seqValue);
|
||||
|
||||
const divValue = getConfigValue(config, DIVERGING);
|
||||
const divArray = asArray(divValue);
|
||||
const divScheme = asString(divValue);
|
||||
|
||||
/** Stops to drive a gradient preview for a family in any of its states. */
|
||||
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, 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 && isHex(markColor) ? 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(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, 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>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -127,9 +127,17 @@
|
||||
.optionLabel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Decorative leading visual (e.g. a color-scheme swatch strip / gradient bar). */
|
||||
.optionPreview {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* caller intercept the click entirely (the armed-channel fast path).
|
||||
*/
|
||||
|
||||
import { Fragment, type ReactNode } from 'react';
|
||||
import { Fragment, type CSSProperties, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { usePopover } from '../hooks/usePopover';
|
||||
import styles from './SelectControl.module.css';
|
||||
@@ -47,6 +47,16 @@ export interface SelectControlOption<V extends string> {
|
||||
* activating discloses further UI, matching the "…" label convention.
|
||||
*/
|
||||
hasPopup?: 'dialog';
|
||||
/**
|
||||
* Optional visual leading the option label — e.g. a color-scheme swatch strip
|
||||
* or gradient bar. Decorative (`aria-hidden`); the label carries the meaning.
|
||||
*/
|
||||
preview?: ReactNode;
|
||||
/**
|
||||
* Style applied to the label text — e.g. render a font option's name in its
|
||||
* own family, so the option previews the value (the type analogue of a swatch).
|
||||
*/
|
||||
labelStyle?: CSSProperties;
|
||||
}
|
||||
|
||||
export interface SelectControlProps<V extends string> {
|
||||
@@ -171,7 +181,12 @@ export function SelectControl<V extends string>({
|
||||
aria-haspopup={o.hasPopup}
|
||||
onClick={() => choose(o.value)}
|
||||
>
|
||||
<span className={styles.optionLabel}>
|
||||
{o.preview !== undefined && (
|
||||
<span className={styles.optionPreview} aria-hidden="true">
|
||||
{o.preview}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.optionLabel} style={o.labelStyle}>
|
||||
{o.label}
|
||||
{o.detail !== undefined && (
|
||||
<span className={styles.detail}>{o.detail}</span>
|
||||
|
||||
@@ -141,31 +141,145 @@
|
||||
border-bottom: var(--border-width) solid var(--border);
|
||||
}
|
||||
|
||||
/* ── Editor + gallery split ────────────────────────────────────────────── */
|
||||
/* ── Structured-control tabs ───────────────────────────────────────────── */
|
||||
|
||||
.work {
|
||||
.tabs {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
gap: 0;
|
||||
padding: 0 var(--space-5);
|
||||
border-bottom: var(--border-width) solid var(--border);
|
||||
}
|
||||
|
||||
.tab {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
/* The structured controls are the first-screen surface: they fill the column
|
||||
and scroll, with the collapsible JSON section docked below. */
|
||||
.tabpanel {
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
border-bottom: var(--border-width) solid var(--border);
|
||||
}
|
||||
|
||||
.controlsDisabled {
|
||||
margin: 0;
|
||||
padding: var(--space-5);
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.typePanel {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.typeTitle {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.typeHint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.fontCaret {
|
||||
margin-left: var(--space-3);
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── Controls + JSON (left) | gallery rail (right) ─────────────────────── */
|
||||
|
||||
/* The gallery takes the whole right side, full height; the controls column —
|
||||
tabs, the active panel, and the JSON editor stacked — sits on the left,
|
||||
capped so the previews get the room. */
|
||||
.body {
|
||||
flex: 1 1 auto;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 400px) 1fr;
|
||||
grid-template-columns: clamp(420px, 38%, 600px) 1fr;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.controlsCol {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
border-right: var(--border-width) solid var(--border);
|
||||
}
|
||||
|
||||
/* Collapsible raw-JSON disclosure, docked at the bottom of the controls column. */
|
||||
.jsonSection {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: var(--border-width) solid var(--border);
|
||||
}
|
||||
|
||||
.jsonToggle {
|
||||
appearance: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
background: none;
|
||||
border: none;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.jsonToggle:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.jsonCaret {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.editorPane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-right: var(--border-width) solid var(--border);
|
||||
min-height: 0;
|
||||
padding: 0 var(--space-5) var(--space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.configText {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
height: clamp(180px, 34vh, 340px);
|
||||
padding: var(--space-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* is exactly what selecting the theme will do.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { THEME_FONT_OPTIONS } from '@core/custom-theme';
|
||||
import type { JsonObject } from '@core/spec-config';
|
||||
import { THEME_PREVIEW_SPECS, type ThemePreviewSpec } from '@core/theme-preview-specs';
|
||||
@@ -27,9 +27,28 @@ import {
|
||||
import { notify } from '../stores/NotificationStore';
|
||||
import { resnapshot } from '../modals/ModalCoordinator';
|
||||
import { Button } from './Button';
|
||||
import { SelectControl } from './SelectControl';
|
||||
import { ColorControls } from './ColorControls';
|
||||
import { SelectControl, type SelectControlOption } from './SelectControl';
|
||||
import styles from './ThemeBuilderModal.module.css';
|
||||
|
||||
/** Structured-control tabs, in display order (docs/chart-theming-scope.md §5). */
|
||||
const THEME_TABS = [
|
||||
{ id: 'color', label: 'Color' },
|
||||
{ id: 'type', label: 'Type' },
|
||||
] as const;
|
||||
type ThemeTab = (typeof THEME_TABS)[number]['id'];
|
||||
|
||||
/**
|
||||
* Font options, each labelled in its own family so the dropdown previews the
|
||||
* typeface (the type analogue of the color dropdowns' swatches). Render-safe
|
||||
* faces only today — see THEME_FONT_OPTIONS.
|
||||
*/
|
||||
const FONT_OPTIONS: SelectControlOption<string>[] = THEME_FONT_OPTIONS.map(({ value, label }) => ({
|
||||
value,
|
||||
label,
|
||||
labelStyle: { fontFamily: value },
|
||||
}));
|
||||
|
||||
/** Debounce for gallery re-renders while the config text is edited (ms). */
|
||||
const GALLERY_DEBOUNCE = 250;
|
||||
|
||||
@@ -108,6 +127,35 @@ export function ThemeBuilderModal() {
|
||||
const saveError = useCustomThemeStore((s) => s.saveError);
|
||||
const dirty = useCustomThemeStore(selectIsDraftDirty);
|
||||
const selectedTheme = useCustomThemeStore(selectSelectedTheme);
|
||||
const [activeTab, setActiveTab] = useState<ThemeTab>('color');
|
||||
// The raw JSON is the advanced/escape-hatch surface, collapsed off the first
|
||||
// screen so the structured controls own it; a parse error forces it open
|
||||
// (it's the only place to fix the JSON).
|
||||
const [jsonExpanded, setJsonExpanded] = useState(false);
|
||||
const jsonOpen = jsonExpanded || parseError !== null;
|
||||
// The font option matching the config's top-level font (if any) — drives the
|
||||
// Type tab's selected state and its in-face trigger label.
|
||||
const currentFont =
|
||||
typeof draftConfig?.font === 'string'
|
||||
? FONT_OPTIONS.find((o) => o.value === draftConfig.font)
|
||||
: undefined;
|
||||
|
||||
// APG tabs: arrow/Home/End move selection, which follows focus (automatic
|
||||
// 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;
|
||||
else if (e.key === 'Home') next = 0;
|
||||
else if (e.key === 'End') next = ids.length - 1;
|
||||
if (next < 0) return;
|
||||
e.preventDefault();
|
||||
setActiveTab(ids[next]);
|
||||
(e.currentTarget.children[next] as HTMLElement | undefined)?.focus();
|
||||
};
|
||||
|
||||
const handleNew = () => {
|
||||
const app = useAppStore.getState();
|
||||
@@ -199,15 +247,6 @@ export function ThemeBuilderModal() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<SelectControl
|
||||
id="theme-builder-font"
|
||||
label="Apply a font across the config"
|
||||
heading="Apply font"
|
||||
options={THEME_FONT_OPTIONS.map(({ value, label }) => ({ value, label }))}
|
||||
onSelect={(family) => useCustomThemeStore.getState().applyDraftFont(family)}
|
||||
triggerContent={<>Font…</>}
|
||||
triggerTitle="Write one font family into every font slot of the config"
|
||||
/>
|
||||
<div className={styles.toolbarEnd}>
|
||||
<Button variant="danger-outline" onClick={() => void handleDelete()}>
|
||||
Delete
|
||||
@@ -222,30 +261,112 @@ export function ThemeBuilderModal() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(saveError ?? parseError) !== null && (
|
||||
<p className={styles.errorMessage} role="alert">
|
||||
{saveError ?? parseError}
|
||||
</p>
|
||||
)}
|
||||
<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>
|
||||
|
||||
<div className={styles.work}>
|
||||
<div className={styles.editorPane}>
|
||||
<label className={styles.label} htmlFor="theme-config">
|
||||
Config (Vega-Lite JSON)
|
||||
</label>
|
||||
{/* A plain textarea, deliberately not Monaco: a second Monaco mount
|
||||
is heavy inside a modal for an occasional surface, and the parse
|
||||
error below is the feedback channel that matters here. Revisit
|
||||
only if real usage asks for config completions. */}
|
||||
<textarea
|
||||
id="theme-config"
|
||||
className={styles.configText}
|
||||
spellCheck={false}
|
||||
value={draft.configText}
|
||||
onChange={(e) =>
|
||||
useCustomThemeStore.getState().updateDraft({ configText: e.target.value })
|
||||
}
|
||||
/>
|
||||
{(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>
|
||||
) : activeTab === 'color' && draftConfig !== null ? (
|
||||
<ColorControls config={draftConfig} />
|
||||
) : activeTab === 'type' ? (
|
||||
<div className={styles.typePanel}>
|
||||
<h4 className={styles.typeTitle}>Font family</h4>
|
||||
<p className={styles.typeHint}>
|
||||
Writes one family into every font slot of the config.
|
||||
</p>
|
||||
<SelectControl
|
||||
id="theme-builder-font"
|
||||
label="Font family"
|
||||
heading="Apply font"
|
||||
options={FONT_OPTIONS}
|
||||
value={currentFont?.value}
|
||||
onSelect={(family) => useCustomThemeStore.getState().applyDraftFont(family)}
|
||||
triggerContent={
|
||||
<>
|
||||
<span style={currentFont ? { fontFamily: currentFont.value } : undefined}>
|
||||
{currentFont?.label ?? 'Apply font…'}
|
||||
</span>
|
||||
<span className={styles.fontCaret} aria-hidden="true">
|
||||
▾
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
triggerTitle="Write one font family into every font slot of the config"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Raw JSON — collapsed by default (the structured controls are the
|
||||
first-screen surface); forced open while a parse error is the
|
||||
only thing that needs the textarea. */}
|
||||
<div className={styles.jsonSection}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.jsonToggle}
|
||||
aria-expanded={jsonOpen}
|
||||
aria-controls="theme-config"
|
||||
onClick={() => setJsonExpanded((o) => !o)}
|
||||
>
|
||||
<span className={styles.jsonCaret} aria-hidden="true">
|
||||
{jsonOpen ? '▾' : '▸'}
|
||||
</span>
|
||||
Config (Vega-Lite JSON)
|
||||
</button>
|
||||
{jsonOpen && (
|
||||
<div className={styles.editorPane}>
|
||||
{/* A plain textarea, deliberately not Monaco: a second Monaco mount
|
||||
is heavy inside a modal for an occasional surface, and the parse
|
||||
error above is the feedback channel that matters here. Revisit
|
||||
only if real usage asks for config completions. */}
|
||||
<textarea
|
||||
id="theme-config"
|
||||
className={styles.configText}
|
||||
spellCheck={false}
|
||||
value={draft.configText}
|
||||
onChange={(e) =>
|
||||
useCustomThemeStore.getState().updateDraft({ configText: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.gallery} aria-label="Theme preview gallery">
|
||||
{draftConfig !== null &&
|
||||
|
||||
@@ -64,6 +64,13 @@ export interface CustomThemeState {
|
||||
* the current text is invalid JSON — fix the JSON first.
|
||||
*/
|
||||
applyDraftFont: (family: string) => void;
|
||||
/**
|
||||
* Apply a pure transform to the draft config and reflect it in both the
|
||||
* parsed config (the gallery follows) and the reformatted text. The single
|
||||
* write path for every structured control. No-op with a parse error — the
|
||||
* controls are disabled while the JSON is invalid; fix the JSON first.
|
||||
*/
|
||||
mutateDraftConfig: (fn: (config: JsonObject) => JsonObject) => void;
|
||||
/**
|
||||
* Validate and commit the draft to the selected theme. On failure sets
|
||||
* `saveError`/`parseError` and returns false.
|
||||
@@ -166,7 +173,10 @@ export const useCustomThemeStore = create<CustomThemeState>((set, get) => ({
|
||||
set({ draft: next, saveError: null });
|
||||
},
|
||||
|
||||
applyDraftFont: (family) => {
|
||||
applyDraftFont: (family) =>
|
||||
get().mutateDraftConfig((config) => applyFontToConfig(config, family)),
|
||||
|
||||
mutateDraftConfig: (fn) => {
|
||||
const draft = get().draft;
|
||||
if (!draft) return;
|
||||
const parsed = parseConfigText(draft.configText);
|
||||
@@ -174,7 +184,7 @@ export const useCustomThemeStore = create<CustomThemeState>((set, get) => ({
|
||||
set({ parseError: parsed.error });
|
||||
return;
|
||||
}
|
||||
const config = applyFontToConfig(parsed.config, family);
|
||||
const config = fn(parsed.config);
|
||||
set({
|
||||
draft: { ...draft, configText: configToText(config) },
|
||||
draftConfig: config,
|
||||
|
||||
Reference in New Issue
Block a user