Files
astrolabe/docs/architecture/05-rendering-theming-preview.md
T

728 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Rendering, Theming & Live Preview
How Astrolabe turns a user-authored Vega-Lite specification into a live chart in
the preview pane. This covers four mechanics: **embedding** a spec via
`vega-embed`, **theming** so charts match the active UI theme, **debounced
re-rendering** so typing stays smooth, and **error handling** so a broken spec
produces a readable message and self-heals. It deliberately stops at the
embedding boundary — the _content_ of the spec (resolving named-dataset
references, applying fit-mode sizing) is prepared upstream by a pure transform;
see §6.
---
## 1. The Embedding Boundary
The preview is a thin imperative layer wrapping the `vega-embed` library, driven
by reactive store state. The flow is always the same:
```
spec text ──parse──▶ Vega-Lite spec object
prepareSpecForRender(spec, { fitMode }) ← pure, src/core/rendering.ts
│ (operates on a COPY; never mutates the stored spec)
render(node, preparedSpec, config) ← src/app, this doc
vega-embed ─▶ View ─▶ SVG in the DOM node
```
`vega-embed` is the only place in the app that touches the chart DOM. Everything
above it is data; everything below it is a Vega `View` we own and must tear down.
### Rules
- **Do** keep all `vega-embed` calls behind one small renderer module. Components
ask the renderer to draw a spec into a node; they never import `vega-embed`
directly.
- **Do** treat the renderer as imperative glue driven by store state (via a
`subscribe` listener), not as reactive state itself.
- **Don't** scatter `vegaEmbed(...)` calls across components.
### The data inspector rides the boundary too
The data inspector (the Live Preview and Chart Builder panel showing each drawn table's
input vs. resolved rows — spec §04) reads runtime rows through the handle, never the raw
view: `RenderHandle.inspectData()` returns the inspectable tables (`{ tables }`, or `null`
when no chart is up), wrapping the view exactly like `toImageURL`. It works in two layers:
- **Enumerate from the compiled spec (`core/inspect-views`).** A composed spec draws
several tables; `inspectableViews` walks the compiled Vega spec — the marks tree's
`from.data` (what each mark draws) and `data[].source` (the lineage, the documented Vega
format) — to list, in document order, one entry per **distinct drawn table** with its
`resolved` (post-transform, what the marks draw) and `input` (most-upstream source) ends.
Enumerating by drawn table, not by authored view, is forced by Vega-Lite desugaring (a
`point: true` line compiles to two layers — a compiled table can't be traced back to one
authored view). Selection `*_store`s and `facet_domain*` layout tables aren't drawn, so
they fall out for free. The walk is pure (in `core`, unit-tested); the boundary reads each
table's rows via `view.data(name)`.
- **Read lazily.** Reading serializes rows, so it happens **only while the panel is open**
a collapsed inspector costs nothing, which is why the panel reads on demand rather than on
every render. (A multi-view spec yields several tables; the panel's `SelectControl` picker
chooses which to show — labels never expose Vega's compiler names, see arch 10.)
---
## 2. vega-embed Integration
A single async `render` function embeds a prepared spec into a DOM node. Three
non-negotiable embed options, plus disciplined teardown of the previous view:
```ts
// src/app/services/chart-renderer.ts (sketch)
import vegaEmbed, { type Result as EmbedResult } from 'vega-embed';
import type { Config, TopLevelSpec } from 'vega-lite';
export interface RenderHandle {
/** Finalize the underlying Vega view and release its resources. */
destroy(): void;
}
export async function renderSpec(
node: HTMLElement,
spec: TopLevelSpec,
config: Config,
): Promise<RenderHandle> {
const result: EmbedResult = await vegaEmbed(node, spec, {
actions: false, // no built-in export/source/editor menu — clean chart
renderer: 'svg', // crisp, inspectable, copyable output
config, // theme config (see §3)
});
return {
destroy() {
// Frees timers, listeners, and the canvas/SVG the view created.
result.view.finalize();
node.replaceChildren(); // drop any leftover DOM the embed inserted
},
};
}
```
### The view lifecycle is the bug surface
Every successful `vegaEmbed` returns a `result.view` (a live Vega `View`
instance). It owns timers, signal listeners, and DOM. If you embed a new spec
into the same node _without_ finalizing the old view, the old one leaks — its
listeners keep firing and resources accumulate over a long editing session.
The renderer that drives re-rendering must therefore hold the previous handle and
destroy it before (or while) creating the next:
```ts
let current: RenderHandle | null = null;
async function rerender(node: HTMLElement, spec: TopLevelSpec, config: Config) {
current?.destroy(); // tear down the previous view first
current = await renderSpec(node, spec, config);
}
```
### Rules
- **Do** pass `actions: false`. Astrolabe owns its own export/copy affordances;
the library's overlay menu does not belong on the preview.
- **The per-chart export goes through the handle, not the raw view.** `RenderHandle`
exposes `toImageURL('png' | 'svg', { scale, background })` so the preview's Export
control can rasterize/serialize the live chart without any component importing
`vega-embed` or touching the `View` directly — the embedding boundary holds. PNG goes
via `view.toCanvas``blob:` URL (revoked after the download); SVG via `view.toSVG`
`data:` URL. Renderer-agnostic: both work from the SVG-backed LivePreview view, since
Vega draws to its own off-screen surface here. Two non-obvious details live in the
handle, not the caller: **(1) dpr-aware scale** — the PNG is drawn at
`scale × devicePixelRatio`, so a `1×` export is as crisp as the chart on a Retina
screen (raw `toImageURL` scaleFactor ignores dpr, so a naive 1× looks half-resolution
on a 2× display). **(2) background fill** — the chart config renders a transparent
background (so the on-screen chart shows the pane colour), which would make a naive
export transparent; an opaque colour is composited under the PNG canvas and added as a
full-bleed `<rect>` to the SVG. The spec-text exports (copy / `.vl.json`) need no view.
- **SVG is the default renderer, canvas is an opt-in for many-mark previews.** SVG is
crisp/inspectable/copyable and stays the default for the editor's LivePreview. But an
SVG chart renders one DOM node per mark, so a many-mark chart (e.g. the Chart Builder's
default one-bar-per-row on a 10k-row dataset) costs **seconds** of main-thread
layout/paint per render (the chart paints _after_ it first appears, freezing the tab). The **Chart Builder preview** therefore passes
`renderSpec(…, { renderer: 'canvas' })` — canvas is a single node and paints in
milliseconds. The raster trade-off is invisible for an ephemeral preview, and image
export (`view.toImageURL`) is renderer-agnostic.
- **Canvas has a hard max dimension; SVG doesn't.** A canvas larger than the browser's
limit (~32k px/side, less on Safari) fails to allocate and draws _nothing_ — silently.
So for canvas, `renderSpec` first runs a headless (`'none'`) layout probe, reads the
resolved height, and throws `ChartTooLargeError(heightPx, limitPx)` when it exceeds
`MAX_CANVAS_PX ÷ devicePixelRatio`, so the caller can show the real cause. This is a
**render-size** limit (the chart is physically too big), distinct from the readability
cardinality warnings — don't conflate them. Only an _unbounded_ axis overflows: a
`width: 'container'` axis is bounded, so it's the deleted (natural-height) axis to watch.
- **Load fonts before rendering.** Vega measures every text label via canvas
`measureText` **regardless of renderer** (even the `'none'` probe runs layout),
so a face that finishes loading after embed lays the whole chart out with
fallback metrics. `renderSpec` therefore gates on `document.fonts.load` for the
families a spec+config reference (`collectFontFamilies`, core) before any layout
pass. This is a non-critical enhancement, so it waits on `allSettled` + a
timeout: a face failing (offline, 404, a system family with no `@font-face`)
degrades to fallback metrics rather than failing the chart (the sanctioned
swallow under §7's fail-loud rule). Chart fonts are self-hosted in
`styles/chart-fonts.css` (offered by the Theme Builder's font control); only
their latin subsets are precached, the rest runtime-cached (vite.config Workbox).
User-uploaded faces are registered on `document.fonts` too, so the same gate
resolves them (§3 → User-uploaded fonts).
- **Do** call `view.finalize()` on every previous view before rendering a new
one, and on component unmount.
- **Do** keep exactly one live view per preview node.
- **Don't** re-embed into a node whose previous view you have not finalized.
- **Don't** keep a reference to a finalized view; null it out.
---
## 3. Theme Follows the UI Theme
A Vega-Lite **config** object styles every chart globally — fonts, axis colors,
background, the categorical color range. Astrolabe ships one config per UI theme
so charts visually belong to the app rather than looking like stock Vega-Lite.
`src/core/vega-themes.ts` is the single source of truth; each house config is
**two merged layers** (the full audit and forward plan live in
[`docs/exploration/chart-theming-scope.md`](../exploration/chart-theming-scope.md)):
- **Base** (`lightBaseConfig`/`darkBaseConfig`) — the legibility minimum:
`background: 'transparent'` (the pane shows through) plus guide colors on the
app's text/border tokens. Without it, stock black-on-white chart text is
illegible on the dark pane.
- **Expressive** (`lightExpressiveConfig`/`darkExpressiveConfig`) — the house
style: IBM Plex, the Carbon data-viz 14-color categorical palette, dotted
grid, bumped guide sizes/weights, no plot border.
`mergeChartLayers(base, expressive)` produces `lightChartConfig`/
`darkChartConfig`, and `chartConfigFor(uiTheme)` is the one UI-theme → config
mapping. The split exists so a non-house style can keep the base layer while
swapping the expressive one (future custom themes).
### Selectable chart themes
On top of the house pair, the user picks a **chart theme** (spec §04 → Chart
theme) — `ChartThemeSelection = 'astrolabe' | 'stock' | <vega-themes preset id>
| 'custom:<id>'`:
- `'astrolabe'` resolves via `chartConfigFor(uiTheme)` (follows light/dark);
- `'stock'` resolves to `{}` — nothing injected, pure Vega-Lite defaults;
- preset ids resolve to the `vega-themes` package's configs verbatim (the same
presets as the Vega editor's theme dropdown; the package is already in the
tree as a vega-embed dependency);
- `custom:<id>` resolves to a saved `CustomTheme` record's config (spec §09G).
Selection is keyed by record **id**, not name, so a rename never invalidates
the persisted preference; a missing record (themes hydrate async from
IndexedDB; the record may be deleted) resolves to the house config rather
than rendering unstyled, and deleting the actively-selected theme resets
`AppStore.chartTheme` to `'astrolabe'` (CustomThemeStore.remove).
`chartConfigForSelection(selection, uiTheme, customThemes)` is the only
resolver; `chartThemeOptions(customThemes)` derives the full picker list
(built-ins, customs, presets — memoize the call: it returns a fresh array). The
choice lives in `AppStore.chartTheme`, persisted as `ui.chartTheme` by
`orchestration/preferences.ts` (the `previewFitMode` pattern; persistence
validates with `isChartThemeSelection`, which accepts `custom:<id>` on shape
alone), and is surfaced by a `SelectControl` in the LivePreview header — **not**
inside the PreviewSettings popover: `SelectControl` and `SettingsPopover` share
the one-open-popover registry, so a select nested in the popover would close
(and unmount) its own parent on open. The "Edit themes…" action row opens the
Theme Builder without changing the selection (the VS Code theme-picker
pattern); it closes the custom-themes block — after the built-ins, **before**
the long preset roster — so it's visible without scrolling and sits next to
the entries it manages.
### Custom themes & the Theme Builder
`CustomTheme` records (`core/custom-theme.ts`) persist in their own IndexedDB
store through the standard stack: `infrastructure/theme-store.ts` (+ read-time
`theme-migrations.ts`), `stores/CustomThemeStore.ts` (the themes array plus the
builder's draft state), and `orchestration/theme-persistence.ts` (diffing
write-through, wired after hydrate in `startup.ts`) — the exact dataset
pattern, one tier each.
The Theme Builder modal (`ThemeBuilderModal`, registered as `themeBuilder`,
xlarge shell, no backdrop dismissal) edits a **draft** held in the store:
`{ name, configText }` plus `draftConfig` — the last text state that parsed.
The gallery (`core/theme-preview-specs.ts`, fixed inline-data swatch specs)
renders `draftConfig` per card through the shared `renderSpec` with the
**canvas** renderer and a per-card debounce + chain-lock (the LivePreview
serialization pattern, one lock per card) — so invalid JSON mid-edit never
blanks the preview, and seven concurrent embeds never interleave on a node. A
card whose render throws shows the error message in place of the chart (the same
fail-loud treatment as LivePreview, §7), never a silent blank.
`applyFontToConfig(config, family)` is the font control's transform: it sets
the top-level `font` and rewrites every `font`/`*Font` string slot at any
depth — explicit slots would otherwise keep overriding the new default.
Creation paths: the builder's "New theme" duplicates the currently selected
chart theme's resolved config, and the editor's **Extract Config to New
Theme** action (`runExtractConfigToTheme`, spec §03G) lifts a spec's `config`
block into a theme, selects it, and removes the block — the spec-to-library
direction of the same boundary the merge action crosses the other way.
Render-time precedence: vega-lite merges the injected config **under** the
spec's own `config` (`mergeConfig(opt.config, spec.config)` — the spec wins
key-by-key), so a snippet can always override or opt out locally. The
`core/spec-config.ts` merge/extract operations (spec §03G) move styling across
that boundary deliberately: merge bakes the selected theme into `spec.config`
(spec keys win — rendering unchanged), extract lifts `spec.config` out.
### User-uploaded fonts
Beyond the self-hosted roster, a user can upload font files (`.woff2`/`.woff`/
`.ttf`/`.otf`) for chart themes. A `FontAsset` (`core/font-asset.ts`) holds the
raw bytes and persists through the standard entity-store stack — a `fonts`
IndexedDB store, `infrastructure/font-store.ts` (+ `font-migrations.ts`),
`stores/FontStore.ts`, `orchestration/font-persistence.ts` — the same
one-tier-each shape as datasets and custom themes. The browser seam that turns
stored bytes into a live face, `infrastructure/font-faces.ts`, registers on
`document.fonts` at startup (before the first render, so the §2 font gate
resolves user faces like the roster) and on each upload; uploads/deletes are
orchestrated by `services/fonts.ts`. The Type panel lists user fonts ahead of the
roster and applies one via the same `applyFontToConfig` transform.
**Font dependencies are derived from the config, never stored on the theme.** A
`CustomTheme` carries no font field: the family a theme (or a snippet) uses is
already in its config's `font`/`*Font` slots, that JSON config is the source of
truth, and snippets use uploaded fonts with no theme to carry such a field. So a
used face is discovered by scanning configs/specs for the families they reference
(`collectFontFamilies`, core) and matching against the font library — a stored
field would only drift from the config it duplicates.
(Embedding the matched faces' bytes into the §08 workspace export and per-chart
SVG export is the remaining transfer work, on shared base64 machinery.)
**Variable fonts: weight is the only leverageable axis.** `parseFontAxes` reads
the OpenType `fvar` table (uncompressed `ttf`/`otf` only — `woff`/`woff2` wrap
their tables in compression we don't unpack, so those register as static), and a
variable face is registered with its `wght``weight` and `wdth``stretch` ranges
so one file serves the whole weight range, driven by the Type panel's weight
controls. Only those two axes take effect because Vega's text rendering emits a
CSS font shorthand (family/size/weight/style) with **no `font-variation-settings`
hook** — optical size, grade, and custom axes pin at the registered default and
can't be exposed. Declaring the `wdth` range also defaults the face to normal
width rather than a variable font's possibly-condensed default instance.
### Structured controls
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
family holds **either** a named scheme as Vega's range-scheme **object**
`{ scheme: name }` **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`).
Repeated control labels across panels ("Size", "Color", "Weight") get a
qualified accessible name while keeping the short visible label; the section is a
`role="group"` labelled by its heading (APG group pattern), so the name a screen
reader announces is unambiguous.
- **Do** bind a panel's writes to the shared `useConfigSetter()` hook (in
`CustomThemeStore` — beside `mutateDraftConfig`, not the JSX field module, which
stays component-only for fast refresh). It is `mutateDraftConfig` + `setConfigValue`:
sets a value at a path **immutably, preserving sibling keys**, and deletes
(pruning emptied ancestors) on `undefined` so a theme stays a diff. A new panel
uses it rather than re-inlining the pair.
- **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** write a named scheme into `range.*` as the object `{ scheme: name }`. A
bare scheme-name string passes vega-lite _compile_ but Vega rejects it at
_render_ ("Unrecognized scale range value"), blanking the chart.
`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** 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
- **Do** keep `chartConfigForSelection` as the _only_ place that maps the user's
selection (and UI theme) to a Vega config.
- **Do** set chart `background: 'transparent'` in the house configs so the
pane's own background shows through and theme switches look seamless. Preset
themes carry their own backgrounds (often white) and render as their authors
intended — honest preview beats pane-matching.
- **Do** keep the Chart Builder preview and onboarding thumbnails on
`chartConfigFor(uiTheme)` — they are app surfaces, not destination previews.
- **Don't** inline colors or fonts into individual specs to "match the theme" —
that is the config's job, and per-spec styling drifts from the app.
- **Don't** write the injected config into the user's stored spec implicitly;
it is applied at embed time, leaving the spec theme-agnostic. Baking it in is
the explicit, user-invoked merge action only.
### Theme flow (end to end)
Theme spans several layers; the path is:
`AppStore.uiTheme` (+ `toggleTheme`) → `orchestration/theme.ts` mirrors it onto
`<html data-theme>` and writes through to `infrastructure/settings-store.ts`
(localStorage `ui.theme`). On load, `initTheme()` — called from `main.tsx`
**before** `createRoot().render` — hydrates the saved theme. Chart and editor
follow by subscribing to `uiTheme`: `LivePreview` re-embeds with
`chartConfigForSelection(chartTheme, uiTheme)`, `SpecEditor` sets the Monaco
theme. UI chrome repaints
purely from the `[data-theme]` token swap in `styles/tokens.css`. The header
`ThemeToggle` is the user control.
- **Do** hydrate the theme **synchronously before first paint** — an async
hydrate (e.g. inside `initApp`) flashes the default theme on load.
- **Do** keep the store browser-free: the `data-theme` write and the localStorage
write-through live in `orchestration/theme.ts`, never in the store or a component.
- The control currently lives in the header; spec §07 houses it in the Settings
modal (M5), which will share the same `ui.theme` key.
---
## 4. Field-Name Escaping
Vega-Lite treats `.`, `[`, and `]` inside a `field:` string as **nested-property
accessors**: `field: "user.age"` reads `row.user.age`, not a column literally
named `"user.age"`. Astrolabe renders arbitrary user data whose column names may
contain those characters, so any column name placed into a `field:` (or `as:`,
`groupby:`, tooltip `field:`, etc.) must be escaped first.
```ts
// src/core/rendering.ts (sketch)
/** Escape `.`/`[`/`]` so Vega-Lite treats the string as a literal field name. */
export function escapeVegaField(name: string): string {
return name.replace(/([.[\]])/g, '\\$1');
}
```
```ts
// usage when constructing/normalizing an encoding that references a column:
encoding.x = { field: escapeVegaField(columnName), type: 'quantitative' };
```
This matters wherever Astrolabe _constructs_ spec fragments from data-derived
column names — most notably the chart builder (see _Chart Builder_ spec) and any
helper that injects an encoding. For specs the user authored by hand, escaping is
the user's responsibility; Astrolabe does not rewrite hand-authored `field:`
values.
### Rules
- **Do** route every data-derived column name through `escapeVegaField` before it
lands in a `field:` (or any field-position key).
- **Don't** ever pass a raw column name to `field:`. If the name came from data,
it is unescaped until proven otherwise.
---
## 5. Debounced Preview
Rendering must never compete with typing. The preview re-renders only after the
user pauses, the pending render is cancelled on each new keystroke, and a render
in flight never blocks the editor.
The debounce delay is **user-configurable** via the `performance.renderDebounce`
setting (range ~5005000 ms). Read it live so changes take effect without reload.
```ts
// src/app/services/debounced-renderer.ts (sketch)
export interface DebouncedRenderer {
/** Schedule a render after the debounce window; resets the timer. */
schedule(): void;
/** Render now, skipping the debounce (e.g. on fit-mode change or theme flip). */
flush(): void;
/** Cancel a pending render without rendering. */
cancel(): void;
}
// The factory wires the three operations over one timer. schedule() does
// `setTimeout(run, delayMs())`, clearing any pending timer first (delayMs read fresh
// so a settings change applies live); flush() clears the timer and runs now; cancel()
// clears it and bumps `generation`. The non-obvious part is out-of-order protection:
function createDebouncedRenderer(opts): DebouncedRenderer {
let generation = 0;
const run = async () => {
const mine = ++generation; // capture this render's turn
opts.setBusy(true);
try {
await opts.render();
} finally {
if (mine === generation) opts.setBusy(false); // only the latest render clears it
}
};
// …schedule/flush/cancel as above. A slow render that resolves after a newer one
// fails the `mine === generation` check, so it can't clobber the fresh view/indicator.
}
```
### Wiring it to the store
Startup subscribers observe the inputs that affect the picture — the current spec
text, the active fit mode, the UI theme — and call `schedule()` (debounced) for
spec edits, or `flush()` for instantaneous controls like a fit-mode toggle:
```ts
// wired once at startup
useEditorStore.subscribe((s, prev) => {
if (s.currentSpecText !== prev.currentSpecText) renderer.schedule(); // react to edits
});
useSettingsStore.subscribe((s, prev) => {
if (s.previewFitMode !== prev.previewFitMode || s.uiTheme !== prev.uiTheme) {
renderer.flush(); // immediate, no debounce
}
});
```
### Implemented policy: what renders immediately vs. debounced
> The service above is a **sketch** — its `useEditorStore`/`useSettingsStore` are
> illustrative placeholders; the real inputs are `useAppStore` (`previewFitMode` +
> `uiTheme`) and `useSnippetStore` (draft text / `bufferEpoch`). The shipped renderer
> lives inline in `LivePreview.tsx` (one `setTimeout` whose delay is computed per
> change) and subscribes to the stores via hooks rather than startup subscribers.
> When it is extracted into a service, preserve this policy.
The debounce exists to stay out of the way **while typing** — nothing else. So the
delay is `0` (immediate) for everything except keystrokes (spec §03C):
- **Immediate** — a _programmatic buffer load_ (`SnippetStore.bufferEpoch` changed:
select / create / duplicate / revert / hydrate) or a _Draft↔Published switch_
(`editorView` changed). These are the cases §03C names; the editor and preview
both key off `bufferEpoch` to tell a load from a keystroke.
- **Debounced** — a keystroke (only `shownText` changed). This is the churn the
debounce protects against.
Detect "this was a keystroke" by elimination: `shownText` changed but `bufferEpoch`
and `editorView` did **not**. Fit-mode and theme changes currently fall through the
debounce too (harmless; not typing) — flush them if instant feedback is wanted, but
never debounce a load or a view switch.
### Busy indicator
`setBusy(true/false)` toggles store state that the preview reads to overlay a
**subtle, non-blocking** spinner/shimmer. It sits _over_ the existing chart so the
last good render stays visible while the next one computes — the pane never goes
blank mid-edit.
### Rules
- **Do** read `renderDebounce` fresh on each `schedule()` (via the `delayMs()`
thunk) so a settings change applies immediately.
- **Do** cancel the pending timer on every new input before scheduling the next.
- **Do** guard against out-of-order completion (the `generation` counter): a slow
render that resolves after a newer one must not clobber the indicator or view.
- **Do** keep the busy indicator non-blocking and overlaid; never clear the chart
to show "rendering…".
- **Don't** render synchronously on every keystroke.
- **Don't** await a render inside an input/keydown handler.
### A second preview surface: the Chart Builder
The editor's `LivePreview` is **bound to the snippet editor** — it reads `SnippetStore`
(shown spec), `AppStore` (fit mode/theme), and `PreviewStore` (shared error). The
**Chart Builder modal** needs a preview of a _different_ spec source (its config), so it
does **not** reuse `LivePreview`; it runs its own small debounced render over the same
`chart-renderer.renderSpec` + `prepareSpecForRender`, with **local** error state (never the
shared `PreviewStore`, which would cross-talk with the editor). Two preview surfaces, one
renderer service. Builder flow: `chart-builder.ts` (pure spec assembler) → `ChartBuilderStore`
(config + create) → `ChartBuilderModal`'s `BuilderPreview`. Reach for a reusable preview
component only if a _third_ surface appears.
The builder's **X/Y axis controls live in the preview pane, not the config pane**: the
on-chart Columns/Rows shelves (`OnChartShelves`) sit _above_ `BuilderPreview`, because axis
position is a property of the chart (Tableau's Columns/Rows metaphor). The field shelf and
the Colour/Size Marks card stay in the config pane. A reserved faceting slot in each shelf
is a placeholder only.
---
## 6. Rendering Contract Lives Upstream (reference)
Before a spec reaches `renderSpec`, it passes through a **pure** transform in
`src/core/rendering.ts`:
```ts
prepareSpecForRender(spec, { fitMode }): TopLevelSpec
```
It does two deterministic things, on a **deep copy** of the spec:
1. **Dataset reference resolution** — replaces any named-data reference with the
referenced dataset's actual contents (inline values, raw CSV/TSV text, or a
URL reference), recursing into layered/concat/child sub-specs.
2. **Fit-mode sizing** — rewrites `width`/`height` per the active fit mode using
Vega-Lite's `"container"` keyword (Original = untouched; Width sets
`width:"container"` and **removes** `height`; Height sets `height:"container"`
and **removes** `width`; Full sets both — see spec §04 → Fit-mode sizing),
recursing the same way.
This is _content_ preparation, not embedding, and it is fully covered by the
_Live Preview_ spec. The only invariant this doc cares about:
> `prepareSpecForRender` runs on a copy and returns a new spec. The renderer
> embeds that returned spec. **The user's stored spec is never mutated by
> rendering.**
The container-relative fit modes (Width/Height/Full) depend on `"container"`
sizing to follow the pane. Re-fitting on a **pane resize** is _not_ a re-embed:
the existing view is re-measured via a `ResizeObserver`-driven event — see §8.
### Rules
- **Do** call `prepareSpecForRender` between parse and embed, every render.
- **Don't** put reference resolution or fit-mode logic in the renderer — it is
pure core logic and must be unit-testable without a DOM.
- **Don't** mutate the input spec anywhere in the pipeline.
- **Fit modes overwrite the spec's own sizing** (Width replaces `width` _and
deletes_ `height`, etc.), so a surface that lets the user set an explicit
width/height must pass `fitMode: 'default'` while either is set and reserve the
container fit for auto sizing — the Chart Builder preview does exactly this.
---
## 7. Error Handling
A spec that cannot be rendered must produce a **readable** message in the preview
area and recover on its own once the spec is valid again. Errors arise at three
stages, all funneled to one error field the preview reads:
| Stage | Failure | Surfaced as |
| -------------------------------- | -------------------------------------- | ---------------------- |
| Parse | Invalid JSON | "Invalid JSON: …" |
| Prepare (`prepareSpecForRender`) | Referenced dataset missing/unfetchable | "Dataset not found: …" |
| Embed (`vega-embed`) | Vega-Lite compile / data error | "Rendering error: …" |
```ts
// inside render(), driven by the debounced renderer
async function render(): Promise<void> {
const text = useEditorStore.getState().currentSpecText.trim();
// Empty/blank is NOT an error — render nothing, clean pane.
if (!text) {
current?.destroy();
current = null;
usePreviewStore.getState().setError(null);
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch (e) {
usePreviewStore.getState().setError(`Invalid JSON: ${(e as Error).message}`);
return; // keep the last good chart underneath the error, or show the message
}
try {
const { previewFitMode, uiTheme } = useSettingsStore.getState();
const prepared = prepareSpecForRender(parsed, { fitMode: previewFitMode });
const config = chartConfigFor(uiTheme);
current?.destroy();
current = await renderSpec(node, prepared, config);
usePreviewStore.getState().setError(null); // success clears any prior error
} catch (e) {
usePreviewStore
.getState()
.setError(
`Rendering error: ${(e as Error).message}. ` +
`Check your JSON syntax and that the spec is valid Vega-Lite.`,
);
}
}
```
The preview component renders the chart node when `error` is `null`, and the
error panel when it is set. Because **every successful render clears the error**,
recovery is automatic: the next valid edit re-renders and wipes the message — no
manual retry, no reload.
### Rules
- **Do** treat empty/blank spec text as "render nothing" — finalize the current
view, clear the error, show a clean empty pane.
- **Do** clear the error state on every successful render.
- **Do** make messages legible and actionable (the underlying reason plus a hint
to check JSON/Vega-Lite validity), never a raw stack trace dump.
- **Do** distinguish the failing stage in the message (Invalid JSON vs Dataset
not found vs Rendering error).
- **Don't** show a broken/partial chart — replace the chart area with the
message.
- **Don't** require a manual "retry"; validity restores the chart on its own.
---
## 8. Container Sizing & Pane Resize (two gotchas that cost real time)
Vega-Lite's `"container"` sizing is responsible for the Width/Height/Full fit
modes, and it has **two non-obvious failure modes**. Both were rediscovered the
hard way; this section is the shortcut.
### Gotcha 1 — the embed host shrink-wraps, collapsing `width:"container"`
`vega-embed` brands the element you embed into with its own
`.vega-embed { display: inline-block }`, injected into `<head>` at runtime so it
**wins the cascade** over a class you put on that same element. `inline-block`
shrink-wraps horizontally, and `"container"` width reads `host.clientWidth` — so
the chart collapses to near-zero width. (Height often survives because a tall box
keeps `clientHeight`, which is why the symptom is "Width broken, Height fine".)
Note also: `vega-embed` only adds its responsive `chart-wrapper` (the element its
`width:100%` rule targets) **when `actions` are enabled** — we pass
`actions: false`, so that path is dead and the host is branded directly.
**Fix:** embed into a dedicated **inner host** with a _static_ className (React
never re-reconciles it, so Vega's runtime classes survive) nested inside a
**React-owned frame** that carries the fit-mode class. Size the host with
**two-class selectors** (`.fitWidth .host { width: 100% }`) that out-specify
`.vega-embed`. Original mode lets the host stay natural and the pane scrolls.
### Gotcha 2 — Vega re-measures only on `window:resize`
The compiled `width`/`height` signals re-evaluate `containerSize()` **only** on
`events: "window:resize"`. Consequences: `view.resize()` re-runs layout with the
**stale** size (it does _not_ re-measure), and a pane drag fires no window resize,
so a responsive chart does **not** follow the pane on its own.
**Fix:** a `ResizeObserver` on the host → `window.dispatchEvent(new Event('resize'))`
(behind `RenderHandle.resize()`, keeping the Vega knowledge in the renderer).
`ResizeObserver` callbacks are frame-batched, so this tracks a drag without a
debounce. Because only the container-bound dimension carries the resize handler,
Width re-fits width and leaves height natural automatically — no fit-mode
bookkeeping. Gate the observer to responsive modes (Original needs no re-fit).
### Rules
- **Do** give `vega-embed` its own inner host element; never put a React-managed,
changing `className` on the element `vega-embed` brands.
- **Do** out-specify `.vega-embed` (two-class selectors) when you must size the host.
- **Do** bridge pane-resize via a synthetic `window:resize`, not `view.resize()`.
- **Don't** assume `actions: false` leaves you the responsive `chart-wrapper` — it doesn't.
- **Don't** re-embed just to re-fit a resize; re-measure the existing view.
---
## Summary
| Concern | Mechanism | Source of truth |
| ------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------- |
| Embedding | One `renderSpec` over `vega-embed`, `actions: false`, `renderer: 'svg'` | `src/app/services/chart-renderer.ts` |
| View teardown | `view.finalize()` before each re-render and on unmount | the renderer's `RenderHandle` |
| Theming | Vega `Config` per UI theme, applied at embed time | `chartConfigFor()` in `src/core/vega-themes.ts` |
| Field names | `escapeVegaField` on every data-derived `field:` | `src/core/rendering.ts` |
| Debounce | Inline timer; `0` on buffer-load/view-switch, `renderDebounce` on keystroke (§5) | `LivePreview.tsx` (service not yet extracted) |
| Spec prep | `prepareSpecForRender` (pure, on a copy) | `src/core/rendering.ts` (see _Live Preview_) |
| Errors | One error field, cleared on success, empty = nothing | `PreviewStore.error` |
| Container fit | Inner host + frame (out-specify `.vega-embed`); resize via synthetic `window:resize` | §8 (`LivePreview` + `chart-renderer`) |