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
@@ -303,13 +303,19 @@ width rather than a variable font's possibly-condensed default instance.
### Structured controls
The builder's panels — Color, Type, Layout, Axes & grid, Legend
(`ColorControls` + `TypeControls`/`LayoutControls`/`AxesControls`/`LegendControls`
on the shared `ThemeFields` field primitives) — are accelerators over the same
`draftConfig`: each reads a value and writes one back through
`CustomThemeStore.mutateDraftConfig(fn)` — the single transform path, which
reparses, reformats, and updates `draftConfig` so the JSON editor and gallery
follow (a parse error disables the controls). The pure transforms live in
The builder's panels — Color, Marks, Type, Title, Layout, Axes & grid, Legend,
Headers, Formats — are accelerators over the same `draftConfig`, one panel per
config domain (each an `XxxControls` component on the shared `ThemeFields`
primitives). `ThemeBuilderModal` holds the single `id → label → Panel` registry
(`THEME_TABS`); panels switch via a **vertical tab list** (APG vertical tabs),
and each panel groups its properties into a **single-expand accordion**
(`ThemeFields` `Accordion` — one section open at a time, each with a set-count
badge so customized sections are scannable while collapsed). Each control reads a
value and writes one back through `CustomThemeStore.mutateDraftConfig(fn)` — the
single transform path, which reparses, reformats, and updates `draftConfig` so
the JSON editor and gallery follow (a parse error disables the controls). The raw
JSON below the panels is the full-power escape hatch for the long tail the
structured controls deliberately omit. The pure transforms live in
`core/theme-controls.ts`: immutable config path get/set, leaf coercion, the
named-scheme catalog (`THEME_SCHEMES`), and `schemeColors` (scheme name → hex
swatches, from the `vega-scale` registry — a focused vega sub-package). A color
@@ -339,8 +345,13 @@ reader announces is unambiguous.
`normalizeRangeSchemes` (core) heals the bare form at the render-resolution
points (`chartConfigForSelection`; the builder gallery) for configs authored or
saved before this was enforced.
- **Do** add a `theme-preview-specs.ts` gallery card for any new color family,
so no control ships without a visible mirror.
- **Do** give every control a visible mirror in the `theme-preview-specs.ts`
gallery, and keep those sample specs on **bare marks** (`mark: 'point'`, not
`{ type: 'point', size: 80 }`): a property hard-coded in a spec overrides the
injected config, making the matching control a no-op in the preview. Mark
styling belongs in the config (a theme), never inline in a card. Default chart
size and tooltips are the unavoidable exceptions — fixed-size swatches, and
hover-only respectively.
### Rules
@@ -660,6 +660,31 @@ without clipping, wrapping, or crowding.
---
## 9. Organizing a large control surface (two levels)
A control surface too big for one scroll (the Theme Builder spans most of the
Vega-Lite config) is organized in two levels, each with a settled widget so the
choice isn't re-litigated per surface:
- **Level 1 — switch by domain with tabs.** Mutually-exclusive top-level
categories (Color, Marks, Type, …) are an APG **tab set**, one panel visible at
a time. Past a handful of tabs a horizontal strip wraps raggedly and the active
tab shifts rows; a **vertical tab list** (`aria-orientation="vertical"`, Up/Down
- Home/End) scales without wrapping and keeps the active panel anchored. Never
**nest** tab sets — two roving tablists collide.
- **Level 2 — group within a panel by how it's read.** Sub-groups a user reads in
full get **flat headings** (`role="group"` labelled by the heading). Sub-groups
where a user tunes one or two and skips the rest get a **single-expand
accordion** (APG accordion — heading-button toggles a `role="region"`; Up/Down
between headers) — Carbon's rule: accordion is for content "not crucial to read
in full." A per-section **modified badge** (count of set properties) keeps an
override scannable while collapsed (NN/g #6, recognition).
_(Consulted via /council → WAI-ARIA APG tabs/accordion/disclosure, IBM Carbon
accordion usage, NN/g #6/#8.)_
---
## Do / Don't
**Do**
+29 -17
View File
@@ -205,10 +205,12 @@ schema-typed model, or it silently drops those keys on a round-trip. Same shape
**Resolved design points:**
- **Surfacing — inline tab strip** (not popovers, not sub-modals). Tabs (Color / Type /
Layout / Axes & grid / Legend) sit between the toolbar and the JSON+gallery, all in the
one xlarge modal. No nested overlays/focus traps, no contention with the one-open-popover
registry, and panels + JSON + gallery stay visible together.
- **Surfacing — vertical tab list + per-panel accordion** (not popovers, not sub-modals),
all in the one xlarge modal. One panel per config domain, switched by a vertical tab rail
(L1); within a panel, related properties group into a single-expand accordion (L2). No
nested overlays/focus traps, no contention with the one-open-popover registry; panel + JSON
- gallery stay visible together. The two-level grouping rule is recorded in arch 10
(_Organizing a large control surface_).
- **Color model — scheme picker that materializes to swatches.** A `range` family takes
either an explicit color array or a named scheme written as Vega's range-scheme object
`{ scheme: name }`. (A bare scheme-name _string_ passes vega-lite compile but Vega
@@ -223,24 +225,34 @@ schema-typed model, or it silently drops those keys on a round-trip. Same shape
- Controls write **minimal** config — clearing a value deletes the key rather than writing
a default, so a theme stays a diff against stock, not a full dump.
**Panels:** Color (`range.category` swatches/scheme, `mark.color`, `range.heatmap`/`ramp`/
`diverging`) · Type (base `font`, title + axis title/label size+weight) · Layout (`background`
incl. transparent, `padding`, `view.stroke`/`fill`/cornerRadius) · Axes & grid (grid on/off +
color + dash, domain, label color/angle, title color — base `axis` only; the 25 variants stay
JSON) · Legend (orient, title/label color+size, symbol size). Legend _type_ (size) lives in
the Legend panel rather than Type, so every legend property a brand tunes sits together.
**Panels** (each an accordion of the groups below): Color (`range.category`, `mark.color`,
sequential `range.heatmap`/`ramp`, `range.diverging`) · Marks (per type — bars, lines &
areas, points, arc — plus generic opacity/fill/tooltips) · Type (base `font`, axis
title/label size+weight) · Title (anchor, colour, size/weight/style, offset, subtitle block)
· Layout (`background`, `view` fill/border/radius, `padding`, default size) · Axes & grid
(grid, ticks, domain, labels — base `axis` only; the per-channel variants stay JSON) · Legend
(placement/direction, title, labels, symbols, gradient, box) · Headers (facet title/label
colour/size/weight) · Formats (number/date/normalized formats, count title).
**Build order:** (a) core foundation — scheme catalog + immutable config path get/set +
`schemeColors` materialize, with tests; (b) Color panel (highest payoff); (c) Type, Layout,
Axes, Legend panels; (d) wire the tab strip into the modal.
**Not in slice 4b:** the house style's own gaps — no `mark.color` (single-series charts stay
Vega-blue), unset legend/header/padding — are left for a separate house-style redo, not
papered over here. Minor cleanup noted: `theme-preview-specs.ts` declares `$schema` v5 while
the app standardizes on v6.
`schemeColors` materialize, with tests; (b) Color panel (highest payoff); (c) the remaining
per-domain panels; (d) wire the navigation (vertical tabs + per-panel accordion) into the
modal.
## 6. Status log
- **2026-06-21** — **structured-control surface expanded + accordion everywhere.** New panels
— Marks (per mark type), Title & subtitle, Headers (facets), Formats — and deepened
Axes/Legend/Layout, covering the brand-tuning bulk of the config; the raw JSON stays the
escape hatch for the long tail. Every panel is a single-expand accordion with a per-section
set-count badge, switched by a vertical tab list, and the modal holds one
`id → label → Panel` registry. Shared primitives extracted: `WeightRow` (UI),
`enumValue`/`countSet` (core). The gallery is now fully reflective — sample specs use bare
marks so no control is shadowed (guarded by a test), and a normalized area + a temporal
facet were added so `normalizedNumberFormat` and `timeFormat` have mirrors; default chart
size and tooltips are the only non-previewable controls. Recorded in arch 10 (_Organizing a
large control surface_), arch 05 (_Structured controls_), and spec §04. Remaining in §4:
Google Fonts opt-in tier; built-in preset gallery; Color-panel swatch reorder.
- **2026-06-16 (slice 7)** — **font export round-trip + SVG embed.** Uploaded faces now
survive a workspace transfer and travel inside an exported SVG. Core: `serializeFontAsset`/
`deserializeFontAsset` (+ base64 helpers), `primaryFamilyName`, and `fontDataUri` in
+4 -3
View File
@@ -61,9 +61,10 @@ The **Theme Builder** is a full-size modal for creating and editing custom chart
Layout: a saved-theme list on the left; the open theme's editor on the right.
- **New theme** creates a theme seeded as a **copy of the chart theme currently selected** in the preview (house style, stock, a preset, or another custom theme), named after its source (e.g. "FiveThirtyEight copy") and auto-suffixed if taken. Duplicating a preset is the expected starting point. The other creation path is the editor's **Extract Config to New Theme** action (see _Spec Editor → Spec ↔ Config Actions_), which turns a pasted spec's `config` block into a theme directly.
- The editor shows the theme's **name** and its **config as editable JSON text**. Invalid JSON is reported inline and blocks saving; the text must parse to a JSON object.
- A **font control** applies a chosen font family across the whole config in one step: it sets the top-level `font` (Vega-Lite's default for every text mark, label, and title) and rewrites every explicit `font`/`labelFont`/`titleFont`/`subtitleFont` slot anywhere in the config — the slots that would otherwise keep overriding the new default. Offered fonts are limited to faces that render without loading (the app's own Plex faces and web-safe/system stacks) until the self-hosted font roster ships.
- A **gallery** of small fixed sample charts (bar with title, multi-series line with subtitle, stacked area, scatter with a gradient legend, heatmap, donut, facets with headers) re-renders live from the draft config — the same config-injection path the preview uses — so one edit is previewed across every chart surface a config styles. While the JSON is invalid, the gallery keeps showing the last valid state.
- **Structured controls** organize the config into panels by domain — Color, Marks, Type, Title, Layout, Axes & grid, Legend, Headers, Formats — navigated by a vertical tab list, each panel's properties grouped into collapsible sections. A control writes one config property, and clearing it removes the key, so a theme stays a minimal diff against stock. The controls cover the common brand-tuning surface, not every Vega-Lite property.
- The theme's **name** and its full **config as editable JSON text** sit below the controls as the escape hatch for anything they don't expose. Invalid JSON is reported inline and blocks saving (and is the only editing surface while invalid); the text must parse to a JSON object.
- A **font control** applies a chosen font family across the whole config in one step: it sets the top-level `font` (Vega-Lite's default for every text mark, label, and title) and rewrites every explicit `font`/`labelFont`/`titleFont`/`subtitleFont` slot anywhere in the config — the slots that would otherwise keep overriding the new default. Offered fonts are the self-hosted roster plus any the user uploads (see _Data Model → FontAsset_), the user's own faces listed first.
- A **gallery** of small fixed sample charts re-renders live from the draft config — the same config-injection path the preview uses — spanning the mark types, the color families, faceting, and titled charts so one edit is previewed across every surface a config styles. **Every structured control has a visible mirror** in at least one card; the default chart size and tooltips are the exceptions (the cards are fixed-size; tooltips are hover-only). While the JSON is invalid, the gallery keeps the last valid state.
- **Save** commits the draft (disabled while unchanged or unparseable). Names are unique case-insensitively, like dataset names. **Delete** removes the theme after confirmation.
- Closing with unsaved edits prompts for discard, like other form modals. A backdrop click does not dismiss the builder (Escape and the close button do).