# 08 · Borrowed Techniques from vega/editor > The official Vega-Lite editor ([vega/editor](https://github.com/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/`](../spec/); > the patterns are still docs [01](01-state-and-stores.md)–[07](07-naming-and-relationships.md). > 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-editor` from 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 via `onMount`, so it saves nothing there. - Its headline **`value`/`onChange` controlled-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.api` to "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-embed` for its live > preview. It builds the compile→parse→View pipeline by hand; `vega-embed` is imported only > for types and the exported standalone-HTML snippet. This is _good news_ — `vega-embed` is > 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 discipline `vega-embed` still 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's `build/` output (`monaco.ts:7-8`, `vite.config.ts`). The schema version is pinned to the installed `vega-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`). - **`markdownDescription` patch** (`monaco.ts:12-13`, `utils/markdownProps.ts`): recursively copy every schema `description` → `markdownDescription` before registering. Monaco renders rich hover docs only from `markdownDescription`; without this, hovers are plain text. Do it once at setup. - **Replace the built-in JSON formatter** with `json-stringify-pretty-compact` via `registerDocumentFormattingEditProvider('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** set `self.MonacoEnvironment.getWorker` to return the `json.worker` for label `'json'` and `editor.worker` otherwise (via `?worker` imports): ```ts import 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()), }; ``` The `json.worker` runs schema validation + autocomplete. **No worker ⇒ no squiggles, no completions.** Upside: dropping the CDN loader makes `monaco` synchronously importable — no `await loader.init()` dance, just call `setDiagnosticsOptions(...)` at module load. - ⚠️ **`$schema`-based binding vs `fileMatch`.** They register schemas under versioned `uri`s (`.../vega-lite/v6.json`) and bind by matching the doc's `$schema` value — **no `fileMatch`** (`monaco.ts:15-46`). Consequence: a spec with **no `$schema` gets zero validation/autocomplete.** Astrolabe should prefer `fileMatch` against our model URIs so validation works regardless of whether the user wrote a `$schema` line. - ⚠️ **Set `enableSchemaRequest:false`** for our offline-first app. They set it `true` (`monaco.ts:54`), which lets the worker network-fetch any unbundled `$schema` URL — 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](05-rendering-theming-preview.md)'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-themes` config 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 chosen `theme`/`config` to `vegaEmbed`, and when our theme changes, re-embed with the new config. - **`"width":"container"` / `"height":"container"` is how VL responsiveness works** — it compiles `width`/`height` to signals that re-read `containerSize()` **only on a `window:resize` event** (`renderer.tsx:78-90` detects container sizing). Two things a from-scratch impl _will_ get wrong (we did): (1) `view.resize().runAsync()` does **not** 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 (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`). `vegaEmbed` returns `{ view, finalize }` — call `finalize()` 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.** `runAsync` is 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 `runAsync` in 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-`eval` CSP. 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:** 1. **Monaco JSON worker** → inline **squiggles, hovers, autocomplete** in the editor. 2. **`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 `error` and suppress the chart. - **Advisory** — ajv schema-validation findings and `$schema` version 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) or `compile` throws. - Register a no-op `color-hex` format (`ajv.addFormat('color-hex', () => true)`) plus `addFormats(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 a `deepEqual` prop 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`). - **`LocalLogger` pattern** (`utils/logger.ts`): a logger that buffers `errors/warns/infos/debugs` into arrays instead of writing to console. This lets a **pure** `src/core` compile/validate step _return_ structured diagnostics with zero browser coupling — e.g. `validateSpec(spec) → { errors, warns }`. Ideal core-first fit. - **`json-stringify-pretty-compact`** for the format action and prettify-on-load — much nicer than `JSON.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. --- ## 5 · Editor augmentation (our layer over the borrowed base) Beyond schema validation/completion (§1), the spec editor adds structural refactors, data-aware hints, and expression intelligence — the edits and feedback that are awkward in raw JSON and out of reach of the single-view visual builder. All transform and analysis logic is pure `src/core/`; the Monaco glue is thin app-layer services. **Core (pure, portable):** - `spec-transforms` — wrap a view in `layer`/`hconcat`/`vconcat`/`facet`/`repeat`; collapse a single-child composition (`unwrapSingleton`). Object-in/object-out. (Surfaced as the toolbar **Compose** menu — named to read distinctly from a data `transform`, below.) - `spec-data-transforms` — the data-pipeline counterpart: `transformSiteAt` resolves the view the cursor is in and its `transform[]` range/count (for the CodeLens), `transformPlacementAt` classifies a step slot (for the completion) — both tagged `shared` when the pipeline sits on a composition parent, `view` on a unit. `DATA_TRANSFORMS` is the field-typed step catalog (filter, calculate, aggregate, bin, timeUnit, window, joinaggregate, fold, lookup); each builder takes the columns in scope and seeds each `${n:default}` tab stop with a type-appropriate one. A test round-trips every snippet through `snippetToPlain` to assert it's valid JSON. - `spec-data` — the Vega-Lite data model: classify a `data` block (`classifyData`, mirroring `isNamedData`), the library reference name (`libraryRefName`), and the data binding in scope at a cursor path (`dataBindingAtPath`, honoring a view's data inheritance from its parent). - `spec-cursor` — `findViewRange` (cursor offset → the enclosing view's byte range), `pathAtOffset` (cursor → JSON path), and `valueKeyAtOffset`/`stringValueAtOffset` (the JSON context at the cursor), over `jsonc-parser`. - `spec-fields` — field names a spec's transforms introduce (their `as`), scoped to a cursor's ancestor chain (`derivedFieldNamesAtPath`). - `spec-inline-data` — the rows a specific `data` binding carries for profiling (`rowsForDataBinding`: inline `values`, or a self-defined `datasets` entry). - `spec-insert` — the composition the cursor is in (`compositionTargetAt`), inserting a view at an index (`insertView`) and reordering siblings (`moveView` swaps a neighbour, `moveViewTo` slides to any index), plus `elementOffset` to re-find a view after the edit. It also owns the shared `SpecPath` walkers (`valueAtPath`/`arrayAtPath`/`isPrefixPath`) — a module navigating a path reuses these rather than re-inlining the array/object descent. - `spec-view-tree` — the whole composition as a recursive tree (`viewTree`), each node carrying its operator, orientation and byte range. Read at once (vs. `spec-insert`'s one-array edits) to drive the composition wireframe — a schematic of the multi-view structure in a preview-toolbar disclosure (`CompositionWireframe`); clicking a box reveals that view's range in the editor via `AppStore.requestRevealView`, and on the draft it is **drag-editable** (reorder + restructure). Interaction contract in [arch 10](10-interaction-and-feedback.md). - `spec-restructure` — the path-targeted cross-container moves behind the wireframe's drag. `wrapViews(target, source, axis, side)` pairs the dragged source beside the drop target in a new concat (placed where the target was, the source removed), enforcing three invariants: **flatten** a bare same-orientation concat nested directly in a concat (so a with-axis drop reads as a plain _insert_, not redundant nesting), **collapse** the source's emptied container (unwrap a one-child, drop a zero-child, recursing up the chain), and **data-pin** a source's inherited `data` before it changes ancestor (`dataBindingAtPath`, so it never silently rebinds). Degenerate drops — onto itself, its own ancestor/descendant, or the root — return null. `wrapContainer` is the complement for a frame-margin drop: it stacks the source against the _whole_ container rather than beside one view — the root included, lifting spec-level metadata onto the wrapper via `spec-transforms.concatRootBeside` — pulling a view out into a new full-span row/column. The same flatten/collapse cleanup runs after. `simplifyStructure` collapses redundant single-child compositions recursively (a `{hconcat:[v]}` is just `v`; `facet`/`repeat` hold one child by design and are left alone) — the wireframe's Simplify, returning null when nothing is redundant. - `expr-validate` — one Vega expression, parsed with Vega's own `parseExpression` (no divergent grammar): `validateExpression` (valid + parser message), `referencedFields` (its `datum.` references), and `activeCall` (the enclosing call + which argument the cursor is in, for signature help). - `spec-expressions` — the expressions embedded in a spec's JSON strings (`EXPRESSION_KEYS` = `calculate`/`filter`/`expr`/`test`; only string values, so object predicates are skipped). `expressionStringsIn` locates each (byte span + key) to drive markers; `firstExpressionError` names the first malformed one (key + parser message + 1-based line) so a failed render can attribute itself. - `vega-expr-catalog` — the expression language's function/constant **names derived from `vega-expression`'s own registry** (zero drift; a test asserts the curated set ⊆ derived), plus curated parameter signatures for the commonly-typed functions (what the registry can't supply). **Services (app, store-aware via `getState`):** `spec-transform-actions` (the wrap/simplify/add-view operations and their surfaces), `spec-transform-scaffold` (the data-transform scaffold — a CodeLens plus a completion), `spec-dataset-hints` (data-column completion, hover, inlay providers), `spec-expression-hints` (expression completion, signature help, hover, and the diagnostic markers), `active-dataset` (`dataInfoAt(text, offset)` — the columns/types/stats plus derived fields the draft sees at the cursor). `SpecEditor` does the wiring. Decision rules: - **Provider lifetime — global-once vs per-editor.** Language providers that need no editor handle (code actions, completion, hover, inlay) register **once** for `json`, like the schema and formatter; per-editor registration would duplicate them on remount. Pieces that need the editor handle — the `addAction` context/F1 commands, and the CodeLens whose command runs `executeEdits` — are installed **per editor** and disposed with it. - **Transform scope.** A transform targets, in order: an explicit selection → the view the cursor sits in (`findViewRange`) → the whole document. A "view" is a composition-array element or a facet/repeat `spec` child; a flat unit spec has no inner view, so it scopes to the whole document. `jsonc-parser` is error-tolerant, so scoping holds mid-edit; the path logic stays in core and only the Monaco `Range` is built in the service. - **One edit path.** The lightbulb returns a `WorkspaceEdit` (no editor handle); the toolbar and palette use `executeEdits`. Both build the replacement through the same serialize-and-reindent step, bracketed by `pushUndoStop`, so ⌘Z restores the prior text. - **Transform actions share an applier, never re-inline the skeleton.** Every `run*` action is parse → `build(spec)` → `writeBack` (toast on null). That prologue lives in a shared applier per family — `resolveTarget` (scoped), `applyArrayEdit` (one array), `applyWholeSpecEdit` (whole-spec drag/simplify) — so a new action passes its core call and differs only in scope and feedback. A fresh `run*` reuses the matching applier rather than copying the model/parse/writeBack lines. - **Wireframe restructuring is a core op + the editor's undo.** Every drag resolves to `moveViewTo` (reorder within one container), `wrapViews` (pair, cross-container move, insert), or `wrapContainer` (pull a view out around a container); the Simplify prompt resolves to `simplifyStructure`. The wireframe only _requests_ each (`AppStore.requestComposeMove`/`requestComposeWrap`/ `requestComposeWrapContainer`/`requestComposeSimplify`) and `SpecEditor` applies it through the same `executeEdits` + `pushUndoStop` path as the other transforms, so a drag is one ⌘Z and the editor stays the single text source. `wrapViews` covers wrap and insert with one operation because it flattens bare same-orientation nesting afterward; the drag's zone interaction model lives in [arch 10](10-interaction-and-feedback.md). - **Composition CodeLens is cursor-scoped.** It follows the view the cursor sits in — `+ Add view above/below` at the view's edges and `↑/↓ Move` to reorder among its siblings — rather than one fixed button per composition array; an empty composition shows a single `+ Add view`, and the F1 palette mirrors all four for the keyboard. The provider reads `editor.getPosition()` and refreshes via an `onDidChange` emitter fired on cursor moves (keyed to the enclosing view, so typing inside one view doesn't churn the lenses). After an edit the cursor follows the affected view (`elementOffset`), so a repeated click keeps acting on it instead of the neighbour that slid into place. Cursor-scoping does not strand a load-bearing action ([arch 10](10-interaction-and-feedback.md) — revealed actions): editing a composition puts the cursor in a view exactly when add/reorder is wanted, and the palette is the ever-present path for the keyboard. - **Field source for hints (view-scoped).** `dataInfoAt(text, offset)` resolves the data binding of the cursor's **nearest enclosing view** (`dataBindingAtPath` — a child inherits a parent's data unless it declares its own), then its columns: a named **library dataset** (matched case-insensitively, like the renderer), else the binding's **inline rows** profiled on the fly (`rowsForDataBinding` + `core/profile`) — a "ghost dataset" with nothing stored. Derived fields come from that view's and its ancestors' transforms only (`derivedFieldNamesAtPath`), so a sibling view's `calculate` does not leak in. Profiling is memoized per (draft text, enclosing view), so the inlay provider's many per-line queries profile once. A composed spec whose views bind different datasets therefore gets the right columns per view. Out of scope: data-dependent derived columns (`pivot`/`lookup` output) and `url`/CSV-string inline data, which need the pipeline run or format-aware parsing. - **No unknown-field diagnostic.** Hints are additive and forgiving, so over- or under-listing costs nothing; a "field not in data" squiggle would false-positive on every derived or data-dependent field, so there is deliberately none. - **Expression intelligence is one service; its markers are per-editor.** `spec-expression-hints` registers the expression completion/signature-help/hover **once for `json`** (like the other providers), but the marker pass — validating every expression string and squiggling the invalid ones with `setModelMarkers` (the app's only editor markers besides the JSON worker's, under the `vega-expr` owner) — is **per editor**, since it writes to one model, and recomputes debounced on edit and on a draft↔published toggle. All expression concerns (completion, hover, markers) live here; `spec-dataset-hints` owns only data-column hints, so neither is a grab-bag. - **Completion replace-ranges come from a self-parsed partial, never `getWordUntilPosition`.** Monaco's JSON `wordPattern` counts `.` and `(` as word characters, so the model's "word" after `datum.` or `fn(` spans the whole `datum.`/`fn(` token; used as a completion item's range it both mis-targets the edit and filters every suggestion out (none start with `datum.`). A provider completing inside a string must build the replace range from the partial it parses itself — a rule the transform scaffold inherits (it parses the trailing element word off the line). This `new Range(line, col − partialLen, line, col)` construction is now at three sites (`spec-expression-hints`, `spec-dataset-hints`, `spec-transform-scaffold`); a shared `replaceRange(position, partialLength)` helper is earned and should be extracted on the next touch. - **Data-transform scaffolding is a CodeLens (discoverable) plus a completion (accelerator), on the one home the schema leaves bare.** A Vega-Lite data `transform` has three possible homes, confirmed against the bundled schema (the `transform` array is on 16 spec types, i.e. every view node): a `transform[]` **step**; the **inline** field props on an encoding channel (`bin`/`timeUnit`/`aggregate`/`sort`); and a **new** pipeline on a bare view. We scaffold the step home only, on the same "add only what the schema lacks" rule as `spec-dataset-hints`: the schema already completes the inline channel keys and their enum values, and the `transform` key itself — but never a ready, field-typed `{ "filter": … }`. The **CodeLens is the discoverable surface** (a completion is invisible until provoked and competes silently with the schema's suggest items): cursor-scoped like the composition CodeLens, it shows `+ Add transform` on a view with no pipeline and per-step `+ filter`/`+ aggregate`/… on the array, each clicking through Monaco's snippet engine so the field-typed tab stops survive. The completion is the type-to-filter accelerator on the same catalog. A step's `scope` (`shared` when the array is on a composition parent, so it feeds every child) is surfaced so the placement is not a surprise, and comma affixing keeps the array valid whether the slot is empty, between elements, or appended after one without a trailing comma. - **Code-action menu icons are kind-derived** (a wrench for the `refactor.*` kinds) — Monaco's `CodeAction` carries no icon field. Custom iconography lives only where it is supported: CodeLens titles (`$(codicon)`), completion-item kinds, and glyph-margin decorations. `jsonc-parser` is a direct dependency (Monaco bundles its own copy internally but does not re-export it). A standalone `editor-augmentation-demo.html` loads Monaco from a CDN to exercise these provider surfaces in isolation. ## 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` → synthetic `window:resize` (not `view.resize()`) | `chart-renderer` + 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 `$schema` line. - **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 wiring - `src/utils/markdownProps.ts` — the `markdownDescription` patch - `src/utils/validate.ts` — ajv setup + cached validators - `src/utils/jsonc-parser.ts` — tolerant parse + line/col syntax errors - `src/utils/logger.ts` — `LocalLogger` / `DispatchingLogger` - `src/components/renderer/renderer.tsx` — the hand-rolled View lifecycle (finalize, sizing, errors) - `src/components/app.tsx:188-365` — parse → $schema check → ajv → compile → render orchestration - `src/components/error-pane/renderer.tsx` — error/log display - `src/constants/default-state.ts` — the full app-state shape - `src/components/input-panel/spec-editor/renderer.tsx` — editor component, 1200ms debounce, $schema→mode detection