M1 MVP from docs/IMPLEMENTATION-PLAN.md, with the /alignment pass applied. - core: Snippet model + factory; prepareSpecForRender (copy-not-mutate); per-theme Vega chart config - state/orchestration: SnippetStore with debounced auto-save; IndexedDB adapter + read-time migration; startup hydration + write-through persistence - ui: SnippetLibrary, SpecEditor (Monaco edcore.main — full editor features, JSON-only languages), LivePreview - build: Monaco/Vega manual chunks; raised PWA precache ceiling - alignment: flush a valid draft on snippet switch (+regression tests); TODO breadcrumbs for the preview render race and the window.confirm delete - housekeeping: gitignore .claude/projects/
19 KiB
08 · Borrowed Techniques from vega/editor
The official Vega-Lite editor (vega/editor) solves the exact "edit a Vega-Lite spec as JSON, validate it, render it live" problem Astrolabe sits on top of — minus the snippet/dataset library. This doc distills the techniques worth borrowing and the gotchas worth avoiding, so we don't rediscover them from scratch in M1/M2.
It is a reference, not a contract. The behavioral contract is still
docs/spec/; the patterns are still docs 01–07. This doc is the bridge: "here is how the canonical implementation does the editor/renderer plumbing, and here is what we keep vs. improve."
Source of these findings
A read-only clone of vega/editor lives at /Users/oleh/code/reference/vega-editor (shallow
clone of main, HEAD 4fdbb59). Re-clone with
git clone --depth 1 https://github.com/vega/editor. Citations below are file:line into
that tree.
Stack delta (read this first — it changes how directly we can borrow)
| vega/editor | Astrolabe | |
|---|---|---|
| UI framework | React | React (moved off Preact before build start) |
| State | Redux-ish single State in React context (useState) |
Zustand stores — not Redux |
| Monaco | @monaco-editor/react + @monaco-editor/loader (CDN-loaded Monaco, workers auto-wired) |
raw monaco-editor via Vite (we must wire workers ourselves) |
| Rendering | hand-rolled vegaLite.compile → vega.parse → new vega.View().runAsync() |
vegaEmbed() (wraps that same pipeline) |
| Schema validation | Monaco JSON worker + standalone ajv ^8 (two independent layers) |
same two-layer model planned |
Because both apps are now React, vega/editor's component lifecycle patterns port more or less directly — the friction is only in (a) state (their Redux-flat-state → our Zustand stores) and (b) Monaco worker wiring (their CDN loader → our explicit Vite workers).
Decision · Monaco integration (self-hosted, raw API)
Decided. Astrolabe uses raw
monaco-editorfrom npm, bundled and self-hosted, with workers wired explicitly via Vite?worker— not vega/editor's@monaco-editor/react+@monaco-editor/loader(CDN) setup. Two independent axes:
Axis A — sourcing: self-hosted/bundled, not CDN. (Forced by Astrolabe's values.)
vega/editor's @monaco-editor/loader fetches Monaco's AMD bundle from a CDN at runtime. For
us that breaks three things at once: (1) offline — the CDN bundle is outside Vite's module
graph, so vite-plugin-pwa/Workbox never precaches it and offline silently fails; bundled npm
assets are hashed files in dist/ that Workbox precaches automatically; (2) privacy — a
third-party fetch on load contradicts SOUL's "the only outbound requests are user-created
URL-dataset fetches"; (3) determinism — npm + package-lock is integrity-pinned and
reproducible, a runtime CDN resolve is not. This axis is not a close call; vega/editor's CDN
choice is right for an online hosted tool and wrong for an offline, installable, private app.
Axis B — React integration: raw API, not @monaco-editor/react. (A lean, not forced.)
The wrapper helps with the easy 80% (mount a JSON editor, lifecycle) and adds nothing to the
load-bearing 20% this app needs:
- Workers are still ours — the wrapper never manages
MonacoEnvironment(see §1 gotcha). - The M2 schema service (
jsonDefaults.setDiagnosticsOptions,fileMatch) is namespace-level; you reach through the wrapper viaonMount, so it saves nothing there. - Its headline
value/onChangecontrolled-input model is a hazard: driving Monaco's content from React state causes cursor jumps and undo-stack churn, against §10's "typing stays fluid" — you end up using it uncontrolled, i.e. the raw pattern anyway. - Its CDN-by-default is a standing footgun (works in dev online, fails offline in prod
unless you remember
loader.config({ monaco })).
Against that, raw costs one testable useMonacoEditor hook (~50–80 lines: create in
useEffect, dispose on unmount, push value, subscribe to onDidChangeModelContent, resize).
That's the same imperative-teardown discipline already adopted for vega-embed in doc 05
(view.finalize()), and consistent with already using raw vegaEmbed() over a React chart
wrapper — "thin integration layers we own" (SOUL). Lock-in is low either way, so the final
raw-vs-wrapper call is confirmable at the Monaco spike; what is not up for revisiting is
self-hosting.
Accepted cost: the explicit worker wiring (§1) is inherent to self-hosting — it is the price of offline, paid in any non-CDN setup, and the wrapper would not remove it.
Entry point: edcore.main, never editor.api (trim languages, not features)
Self-hosting raw Monaco forces a choice of ESM entry point, and the granularity matters:
| Import | What you get | Use? |
|---|---|---|
monaco-editor (barrel) |
All features + every basic language (sql, abap, solidity, …) | ❌ language bloat (~20 dead chunks) |
esm/vs/editor/editor.api |
The API surface only — zero feature contributions | ❌ a text box: no folding, suggest widget, Cmd+Backspace, find, bracket colorization |
esm/vs/editor/edcore.main |
editor.all (all 59 feature contributions) + API, no languages |
✅ full editor UX, JSON-only weight |
Import edcore.main and add only the JSON language service
(esm/vs/language/json/monaco.contribution). edcore.main ships no .d.ts of its own —
add an ambient declare module … { export * from '…/editor.api'; } so types (and Monaco's
global MonacoEnvironment augmentation) resolve. Two editor options worth setting because
they bite Vega-Lite specs specifically: showFoldingControls: 'always' (fold arrows always
visible), and quickSuggestions: { strings: true } (VL enum values like "bar" live inside
JSON strings, where Monaco disables auto-suggest by default).
Reaching for
editor.apito "drop unused languages" silently strips every editor feature — the languages live elsewhere. This is the concrete case behind AGENTS.md's "trim content, not capability" rule: cut the unwanted content, keep the behavior, and verify the behavior survived by exercising the editor, not by a green build.
The single biggest surprise: vega/editor does not use
vega-embedfor its live preview. It builds the compile→parse→View pipeline by hand;vega-embedis imported only for types and the exported standalone-HTML snippet. This is good news —vega-embedis exactly the wrapper they wrote by hand, so we get it for free. But their hand-rolled version (src/components/renderer/renderer.tsx) is the best available documentation of the lifecycle/cleanup disciplinevega-embedstill expects from us.
1 · Monaco + Vega-Lite schema wiring (M2 — highest from-scratch risk)
All of vega/editor's Monaco setup is one file: src/utils/monaco.ts.
What to borrow:
- Bundle the schema; never fetch it. They
import vegaLiteSchema from 'vega-lite/vega-lite-schema.json', resolved by a Vite alias to the package'sbuild/output (monaco.ts:7-8,vite.config.ts). The schema version is pinned to the installedvega-lite— offline-safe, version-locked, no runtime network call. Astrolabe should do the same. - Attach via the JSON language service, once, globally:
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ schemas, validate:true, ... })(monaco.ts:51-57). markdownDescriptionpatch (monaco.ts:12-13,utils/markdownProps.ts): recursively copy every schemadescription→markdownDescriptionbefore registering. Monaco renders rich hover docs only frommarkdownDescription; without this, hovers are plain text. Do it once at setup.- Replace the built-in JSON formatter with
json-stringify-pretty-compactviaregisterDocumentFormattingEditProvider('json', …)(monaco.ts:60-61,71-80) for Vega's compact array-on-one-line style. - Editor options worth copying (
spec-editor/renderer.tsx:263-274):folding:true,minimap.enabled:false,scrollBeyondLastLine:false,wordWrap:'on',quickSuggestions:true(this is what makes schema completions appear without an explicit trigger),stickyScroll.enabled:false.
Gotchas / where we improve:
- ⚠️ Workers are on us. vega/editor never configures Monaco workers — the CDN loader does.
With raw
monaco-editor+ Vite we must setself.MonacoEnvironment.getWorkerto return thejson.workerfor label'json'andeditor.workerotherwise (via?workerimports):Theimport EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'; self.MonacoEnvironment = { getWorker: (_id, label) => (label === 'json' ? new JsonWorker() : new EditorWorker()), };json.workerruns schema validation + autocomplete. No worker ⇒ no squiggles, no completions. Upside: dropping the CDN loader makesmonacosynchronously importable — noawait loader.init()dance, just callsetDiagnosticsOptions(...)at module load. - ⚠️
$schema-based binding vsfileMatch. They register schemas under versioneduris (.../vega-lite/v6.json) and bind by matching the doc's$schemavalue — nofileMatch(monaco.ts:15-46). Consequence: a spec with no$schemagets zero validation/autocomplete. Astrolabe should preferfileMatchagainst our model URIs so validation works regardless of whether the user wrote a$schemaline. - ⚠️ Set
enableSchemaRequest:falsefor our offline-first app. They set ittrue(monaco.ts:54), which lets the worker network-fetch any unbundled$schemaURL — failing network calls for an offline app. Register all schema versions locally instead. - The schema is multi-MB; register it once globally, never per-model.
2 · Live preview with vega-embed (M1 lifecycle, M2 fit-mode)
This is doc 05's territory; these are the concrete details
vega/editor's hand-rolled renderer (src/components/renderer/renderer.tsx) reveals.
What to borrow:
- Theme = a
vega-themesconfig object merged into the spec config. There is no automatic light/dark sync in vega/editor — theme is an explicit choice baked in at compile (config-editor/config-editor-header.tsx:5-37). For Astrolabe: pass the chosentheme/configtovegaEmbed, and when our theme changes, re-embed with the new config. "width":"container"/"height":"container"is how VL responsiveness works — it compiles to acontainerSizesignal (renderer.tsx:78-90detects this). Pair it with aResizeObserveron the preview pane →view.resize().runAsync(). This is cleaner than vega/editor'swindow.dispatchEvent(new Event('resize'))hack (renderer.tsx:101-122) and is the mechanism behind our M2 fit-mode contract.- Reuse the view for cheap changes. They rebuild the
Viewonly on spec change; renderer (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).
Gotchas / where we improve:
- ⚠️ Finalize before re-embed, or leak. Every spec change must
view.finalize()the old view and clear the container before mounting the new one (renderer.tsx:218-226).vegaEmbedreturns{ view, finalize }— callfinalize()before the next embed and on unmount. This is already a Do-rule in doc 05; vega/editor confirms how easy it is to leak otherwise. - ⚠️ Race on rapid edits.
runAsyncis async; a stale render can resolve after a newer one mounts. vega/editor mitigates only with debounce. We should add a render-generation token and ignore stale resolves (an improvement over the reference). - ⚠️ Wrap
runAsyncin try/catch and finalize on failure — Vega won't catch runtime errors for you, and a half-initialized view leaks if you don't finalize (renderer.tsx:247-259). - The CSP-safe expression interpreter (
vega-interpreter+vega.parse(..., {ast:true})) matters only under a strict no-evalCSP. A local offline app doesn't need it — keep it opt-in.
3 · Two-tier validation & error surfacing (M2, spec §03E)
vega/editor runs two independent schema-validation systems with no reconciliation, and sorts errors into two tiers. Both are worth copying.
The two layers:
- Monaco JSON worker → inline squiggles, hovers, autocomplete in the editor.
ajv ^8(src/utils/validate.ts) → runs at parse time, feeds the error/log pane. It does not create editor markers.
The two error tiers (keep them separate):
- Fatal / blocking — thrown exceptions: JSON syntax error, VL compile error, Vega runtime
error. These set a single
errorand suppress the chart. - Advisory — ajv schema-validation findings and
$schemaversion mismatch. These are a warnings list and do not block rendering. (Vega-Lite emits many benign warnings; treating ajv output as fatal would wrongly hide specs that render fine.)
The orchestration is app.tsx:188-291: parseJSONCOrThrow → $schema semver check (warn) →
validateVegaLite (ajv, warn) → vegaLite.compile (throw=fatal) → render (renderer.tsx,
throw=fatal).
ajv setup specifics that will bite a from-scratch impl (validate.ts:9-17):
new Ajv({ strict: false })— the VL/Vega schemas fail ajv strict-mode at compile time otherwise.- The VL schema is draft-06 → must
ajv.addMetaSchema(json-schema-draft-06.json)(ajv 8 defaults to draft-07/2020) orcompilethrows. - Register a no-op
color-hexformat (ajv.addFormat('color-hex', () => true)) plusaddFormats(ajv); the schema references formats ajv-formats doesn't cover. - Compile the validator once at module load and cache it — the schema is huge; compiling per keystroke is a perf killer.
Where we improve: ajv errors are shown as JSON-pointer text (e.g. /encoding/x) with no
editor position — vega/editor does not map them to markers. Only JSON syntax errors get a
line/col (via jsonc-parser's visitor, utils/jsonc-parser.ts:3-17). If our §03E wants inline
ajv markers, we map instancePath → editor offsets ourselves via jsonc-parser's node tree —
something the reference does not do.
4 · Data flow & debouncing (M1/M2 — translate to Zustand stores)
vega/editor keeps editorString (the text) as the single source of truth; the parsed spec
and compiled Vega spec are derived and recomputed by a subscriber when text/mode/config change
(app.tsx:338-365). Errors don't clobber the last-good derived specs.
The Zustand-store translation (this is the shape to build):
text (store field, debounced writer on editor change)
└─▶ parsedSpec (derived: JSONC parse + collect syntax/diagnostic errors)
└─▶ renderInput (derived: prepareSpecForRender — refs, fit-mode)
└─▶ effect: deep-equal guard → vegaEmbed(); finalize previous view
What to borrow:
- Debounce only at edit→state, not state→render. vega/editor debounces the editor at
1200 ms (
spec-editor/renderer.tsx:66) and guards the render with adeepEqualprop diff (renderer.tsx:340-349). (1200 ms is their number; tune ours — our settings expose a render-debounce preference.) - A manual-parse escape hatch (Ctrl/Cmd+S re-parses without waiting) maps to a future
live-vs-manual preview toggle (
renderer.tsx:89-111). LocalLoggerpattern (utils/logger.ts): a logger that bufferserrors/warns/infos/debugsinto arrays instead of writing to console. This lets a puresrc/corecompile/validate step return structured diagnostics with zero browser coupling — e.g.validateSpec(spec) → { errors, warns }. Ideal core-first fit.json-stringify-pretty-compactfor the format action and prettify-on-load — much nicer thanJSON.stringify(…, null, 2)for VL specs.
Persistence note: vega/editor snapshots its whole state to localStorage on every change,
stripping non-serializable fields (view, runtime, editor refs) and restoring via
{ ...DEFAULT_STATE, ...parsed } (context/app-context.tsx). Our debounced auto-save to
IndexedDB (doc 01/02) is the better pattern — but the "strip non-serializable, restore with
defaults-spread" discipline is worth keeping.
Borrow list (where each lands)
| Technique | Lands in | Milestone |
|---|---|---|
Bundle VL schema from package build/; setDiagnosticsOptions |
src/app/infrastructure/ Monaco setup |
M2 |
markdownDescription patch + compact formatter |
Monaco setup | M2 |
Explicit Vite worker wiring (MonacoEnvironment.getWorker) |
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 |
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 |
| Fatal-vs-advisory two-tier error model | rendering/store contract | M1/M2 |
"container" sizing + ResizeObserver → view.resize() |
rendering.ts + LivePreview |
M2 |
finalize()-before-reembed + render-generation guard |
LivePreview | M1 |
theme = vega-themes config merged into vegaEmbed |
preview + settings | M5 |
json-stringify-pretty-compact format action |
editor | M2 |
Where we deliberately do better than the reference
- Wire Monaco workers explicitly (they sidestep it via the CDN loader).
- Map ajv errors to editor positions via jsonc-parser offsets (they show pointer text only).
- Render-generation guard against stale async renders (they rely on debounce alone).
fileMatch-based schema binding so validation works without a$schemaline.- Debounced auto-save to IndexedDB rather than write-the-whole-state-on-every-change.
Key files in the reference (for deeper reads)
src/utils/monaco.ts— all Monaco/schema wiringsrc/utils/markdownProps.ts— themarkdownDescriptionpatchsrc/utils/validate.ts— ajv setup + cached validatorssrc/utils/jsonc-parser.ts— tolerant parse + line/col syntax errorssrc/utils/logger.ts—LocalLogger/DispatchingLoggersrc/components/renderer/renderer.tsx— the hand-rolled View lifecycle (finalize, sizing, errors)src/components/app.tsx:188-365— parse → $schema check → ajv → compile → render orchestrationsrc/components/error-pane/renderer.tsx— error/log displaysrc/constants/default-state.ts— the full app-state shapesrc/components/input-panel/spec-editor/renderer.tsx— editor component, 1200ms debounce, $schema→mode detection