Implement fit-mode rendering contract with container sizing and pane re-fit

This commit is contained in:
2026-06-05 10:45:41 +03:00
parent f4253f50ca
commit 411bfbc6c2
13 changed files with 552 additions and 73 deletions
+34 -4
View File
@@ -174,7 +174,7 @@ set to Plex Mono explicitly since it can't read the CSS token.
--- ---
## M2 · Editor robustness ## M2 · Editor robustness ✅ (done)
**Goal:** the editor becomes trustworthy — draft vs published, schema-aware **Goal:** the editor becomes trustworthy — draft vs published, schema-aware
assistance, and the fit-mode rendering contract. assistance, and the fit-mode rendering contract.
@@ -185,7 +185,9 @@ assistance, and the fit-mode rendering contract.
Vega-Lite `"container"`), recursing into layered/concat/child specs (spec §04 Vega-Lite `"container"`), recursing into layered/concat/child specs (spec §04
Rendering Contract, step 2). Rendering Contract, step 2).
- `vega-lite-schema.ts` — provide the Vega-Lite JSON schema for Monaco's - `vega-lite-schema.ts` — provide the Vega-Lite JSON schema for Monaco's
validation/autocomplete (mine vega-editor for sourcing/versioning the schema). validation/autocomplete. _Delivered early in M1.5 as
`infrastructure/monaco-schema.ts` (bundled schema, offline, `markdownDescription`
hover docs); no further work needed in M2._
**App** **App**
@@ -210,6 +212,30 @@ assistance, and the fit-mode rendering contract.
- Invalid spec shows inline error; autocomplete suggests Vega-Lite properties. - Invalid spec shows inline error; autocomplete suggests Vega-Lite properties.
- Each fit mode resizes the chart as specified; choice survives reload. - Each fit mode resizes the chart as specified; choice survives reload.
**Verified:** `typecheck` + `test` (83 passing — `rendering` fit-mode incl.
nested layer/concat/facet specs, `SnippetStore` draft/publish/revert/editorView,
`settings-store` `previewFitMode` round-trip) + `build` (PWA, 41 precache
entries) + `eslint` clean. Implementation notes: editing now writes the
**draft** only (`commitDraft` no longer touches `spec`); `publish`/`revert` live
in `SnippetStore`, with a `bufferEpoch` counter so programmatic buffer reloads
(select/create/revert) refresh Monaco without fighting the cursor mid-typing.
The Draft/Published view is a store-level `editorView`; the published view is
read-only and the preview renders whichever version is shown (`selectShownText`).
The editor (§03E) and preview (§04) share one render error via a small
`PreviewStore`. `previewFitMode` was pulled into `AppStore` + the settings
adapter, hydrated/persisted by a new `orchestration/preferences.ts` mirroring the
theme slice. Publish/Revert **success toasts** stay deferred to M6 (TODO
breadcrumbs at the call sites), matching the existing delete-toast convention.
Fit-mode rendering needed a layout fix: vega-embed brands the embed host with its
own `.vega-embed { display: inline-block }` (injected at runtime, wins the
cascade), which shrink-wrapped the host so `width: "container"` collapsed (Height
survived only via the old `min-height: 100%`). Fix: embed into a static-class
inner host (React never reconciles its className, so Vega's runtime classes
survive) inside a React-owned frame that carries the fit-sizing class via
two-class selectors that out-specify `.vega-embed`. All four fit modes
user-verified in the running app.
--- ---
## M3 · Datasets ## M3 · Datasets
@@ -329,8 +355,12 @@ the reference.
**Goal:** the workspace feels finished and meets §10. **Goal:** the workspace feels finished and meets §10.
- **Panes:** drag-resize handles with min widths; per-pane show/hide toggle strip; - **Panes:** ~~drag-resize handles with min widths; widths persist~~ ✅ **pulled
widths + visibility persist (§01A, §09D). forward after M2** (coupled to the preview's container sizing — see
[arch 05 §8](architecture/05-rendering-theming-preview.md)). Side panes carry
remembered widths, the editor flexes between them, widths persist to
`astrolabe:ux-prefs`. **Remaining:** per-pane show/hide **toggle strip** +
visibility persist + proportional redistribution on hide (§01A, §09D).
- **Routing:** URL hash view-state (`#snippet-<id>`, `#datasets/...`) with Back/Forward; - **Routing:** URL hash view-state (`#snippet-<id>`, `#datasets/...`) with Back/Forward;
restore on load (§01E). _(see [Architecture 04 · Routing & Events](architecture/04-routing-and-events.md))_ restore on load (§01E). _(see [Architecture 04 · Routing & Events](architecture/04-routing-and-events.md))_
- **Shortcuts:** Cmd/Ctrl+Shift+N / +K / +S / +, / Esc via a single key router - **Shortcuts:** Cmd/Ctrl+Shift+N / +K / +S / +, / Esc via a single key router
@@ -379,9 +379,9 @@ _Live Preview_ spec. The only invariant this doc cares about:
> embeds that returned spec. **The user's stored spec is never mutated by > embeds that returned spec. **The user's stored spec is never mutated by
> rendering.** > rendering.**
The container-relative fit modes (Width/Height/Full) depend on `renderer: 'svg'` The container-relative fit modes (Width/Height/Full) depend on `"container"`
plus `"container"` sizing to follow the pane; when the pane resizes, re-running sizing to follow the pane. Re-fitting on a **pane resize** is _not_ a re-embed:
`prepareSpecForRender` + re-embedding (a `flush()`) re-fits the chart. the existing view is re-measured via a `ResizeObserver`-driven event — see §8.
### Rules ### Rules
@@ -463,14 +463,64 @@ manual retry, no reload.
--- ---
## 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 ## Summary
| Concern | Mechanism | Source of truth | | Concern | Mechanism | Source of truth |
| ------------- | ----------------------------------------------------------------------- | ----------------------------------------------- | | ------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------- |
| Embedding | One `renderSpec` over `vega-embed`, `actions: false`, `renderer: 'svg'` | `src/app/services/chart-renderer.ts` | | 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` | | 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` | | 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` | | Field names | `escapeVegaField` on every data-derived `field:` | `src/core/rendering.ts` |
| Debounce | `createDebouncedRenderer`, delay from `renderDebounce` setting | `src/app/services/debounced-renderer.ts` | | Debounce | `createDebouncedRenderer`, delay from `renderDebounce` setting | `src/app/services/debounced-renderer.ts` |
| Spec prep | `prepareSpecForRender` (pure, on a copy) | `src/core/rendering.ts` (see _Live Preview_) | | 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` | | 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`) |
+22 -18
View File
@@ -170,10 +170,14 @@ vega/editor's hand-rolled renderer (`src/components/renderer/renderer.tsx`) reve
(`config-editor/config-editor-header.tsx:5-37`). For Astrolabe: pass the chosen `theme`/`config` (`config-editor/config-editor-header.tsx:5-37`). For Astrolabe: pass the chosen `theme`/`config`
to `vegaEmbed`, and when our theme changes, re-embed with the new config. to `vegaEmbed`, and when our theme changes, re-embed with the new config.
- **`"width":"container"` / `"height":"container"` is how VL responsiveness works** — it - **`"width":"container"` / `"height":"container"` is how VL responsiveness works** — it
compiles to a `containerSize` signal (`renderer.tsx:78-90` detects this). Pair it with a compiles `width`/`height` to signals that re-read `containerSize()` **only on a
**`ResizeObserver`** on the preview pane → `view.resize().runAsync()`. This is cleaner than `window:resize` event** (`renderer.tsx:78-90` detects container sizing). Two things a
vega/editor's `window.dispatchEvent(new Event('resize'))` hack (`renderer.tsx:101-122`) and is from-scratch impl _will_ get wrong (we did): (1) `view.resize().runAsync()` does **not**
the mechanism behind our M2 fit-mode contract. re-measure — it re-runs layout with the stale size; (2) a pane drag fires no window resize, so
nothing re-fits on its own. The fix is exactly vega/editor's
`window.dispatchEvent(new Event('resize'))` (`renderer.tsx:101-122`) — **not a hack, the
actual mechanism** — driven by a `ResizeObserver` on the pane. It also leaves a non-container
dimension natural for free. Full write-up in doc [05](05-rendering-theming-preview.md) §8.
- **Reuse the view for cheap changes.** They rebuild the `View` only on spec change; renderer - **Reuse the view for cheap changes.** They rebuild the `View` only on spec change; renderer
(svg/canvas) and tooltip toggles re-`initialize()` the existing view (`renderer.tsx:367-371`). (svg/canvas) and tooltip toggles re-`initialize()` the existing view (`renderer.tsx:367-371`).
- **Capture warnings separately from errors** via a buffering logger (see §4's `LocalLogger`). - **Capture warnings separately from errors** via a buffering logger (see §4's `LocalLogger`).
@@ -273,20 +277,20 @@ defaults-spread" discipline is worth keeping.
## Borrow list (where each lands) ## Borrow list (where each lands)
| Technique | Lands in | Milestone | | Technique | Lands in | Milestone |
| ---------------------------------------------------------------------------------------- | -------------------------------------- | --------- | | ----------------------------------------------------------------------------------------- | -------------------------------------- | --------- |
| Bundle VL schema from package `build/`; `setDiagnosticsOptions` | `src/app/infrastructure/` Monaco setup | M2 | | Bundle VL schema from package `build/`; `setDiagnosticsOptions` | `src/app/infrastructure/` Monaco setup | M2 |
| `markdownDescription` patch + compact formatter | Monaco setup | M2 | | `markdownDescription` patch + compact formatter | Monaco setup | M2 |
| Explicit Vite worker wiring (`MonacoEnvironment.getWorker`) | Monaco setup | M2 | | Explicit Vite worker wiring (`MonacoEnvironment.getWorker`) | Monaco setup | M2 |
| `fileMatch` schema binding (improvement over `$schema`-only) | Monaco setup | M2 | | `fileMatch` schema binding (improvement over `$schema`-only) | Monaco setup | M2 |
| jsonc-parser tolerant parse + line/col syntax errors | `src/core/` | M1/M2 | | jsonc-parser tolerant parse + line/col syntax errors | `src/core/` | M1/M2 |
| ajv wrapper (`strict:false`, draft-06, color-hex, compile-once) → structured diagnostics | `src/core/` | M2 | | ajv wrapper (`strict:false`, draft-06, color-hex, compile-once) → structured diagnostics | `src/core/` | M2 |
| `LocalLogger`-style buffered diagnostics from pure compile | `src/core/` | M2 | | `LocalLogger`-style buffered diagnostics from pure compile | `src/core/` | M2 |
| Fatal-vs-advisory two-tier error model | rendering/store contract | M1/M2 | | Fatal-vs-advisory two-tier error model | rendering/store contract | M1/M2 |
| `"container"` sizing + `ResizeObserver` → `view.resize()` | `rendering.ts` + LivePreview | M2 | | `"container"` sizing + `ResizeObserver` → synthetic `window:resize` (not `view.resize()`) | `chart-renderer` + LivePreview | M2 |
| `finalize()`-before-reembed + **render-generation guard** | LivePreview | M1 | | `finalize()`-before-reembed + **render-generation guard** | LivePreview | M1 |
| theme = `vega-themes` config merged into `vegaEmbed` | preview + settings | M5 | | theme = `vega-themes` config merged into `vegaEmbed` | preview + settings | M5 |
| `json-stringify-pretty-compact` format action | editor | M2 | | `json-stringify-pretty-compact` format action | editor | M2 |
## Where we deliberately do better than the reference ## Where we deliberately do better than the reference
+103 -5
View File
@@ -1,16 +1,114 @@
.preview { .preview {
display: flex;
flex-direction: column;
height: 100%; height: 100%;
width: 100%; width: 100%;
overflow: auto;
background: var(--bg); background: var(--bg);
} }
.chart { .header {
flex: 0 0 auto;
display: flex; display: flex;
align-items: flex-start; align-items: center;
justify-content: center; justify-content: flex-end;
min-height: 100%; gap: var(--space-3);
height: 40px;
padding: 0 var(--space-4);
border-bottom: var(--border-width) solid var(--border);
}
.body {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: var(--space-5); padding: var(--space-5);
box-sizing: border-box;
}
/* Segmented control — the four fit modes (spec §04). */
.fit {
display: inline-flex;
border: var(--border-width) solid var(--border-strong);
}
.fitOption {
appearance: none;
border: none;
background: var(--bg);
color: var(--text-secondary);
font: inherit;
font-size: 12px;
line-height: 1;
padding: var(--space-2) var(--space-3);
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.fitOption + .fitOption {
border-left: var(--border-width) solid var(--border-strong);
}
.fitOption:hover {
background: var(--layer-01);
color: var(--text);
}
.fitActive,
.fitActive:hover {
background: var(--accent);
color: var(--accent-contrast);
}
/*
* Chart sizing. The host (passed to vega-embed) is branded `.vega-embed`
* (display:inline-block) at runtime; that shrink-wraps it, which is why
* width:"container" collapsed before. The frame carries the fit class and the
* two-class selectors below out-specify `.vega-embed` to give the host a
* definite box for the responsive modes. `box-sizing:border-box` keeps the
* chart inside the body padding rather than overflowing it.
*/
.frame {
box-sizing: border-box;
}
.host {
box-sizing: border-box;
}
/* Original — natural size; the body scrolls if the chart is larger than the pane. */
.fitOriginal {
display: inline-block;
}
/* Width — host spans the pane width; height stays natural. */
.fitWidth {
display: block;
width: 100%;
}
.fitWidth .host {
width: 100%;
}
/* Height — host spans the pane height; width stays natural. */
.fitHeight {
display: block;
height: 100%;
}
.fitHeight .host {
height: 100%;
}
/* Full — host fills the pane in both dimensions. */
.fitFull {
display: block;
width: 100%;
height: 100%;
}
.fitFull .host {
width: 100%;
height: 100%;
} }
.error { .error {
+99 -20
View File
@@ -1,40 +1,90 @@
/** /**
* Live Preview — the right pane (spec §04). * Live Preview — the right pane (spec §04).
* *
* Renders the active snippet's current buffer as a Vega-Lite chart, debounced so * Renders the active snippet's currently-shown spec (draft or published, per the
* typing stays smooth. The pipeline is: buffer text → JSON.parse → * editor view) as a Vega-Lite chart, debounced so typing stays smooth. The
* prepareSpecForRender (copy, pure) → renderSpec (vega-embed). A render- * pipeline is: shown text → JSON.parse → prepareSpecForRender (copy, pure, fit
* generation token guards against a slow render resolving after a newer one. * mode applied) → renderSpec (vega-embed). A render-generation token guards
* against a slow render resolving after a newer one.
* *
* M1 scope: inline-data specs, Original sizing, basic error text. Fit modes (M2) * The pane header carries the Fit control (4 sizing modes, §04). Render errors
* and dataset reference resolution (M3) plug into prepareSpecForRender without * are published to the shared PreviewStore so the editor pane mirrors them
* changing this component. * (§03E); the preview shows the same message in place of the chart.
*
* M2 scope: inline-data specs, all four fit modes. Dataset reference resolution
* (M3) plugs into prepareSpecForRender without changing this component.
*/ */
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef } from 'react';
import type { VisualizationSpec } from 'vega-embed'; import type { VisualizationSpec } from 'vega-embed';
import type { FitMode } from '@core/rendering';
import { prepareSpecForRender } from '@core/rendering'; import { prepareSpecForRender } from '@core/rendering';
import { chartConfigFor } from '@core/vega-themes'; import { chartConfigFor } from '@core/vega-themes';
import { renderSpec, type RenderHandle } from '../services/chart-renderer'; import { renderSpec, type RenderHandle } from '../services/chart-renderer';
import { useAppStore } from '../stores/AppStore'; import { useAppStore } from '../stores/AppStore';
import { useSnippetStore } from '../stores/SnippetStore'; import { usePreviewStore } from '../stores/PreviewStore';
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
import styles from './LivePreview.module.css'; import styles from './LivePreview.module.css';
/** Render debounce (ms). Becomes the configurable `renderDebounce` setting in M5. */ /** Render debounce (ms). Becomes the configurable `renderDebounce` setting in M5. */
const RENDER_DEBOUNCE_MS = 300; const RENDER_DEBOUNCE_MS = 300;
/** The four fit modes in display order (spec §04 → Fit / Sizing Modes). */
const FIT_MODES: ReadonlyArray<{ mode: FitMode; label: string }> = [
{ mode: 'default', label: 'Original' },
{ mode: 'width', label: 'Width' },
{ mode: 'height', label: 'Height' },
{ mode: 'full', label: 'Full' },
];
/**
* Sizing class for the chart frame per fit mode. The frame is React-owned, so
* these classes drive how the host element (which vega-embed brands with its own
* `display:inline-block`) is sized: the responsive modes give the host a
* definite width/height for Vega's `"container"` measurement (`containerSize()`
* reads `host.clientWidth/Height`), while Original lets it shrink to natural size.
*/
const FIT_CLASS: Record<FitMode, string> = {
default: styles.fitOriginal,
width: styles.fitWidth,
height: styles.fitHeight,
full: styles.fitFull,
};
function FitControl() {
const fitMode = useAppStore((s) => s.previewFitMode);
const setFitMode = useAppStore((s) => s.setPreviewFitMode);
return (
<div className={styles.fit} role="group" aria-label="Fit chart to pane">
{FIT_MODES.map(({ mode, label }) => (
<button
key={mode}
type="button"
className={`${styles.fitOption} ${mode === fitMode ? styles.fitActive : ''}`}
aria-pressed={mode === fitMode}
onClick={() => setFitMode(mode)}
>
{label}
</button>
))}
</div>
);
}
export function LivePreview() { export function LivePreview() {
const hostRef = useRef<HTMLDivElement>(null); const hostRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<RenderHandle | null>(null); const handleRef = useRef<RenderHandle | null>(null);
const generationRef = useRef(0); const generationRef = useRef(0);
const draftText = useSnippetStore((s) => s.draftText); const shownText = useSnippetStore(selectShownText);
const fitMode = useAppStore((s) => s.previewFitMode);
const uiTheme = useAppStore((s) => s.uiTheme); const uiTheme = useAppStore((s) => s.uiTheme);
const [error, setError] = useState<string | null>(null); const error = usePreviewStore((s) => s.error);
const setError = usePreviewStore((s) => s.setError);
useEffect(() => { useEffect(() => {
const node = hostRef.current; const node = hostRef.current;
if (!node) return; if (!node) return;
const text = draftText.trim(); const text = shownText.trim();
// The debounced body is async; wrap in a void IIFE so the timer callback // The debounced body is async; wrap in a void IIFE so the timer callback
// returns void (it handles its own errors internally — nothing awaits it). // returns void (it handles its own errors internally — nothing awaits it).
@@ -58,7 +108,7 @@ export function LivePreview() {
const mine = ++generationRef.current; const mine = ++generationRef.current;
try { try {
const prepared = prepareSpecForRender(parsed, { fitMode: 'default' }); const prepared = prepareSpecForRender(parsed, { fitMode });
const config = chartConfigFor(uiTheme); const config = chartConfigFor(uiTheme);
handleRef.current?.destroy(); handleRef.current?.destroy();
handleRef.current = null; handleRef.current = null;
@@ -67,9 +117,9 @@ export function LivePreview() {
// TODO: a superseded render's destroy() calls node.replaceChildren(), // TODO: a superseded render's destroy() calls node.replaceChildren(),
// which can blank the live chart if two embeds on the same node are // which can blank the live chart if two embeds on the same node are
// ever in flight at once (heavy spec whose embed outlasts the 300ms // ever in flight at once (heavy spec whose embed outlasts the 300ms
// debounce). The debounce makes this rare in M1; when fit-mode/dataset // debounce). The debounce makes this rare; when dataset work (M3)
// work (M2/M3) lands, serialize renders or finalize the stale view // lands, serialize renders or finalize the stale view without
// without clearing the shared node. // clearing the shared node.
handle.destroy(); // a newer render superseded this one handle.destroy(); // a newer render superseded this one
return; return;
} }
@@ -87,21 +137,50 @@ export function LivePreview() {
}, RENDER_DEBOUNCE_MS); }, RENDER_DEBOUNCE_MS);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [draftText, uiTheme]); }, [shownText, fitMode, uiTheme, setError]);
// Finalize the live view on unmount. // Re-fit the chart when its container resizes (e.g. a pane drag). Vega doesn't
// observe the element, so we do: one observer on the stable host node for the
// component's life. Only responsive fit modes depend on container size;
// Original is fixed natural size and the pane just scrolls. ResizeObserver
// callbacks are frame-batched, so this tracks the drag without thrashing.
useEffect(() => {
const node = hostRef.current;
if (!node || typeof ResizeObserver === 'undefined') return;
const ro = new ResizeObserver(() => {
if (useAppStore.getState().previewFitMode === 'default') return;
handleRef.current?.resize();
});
ro.observe(node);
return () => ro.disconnect();
}, []);
// Finalize the live view on unmount, and clear the shared error so a stale
// message never outlives this pane.
useEffect( useEffect(
() => () => { () => () => {
handleRef.current?.destroy(); handleRef.current?.destroy();
handleRef.current = null; handleRef.current = null;
usePreviewStore.getState().setError(null);
}, },
[], [],
); );
return ( return (
<div className={styles.preview}> <div className={styles.preview}>
<div className={styles.chart} ref={hostRef} hidden={error !== null} /> <div className={styles.header}>
{error !== null && <pre className={styles.error}>{error}</pre>} <FitControl />
</div>
<div className={styles.body}>
{/* Frame is React-owned and carries the fit-sizing class; the inner host
is owned by vega-embed (it brands it `.vega-embed` and mutates its
classList at runtime), so its className stays static and React never
clobbers Vega's own classes. */}
<div className={`${styles.frame} ${FIT_CLASS[fitMode]}`} hidden={error !== null}>
<div className={styles.host} ref={hostRef} />
</div>
{error !== null && <pre className={styles.error}>{error}</pre>}
</div>
</div> </div>
); );
} }
+27 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { loadUiTheme, saveUiTheme } from './settings-store'; import { loadPreviewFitMode, loadUiTheme, savePreviewFitMode, saveUiTheme } from './settings-store';
const KEY = 'astrolabe:settings'; const KEY = 'astrolabe:settings';
@@ -84,3 +84,29 @@ describe('settings-store · ui.theme', () => {
}); });
}); });
}); });
describe('settings-store · preview.fitMode', () => {
beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub()));
afterEach(() => vi.unstubAllGlobals());
it('defaults to Original (default) when nothing is stored', () => {
expect(loadPreviewFitMode()).toBe('default');
});
it('returns a stored valid fit mode', () => {
localStorage.setItem(KEY, JSON.stringify({ preview: { fitMode: 'full' } }));
expect(loadPreviewFitMode()).toBe('full');
});
it('falls back to default for an unrecognized value', () => {
localStorage.setItem(KEY, JSON.stringify({ preview: { fitMode: 'cover' } }));
expect(loadPreviewFitMode()).toBe('default');
});
it('round-trips through load and preserves the theme slice', () => {
saveUiTheme('dark');
savePreviewFitMode('height');
expect(loadPreviewFitMode()).toBe('height');
expect(loadUiTheme()).toBe('dark'); // the other slice survives the merge
});
});
+21
View File
@@ -13,6 +13,7 @@
* `localStorage`; everything else goes through these typed functions. * `localStorage`; everything else goes through these typed functions.
*/ */
import type { FitMode } from '@core/rendering';
import type { UiTheme } from '@core/theme'; import type { UiTheme } from '@core/theme';
const KEY = 'astrolabe:settings'; const KEY = 'astrolabe:settings';
@@ -20,9 +21,13 @@ const KEY = 'astrolabe:settings';
/** Spec §07 Appearance default. */ /** Spec §07 Appearance default. */
const DEFAULT_THEME: UiTheme = 'light'; const DEFAULT_THEME: UiTheme = 'light';
/** Spec §04 — the Fit control defaults to Original. */
const DEFAULT_FIT_MODE: FitMode = 'default';
/** Loose view of the stored record — M5 will give this its full typed shape. */ /** Loose view of the stored record — M5 will give this its full typed shape. */
interface StoredSettings { interface StoredSettings {
ui?: { theme?: unknown; [k: string]: unknown }; ui?: { theme?: unknown; [k: string]: unknown };
preview?: { fitMode?: unknown; [k: string]: unknown };
[k: string]: unknown; [k: string]: unknown;
} }
@@ -61,6 +66,10 @@ function isUiTheme(v: unknown): v is UiTheme {
return v === 'light' || v === 'dark'; return v === 'light' || v === 'dark';
} }
function isFitMode(v: unknown): v is FitMode {
return v === 'default' || v === 'width' || v === 'height' || v === 'full';
}
/** The persisted UI theme, or the default — unknown/legacy values fall back. */ /** The persisted UI theme, or the default — unknown/legacy values fall back. */
export function loadUiTheme(): UiTheme { export function loadUiTheme(): UiTheme {
const stored = readRaw().ui?.theme; const stored = readRaw().ui?.theme;
@@ -74,3 +83,15 @@ export function saveUiTheme(theme: UiTheme): void {
const current = readRaw(); const current = readRaw();
writeRaw({ ...current, ui: { ...current.ui, theme } }); writeRaw({ ...current, ui: { ...current.ui, theme } });
} }
/** The persisted preview fit mode, or the default — unknown values fall back. */
export function loadPreviewFitMode(): FitMode {
const stored = readRaw().preview?.fitMode;
return isFitMode(stored) ? stored : DEFAULT_FIT_MODE;
}
/** Persist the preview fit mode, preserving every other key already in the record. */
export function savePreviewFitMode(fitMode: FitMode): void {
const current = readRaw();
writeRaw({ ...current, preview: { ...current.preview, fitMode } });
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Preference orchestration — bridges the (browser-free) AppStore to the settings
* adapter for the small UI preferences pulled forward ahead of the M5 Settings
* modal. Same store↔adapter pattern as theme orchestration; currently the only
* such preference is the Live Preview fit mode (spec §04, `previewFitMode`).
*
* Unlike theme there is no FOUC concern (the preview renders after hydration
* anyway), but hydrating early keeps the store the single source of truth from
* the first render.
*/
import { loadPreviewFitMode, savePreviewFitMode } from '../infrastructure/settings-store';
import { useAppStore } from '../stores/AppStore';
/** Hydrate the persisted fit mode into the store. Call before render. */
export function initPreviewFitMode(): void {
useAppStore.getState().setPreviewFitMode(loadPreviewFitMode());
}
/** Persist the fit mode on change. Returns a teardown that detaches the subscriber. */
export function wirePreviewFitMode(): () => void {
return useAppStore.subscribe((state, prev) => {
if (state.previewFitMode === prev.previewFitMode) return;
savePreviewFitMode(state.previewFitMode);
});
}
+18
View File
@@ -14,6 +14,17 @@ import type { Config } from 'vega-lite';
export interface RenderHandle { export interface RenderHandle {
/** Finalize the underlying Vega view and clear the node. */ /** Finalize the underlying Vega view and clear the node. */
destroy(): void; destroy(): void;
/**
* Re-fit the chart to its container's current size (spec §04 Responsiveness).
*
* Vega-Lite compiles `"container"` sizing to width/height signals that re-read
* `containerSize()` ONLY on a `window:resize` event (nothing observes the
* element, and `view.resize()` alone re-runs layout with the stale size). So a
* pane drag — which fires no window resize — needs us to synthesize that event.
* Doing it this way also means only the container-bound dimensions re-fit
* (fixed ones have no such handler), which is exactly right for Width/Height.
*/
resize(): void;
} }
/** Embed a prepared spec into `node`. Non-negotiable: no actions menu, SVG output. */ /** Embed a prepared spec into `node`. Non-negotiable: no actions menu, SVG output. */
@@ -33,5 +44,12 @@ export async function renderSpec(
result.view.finalize(); result.view.finalize();
node.replaceChildren(); node.replaceChildren();
}, },
resize() {
// Synthesize the window:resize the container signals listen for (see the
// interface doc). The view re-reads containerSize() and re-renders itself;
// a finalized view has already removed its listener, so this is a safe
// no-op after destroy().
if (typeof window !== 'undefined') window.dispatchEvent(new Event('resize'));
},
}; };
} }
+7
View File
@@ -1,4 +1,5 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { FitMode } from '@core/rendering';
import type { UiTheme } from '@core/theme'; import type { UiTheme } from '@core/theme';
/** /**
@@ -19,12 +20,16 @@ export type ModalName = 'datasets' | 'settings' | 'about' | 'donate' | 'chartBui
export interface AppState { export interface AppState {
/** Active UI theme; mirrored onto <html data-theme> by a subscriber. */ /** Active UI theme; mirrored onto <html data-theme> by a subscriber. */
uiTheme: UiTheme; uiTheme: UiTheme;
/** Preview sizing mode (spec §04); persisted to Settings as `previewFitMode`. */
previewFitMode: FitMode;
/** The currently open modal, or null. */ /** The currently open modal, or null. */
activeModal: ModalName | null; activeModal: ModalName | null;
setTheme: (theme: UiTheme) => void; setTheme: (theme: UiTheme) => void;
/** Flip between light and dark — the header ThemeToggle's action. */ /** Flip between light and dark — the header ThemeToggle's action. */
toggleTheme: () => void; toggleTheme: () => void;
/** Set the preview fit mode — the Live Preview Fit control's action. */
setPreviewFitMode: (mode: FitMode) => void;
/** /**
* Low-level modal setter — the single primitive that mutates `activeModal`. * Low-level modal setter — the single primitive that mutates `activeModal`.
* High-level open/close (snapshot for unsaved-change detection, URL sync, * High-level open/close (snapshot for unsaved-change detection, URL sync,
@@ -36,9 +41,11 @@ export interface AppState {
export const useAppStore = create<AppState>((set) => ({ export const useAppStore = create<AppState>((set) => ({
uiTheme: 'light', uiTheme: 'light',
previewFitMode: 'default',
activeModal: null, activeModal: null,
setTheme: (uiTheme) => set({ uiTheme }), setTheme: (uiTheme) => set({ uiTheme }),
toggleTheme: () => set((s) => ({ uiTheme: s.uiTheme === 'dark' ? 'light' : 'dark' })), toggleTheme: () => set((s) => ({ uiTheme: s.uiTheme === 'dark' ? 'light' : 'dark' })),
setPreviewFitMode: (previewFitMode) => set({ previewFitMode }),
setActiveModal: (activeModal) => set({ activeModal }), setActiveModal: (activeModal) => set({ activeModal }),
})); }));
+70 -5
View File
@@ -14,19 +14,84 @@ describe('prepareSpecForRender', () => {
const spec = { const spec = {
data: { values: [{ a: 1 }] }, data: { values: [{ a: 1 }] },
mark: 'bar', mark: 'bar',
width: 200,
height: 100,
encoding: { x: { field: 'a', type: 'quantitative' } }, encoding: { x: { field: 'a', type: 'quantitative' } },
}; };
const before = structuredClone(spec); const before = structuredClone(spec);
const out = prepareSpecForRender(spec, { fitMode: 'width' }); // A mode that both sets and removes sizing — the most invasive transform.
prepareSpecForRender(spec, { fitMode: 'width' });
// Mutating the output must not touch the input.
(out as { mark: string }).mark = 'point';
expect(spec).toEqual(before); expect(spec).toEqual(before);
}); });
test('M1 is a faithful pass-through of the spec content', () => { test('Original (default) leaves sizing untouched', () => {
const spec = { $schema: 'x', mark: 'line', width: 200, height: 100 }; const spec = { $schema: 'x', mark: 'line', width: 200, height: 100 };
expect(prepareSpecForRender(spec)).toEqual(spec); expect(prepareSpecForRender(spec)).toEqual(spec);
expect(prepareSpecForRender(spec, { fitMode: 'default' })).toEqual(spec);
});
});
describe('prepareSpecForRender — fit modes (spec §04 Rendering Contract step 2)', () => {
const base = { mark: 'bar', width: 200, height: 100 };
test('Width: width→container, height removed', () => {
const out = prepareSpecForRender(base, { fitMode: 'width' }) as Record<string, unknown>;
expect(out.width).toBe('container');
expect('height' in out).toBe(false);
});
test('Height: height→container, width removed', () => {
const out = prepareSpecForRender(base, { fitMode: 'height' }) as Record<string, unknown>;
expect(out.height).toBe('container');
expect('width' in out).toBe(false);
});
test('Full: both dimensions→container', () => {
const out = prepareSpecForRender(base, { fitMode: 'full' }) as Record<string, unknown>;
expect(out.width).toBe('container');
expect(out.height).toBe('container');
});
test('adds container sizing even when the spec declares no width/height', () => {
const out = prepareSpecForRender({ mark: 'point' }, { fitMode: 'full' }) as Record<
string,
unknown
>;
expect(out).toEqual({ mark: 'point', width: 'container', height: 'container' });
});
test('recurses into layered sub-specs', () => {
const spec = {
layer: [
{ mark: 'bar', width: 50, height: 50 },
{ mark: 'line', height: 50 },
],
};
const out = prepareSpecForRender(spec, { fitMode: 'full' }) as unknown as {
width: string;
height: string;
layer: Array<Record<string, unknown>>;
};
expect(out.width).toBe('container');
expect(out.height).toBe('container');
expect(out.layer[0]).toMatchObject({ width: 'container', height: 'container' });
expect(out.layer[1]).toMatchObject({ width: 'container', height: 'container' });
});
test('recurses into concat arrays and a child spec (facet/repeat)', () => {
const spec = {
facet: { field: 'g', type: 'nominal' },
spec: {
hconcat: [{ mark: 'bar', height: 80 }, { mark: 'point' }],
},
};
const out = prepareSpecForRender(spec, { fitMode: 'width' }) as unknown as {
spec: { hconcat: Array<Record<string, unknown>> };
};
for (const child of out.spec.hconcat) {
expect(child.width).toBe('container');
expect('height' in child).toBe(false);
}
}); });
}); });
+57 -8
View File
@@ -8,21 +8,69 @@
* never mutated by rendering: * never mutated by rendering:
* *
* 1. Dataset reference resolution — arrives in M3 (no-op here). * 1. Dataset reference resolution — arrives in M3 (no-op here).
* 2. Fit-mode sizing — arrives in M2 (no-op here). * 2. Fit-mode sizing — implemented in M2.
* *
* In M1 it is an identity transform over a copy: it establishes the * The copy-not-mutate invariant and the call site the renderer depends on are
* copy-not-mutate invariant and the call site the renderer depends on, so M2/M3 * fixed; M3 fills in step 1 without the preview pipeline changing shape.
* can fill in the steps without the preview pipeline changing shape.
*/ */
/** Preview sizing modes (spec §04 → Fit / Sizing Modes). `default` = Original. */ /** Preview sizing modes (spec §04 → Fit / Sizing Modes). `default` = Original. */
export type FitMode = 'default' | 'width' | 'height' | 'full'; export type FitMode = 'default' | 'width' | 'height' | 'full';
export interface PrepareOptions { export interface PrepareOptions {
/** Active fit mode. Applied in M2; ignored in M1. */ /** Active fit mode. Defaults to `'default'` (Original — spec sizing untouched). */
fitMode?: FitMode; fitMode?: FitMode;
} }
/** The container/sub-spec keys the rendering contract recurses into (spec §04). */
const CHILD_ARRAY_KEYS = ['layer', 'concat', 'hconcat', 'vconcat'] as const;
/** A spec node we might rewrite sizing on; loose by design (any Vega-Lite spec). */
type SpecNode = Record<string, unknown>;
function isSpecNode(value: unknown): value is SpecNode {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
/**
* Rewrite one node's sizing to the fit mode (spec §04 → Rendering Contract,
* step 2). `'container'` is Vega-Lite's responsive keyword; the unconstrained
* dimension is removed so it recomputes naturally.
*/
function applyFitToNode(node: SpecNode, mode: FitMode): void {
switch (mode) {
case 'width':
node.width = 'container';
delete node.height;
break;
case 'height':
node.height = 'container';
delete node.width;
break;
case 'full':
node.width = 'container';
node.height = 'container';
break;
// 'default' (Original) leaves sizing untouched and never reaches here.
}
}
/**
* Apply the fit mode to a spec and every nested sub-spec it recurses into —
* layered (`layer`) and concatenated (`concat`/`hconcat`/`vconcat`) children,
* and a parent spec's single child `spec` (facet/repeat). Mutates in place; the
* caller (`prepareSpecForRender`) already works on a copy.
*/
function applyFitMode(node: unknown, mode: FitMode): void {
if (!isSpecNode(node)) return;
applyFitToNode(node, mode);
for (const key of CHILD_ARRAY_KEYS) {
const children = node[key];
if (Array.isArray(children)) for (const child of children) applyFitMode(child, mode);
}
if (isSpecNode(node.spec)) applyFitMode(node.spec, mode);
}
/** /**
* Escape `.`/`[`/`]` so Vega-Lite treats a string as a literal field name rather * Escape `.`/`[`/`]` so Vega-Lite treats a string as a literal field name rather
* than a nested-property accessor (docs/architecture/05 §4). Used wherever * than a nested-property accessor (docs/architecture/05 §4). Used wherever
@@ -37,11 +85,12 @@ export function escapeVegaField(name: string): string {
* Transform the authored spec into the spec to embed. Operates on a deep copy * Transform the authored spec into the spec to embed. Operates on a deep copy
* and returns it; the input is never mutated. * and returns it; the input is never mutated.
*/ */
export function prepareSpecForRender<T>(spec: T, _options: PrepareOptions = {}): T { export function prepareSpecForRender<T>(spec: T, options: PrepareOptions = {}): T {
const copy = structuredClone(spec); const copy = structuredClone(spec);
// M3: resolveDatasetRefs(copy, datasets) // 1. M3: resolveDatasetRefs(copy, datasets)
// M2: applyFitMode(copy, options.fitMode) // 2. Fit-mode sizing.
applyFitMode(copy, options.fitMode ?? 'default');
return copy; return copy;
} }
+6
View File
@@ -1,5 +1,6 @@
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { App } from './app/App'; import { App } from './app/App';
import { initPreviewFitMode, wirePreviewFitMode } from './app/orchestration/preferences';
import { initApp } from './app/orchestration/startup'; import { initApp } from './app/orchestration/startup';
import { initTheme, wireTheme } from './app/orchestration/theme'; import { initTheme, wireTheme } from './app/orchestration/theme';
import '../styles/base.css'; import '../styles/base.css';
@@ -10,6 +11,11 @@ import '../styles/base.css';
initTheme(); initTheme();
wireTheme(); wireTheme();
// Hydrate + persist the preview fit mode the same way (pulled ahead of the M5
// Settings modal); hydrating before render keeps the store authoritative.
initPreviewFitMode();
wirePreviewFitMode();
// Load the library from IndexedDB (seeding a sample on first run) and wire // Load the library from IndexedDB (seeding a sample on first run) and wire
// persistence. Fire-and-forget: the UI renders immediately and fills in when // persistence. Fire-and-forget: the UI renders immediately and fills in when
// hydration resolves. // hydration resolves.