From e4d1466ab038dfdec399885640edae0857f8c6cc Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Sun, 14 Jun 2026 01:25:44 +0300 Subject: [PATCH] Theme Builder: structured color/type controls, scheme catalog, diverging preview --- .../05-rendering-theming-preview.md | 24 + docs/chart-theming-scope.md | 75 +++- package-lock.json | 1 + package.json | 1 + src/app/components/ColorControls.module.css | 143 ++++++ src/app/components/ColorControls.test.tsx | 255 +++++++++++ src/app/components/ColorControls.tsx | 419 ++++++++++++++++++ src/app/components/SelectControl.module.css | 8 + src/app/components/SelectControl.tsx | 19 +- .../components/ThemeBuilderModal.module.css | 132 +++++- src/app/components/ThemeBuilderModal.tsx | 189 ++++++-- src/app/stores/CustomThemeStore.ts | 14 +- src/core/custom-theme.test.ts | 16 + src/core/theme-controls.test.ts | 123 +++++ src/core/theme-controls.ts | 186 ++++++++ src/core/theme-preview-specs.ts | 46 +- src/vite-env.d.ts | 8 + 17 files changed, 1607 insertions(+), 52 deletions(-) create mode 100644 src/app/components/ColorControls.module.css create mode 100644 src/app/components/ColorControls.test.tsx create mode 100644 src/app/components/ColorControls.tsx create mode 100644 src/core/theme-controls.test.ts create mode 100644 src/core/theme-controls.ts diff --git a/docs/architecture/05-rendering-theming-preview.md b/docs/architecture/05-rendering-theming-preview.md index 59567bf..41bf52b 100644 --- a/docs/architecture/05-rendering-theming-preview.md +++ b/docs/architecture/05-rendering-theming-preview.md @@ -236,6 +236,30 @@ key-by-key), so a snippet can always override or opt out locally. The that boundary deliberately: merge bakes the selected theme into `spec.config` (spec keys win — rendering unchanged), extract lifts `spec.config` out. +### Structured controls + +The builder's panels (`ColorControls`; the Type tab's font control) are +accelerators over the same `draftConfig`: each reads a value and writes one back +through `CustomThemeStore.mutateDraftConfig(fn)` — the single transform path, +which reparses, reformats, and updates `draftConfig` so the JSON editor and +gallery follow (a parse error disables the controls). The pure transforms live +in `core/theme-controls.ts`: immutable config path get/set, the named-scheme +catalog (`THEME_SCHEMES`), and `schemeColors` (scheme name → hex swatches, from +the `vega-scale` registry — a focused vega sub-package). A color family holds +**either** a named scheme string **or** an explicit color array; the picker +materializes one to the other. Family by scale: `range.category` (nominal), +`range.ramp` (continuous; `range.heatmap` for `rect`), `range.diverging` +(continuous color with a `domainMid`). + +- **Do** route structured edits through `mutateDraftConfig` + `setConfigValue`, + which sets a value at a path **immutably, preserving sibling keys**, and + deletes (pruning emptied ancestors) on `undefined` so a theme stays a diff. +- **Don't** rebuild the config from a fixed schema: vega-themes presets carry + Vega-_layer_ keys (`symbol`/`shape`/`path`/`group`) absent from the Vega-Lite + `Config` schema but forwarded to Vega — a rebuild drops them. Merge in place. +- **Do** add a `theme-preview-specs.ts` gallery card for any new color family, + so no control ships without a visible mirror. + ### Rules - **Do** keep `chartConfigForSelection` as the _only_ place that maps the user's diff --git a/docs/chart-theming-scope.md b/docs/chart-theming-scope.md index e1d7134..9c53f26 100644 --- a/docs/chart-theming-scope.md +++ b/docs/chart-theming-scope.md @@ -165,7 +165,80 @@ ships, it is an explicit per-font user action, never automatic. **Rejected:** per-snippet theme field (2026-06-12 — `spec.config` + merge/extract covers it without a second mechanism). -## 5. Status log +## 5. Structured controls (slice 4b) + +The builder today is a raw JSON textarea + one font dropdown + the live gallery. Slice 4b +adds a strip of structured controls above the editor — accelerators that write into the +JSON, never replacing it. The JSON stays the source of truth and the full-power escape +hatch; controls cover the common ~80% (color, type, spacing, grid), not all 72 config +properties (that is the trap vega-editor deliberately avoids by staying JSON). + +**Hard constraint — the builder must preserve unknown keys.** Verified by compiling: the +vega-themes presets carry Vega-_layer_ keys (`symbol`, `shape`, `path`, `group`) that are +not in the Vega-Lite `Config` schema, and Vega-Lite forwards the whole config to Vega +unchanged — they take effect. So a structured control must **merge into** the existing +config (immutable path-set that spreads siblings), never rebuild it from a closed +schema-typed model, or it silently drops those keys on a round-trip. Same shape as +`applyFontToConfig`, which walks and rewrites rather than reconstructing. + +**Resolved design points:** + +- **Surfacing — inline tab strip** (not popovers, not sub-modals). Tabs (Color / Type / + Layout / Axes & grid / Legend) sit between the toolbar and the JSON+gallery, all in the + one xlarge modal. No nested overlays/focus traps, no contention with the one-open-popover + registry, and panels + JSON + gallery stay visible together. +- **Color model — scheme picker that materializes to swatches.** A `range` family takes + either an explicit color array or a named Vega scheme string (both verified to compile). + Pick a named scheme for the quick path; "materialize" expands it to an editable swatch + array for brand tuning. Catalog ships 15 categorical + 24 sequential + 10 diverging + schemes; categorical schemes resolve to arrays, continuous ones to interpolators sampled + into stops for the gradient preview and the materialize action. +- Structured controls are gated on valid JSON (same as the font control): a parse error + disables them and the textarea is the fix. +- Controls write **minimal** config — clearing a value deletes the key rather than writing + a default, so a theme stays a diff against stock, not a full dump. + +**Panels:** Color (`range.category` swatches/scheme, `mark.color`, `range.heatmap`/`ramp`/ +`diverging`) · Type (base `font`, title/axis/legend size+weight) · Layout (`background` incl. +transparent, `padding`, `view.stroke`/`fill`/cornerRadius) · Axes & grid (grid on/off + color + +- dash, domain, label color/angle — base `axis` only; the 25 variants stay JSON) · Legend + (orient, label/title color+size, symbol size). + +**Build order:** (a) core foundation — scheme catalog + immutable config path get/set + +`schemeColors` materialize, with tests; (b) Color panel (highest payoff); (c) Type, Layout, +Axes, Legend panels; (d) wire the tab strip into the modal. + +**Not in slice 4b:** the house style's own gaps — no `mark.color` (single-series charts stay +Vega-blue), unset legend/header/padding — are left for a separate house-style redo, not +papered over here. Minor cleanup noted: `theme-preview-specs.ts` declares `$schema` v5 while +the app standardizes on v6. + +## 6. Status log + +- **2026-06-14 (slice 4b, first increment)** — **structured-control foundation + Color + panel.** Core `theme-controls.ts`: immutable config path get/set (preserves siblings — + the Vega-layer-key guarantee — and prunes on delete) + the named-scheme catalog (15 + categorical / 24 sequential / 10 diverging) + `schemeColors` resolution (categorical + arrays passthrough, continuous interpolators sampled to hex), all tested. `vega-scale` + added as a declared dep (focused sub-package, like `vega-expression`) with a typings + shim in `vite-env.d.ts` (its package.json `exports` omits `types`). Store gains the + generic `mutateDraftConfig(fn)` write path; `applyDraftFont` refactored onto it. Modal + gains an APG tab strip — **Color** (categorical scheme/swatches + materialize, default + `mark.color`, sequential/diverging gradient pickers) and **Type** (the relocated font + control). Tabpanel gated on valid JSON. From first-use feedback, same day: the modal + body is now **controls + JSON on the left, gallery as a full-height right rail** (the + previews were starved before); `SelectControl` gained an optional per-option `preview` + so the scheme dropdowns show swatch strips (categorical) / gradient bars (continuous); + every swatch is a reusable `SwatchRow` (color picker + copyable/editable hex field); and + sequential/diverging gained **Materialize → editable stops**, so custom gradient colors + are possible, not just named schemes. Second feedback pass: the raw JSON is now a + **collapsed disclosure** at the bottom of the controls column (it was eating half the + first screen), forced open only on a parse error; the structured controls fill the + column. `SelectControl` options gained a `labelStyle`, so the **font dropdown renders + each name in its own family** (the type analogue of the color swatches) and its trigger + shows the current font in-face. Verified: typecheck, lint, full tests (950). + Remaining: Layout / Axes & grid / Legend panels; swatch reorder. - **2026-06-12 (slice 4 close-out)** — **custom themes in the §08 envelope.** The workspace export now writes a `themes` array (additive — no format bump; importers diff --git a/package-lock.json b/package-lock.json index 91b8c65..da1e0b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "vega-embed": "^7.1.0", "vega-expression": "^6.1.0", "vega-lite": "^6.4.2", + "vega-scale": "^8.1.0", "vega-themes": "3.0.0", "zustand": "^5.0.14" }, diff --git a/package.json b/package.json index a8a67a1..5d775e6 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "vega-embed": "^7.1.0", "vega-expression": "^6.1.0", "vega-lite": "^6.4.2", + "vega-scale": "^8.1.0", "vega-themes": "3.0.0", "zustand": "^5.0.14" }, diff --git a/src/app/components/ColorControls.module.css b/src/app/components/ColorControls.module.css new file mode 100644 index 0000000..cdf4ef0 --- /dev/null +++ b/src/app/components/ColorControls.module.css @@ -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; +} diff --git a/src/app/components/ColorControls.test.tsx b/src/app/components/ColorControls.test.tsx new file mode 100644 index 0000000..d7dbb76 --- /dev/null +++ b/src/app/components/ColorControls.test.tsx @@ -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()); + +/** 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; + +/** 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 ) 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( + '[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( + '[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( + 'input[aria-label="Default mark color"]', + )!; + act(() => setNativeValue(input, '#ff0000')); + expect((draftConfig()?.mark as Record).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( + '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( + '[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('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( + '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'); + }); +}); diff --git a/src/app/components/ColorControls.tsx b/src/app/components/ColorControls.tsx new file mode 100644 index 0000000..b1076e9 --- /dev/null +++ b/src/app/components/ColorControls.tsx @@ -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 => ( + + {colors.slice(0, 12).map((c, i) => ( + + ))} + +); +const gradientBar = (colors: string[]): ReactNode => ( + +); + +const CATEGORICAL_OPTIONS: SelectControlOption[] = schemesByKind('categorical').map( + (s) => ({ + value: s.name, + label: s.label, + preview: swatchStrip(schemeColors(s.name)), + }), +); +const SEQUENTIAL_OPTIONS: SelectControlOption[] = schemesByKind('sequential').map((s) => ({ + value: s.name, + label: s.label, + preview: gradientBar(schemeColors(s.name, 12)), +})); +const DIVERGING_OPTIONS: SelectControlOption[] = 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 ( + + onChange(e.target.value)} + /> + { + 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 + )} + + ); +} + +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 ( +
+ {/* ── Categorical palette ─────────────────────────────────────────── */} +
+

Categorical palette

+

Series colors — assigned to discrete categories in order.

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

Default mark color

+

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

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

Sequential gradient

+

Continuous color — heatmaps and quantitative legends.

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

Diverging gradient

+

Two-ended color — values around a meaningful midpoint.

+
+
+ {divArray && ( +
+ {divArray.map((color, i) => ( + + set( + DIVERGING, + divArray.map((c, j) => (j === i ? hex : c)), + ) + } + onRemove={() => + set( + DIVERGING, + divArray.length === 1 ? undefined : divArray.filter((_, j) => j !== i), + ) + } + /> + ))} +
+ )} +
+
+ ); +} diff --git a/src/app/components/SelectControl.module.css b/src/app/components/SelectControl.module.css index a1c81b1..d9b037b 100644 --- a/src/app/components/SelectControl.module.css +++ b/src/app/components/SelectControl.module.css @@ -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; diff --git a/src/app/components/SelectControl.tsx b/src/app/components/SelectControl.tsx index b016b91..717e15c 100644 --- a/src/app/components/SelectControl.tsx +++ b/src/app/components/SelectControl.tsx @@ -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 { * 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 { @@ -171,7 +181,12 @@ export function SelectControl({ aria-haspopup={o.hasPopup} onClick={() => choose(o.value)} > - + {o.preview !== undefined && ( + + )} + {o.label} {o.detail !== undefined && ( {o.detail} diff --git a/src/app/components/ThemeBuilderModal.module.css b/src/app/components/ThemeBuilderModal.module.css index 0f626b0..e78bdc2 100644 --- a/src/app/components/ThemeBuilderModal.module.css +++ b/src/app/components/ThemeBuilderModal.module.css @@ -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; diff --git a/src/app/components/ThemeBuilderModal.tsx b/src/app/components/ThemeBuilderModal.tsx index 4fbcfdb..369434d 100644 --- a/src/app/components/ThemeBuilderModal.tsx +++ b/src/app/components/ThemeBuilderModal.tsx @@ -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[] = 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('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) => { + 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() { } /> - ({ value, label }))} - onSelect={(family) => useCustomThemeStore.getState().applyDraftFont(family)} - triggerContent={<>Font…} - triggerTitle="Write one font family into every font slot of the config" - />
- {(saveError ?? parseError) !== null && ( -

- {saveError ?? parseError} -

- )} +
+
+
+ {THEME_TABS.map((t) => ( + + ))} +
-
-
- - {/* 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. */} -