Files
astrolabe/docs/architecture/08-vega-editor-techniques.md
T

515 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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** (~5080 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-params` — the parameter counterpart: `paramSiteAt` resolves both homes a parameter can take
(the **root** spec for a variable widget, the **nearest enclosing unit** for a selection —
grammar-confirmed against the bundled schema: a nested unit's `params[]` takes selections only),
and `paramPlacementAt` classifies a `params[]` slot (root → both families, nested → selections only)
for the completion. `PARAMS` is the catalog (slider, dropdown, radio, checkbox; point, interval); a
slider's bounds seed from the numeric field's `numericExtent`, a point selection's field from a
categorical column. A test validates every seeded entry against the bundled Vega-Lite schema.
- `spec-snippet` — the snippet-insertion text math both scaffolds share: `snippetToPlain`
(tab stops → default text, so catalogs and edits round-trip through `JSON.parse` in tests),
`arrayAffixes`/`appendEntryEdit` (the comma affixing that keeps a spliced array valid), and
`createArrayPropertyEdit` (the inline `"key": [ … ]` first-property insertion and its indent
math). Pure `(text, site) → { offset, snippet }` — a bug here writes invalid JSON into the
user's editor, so the edits are table-tested applied-and-reparsed, off the Monaco glue.
- `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.<field>`
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-param-scaffold` (the parameter
scaffold — likewise 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.
- **Cursor-aware CodeLenses share one skeleton.** The composition lens
(`spec-transform-actions`), the transform scaffold, and the parameter scaffold are the same
per-editor shape: a draft-gated provider that re-resolves the site under the cursor and refreshes
only when a keyed summary of it changes. That skeleton is `installCursorLens(editor, { resolve,
keyOf, lensesOf })` (`services/editor-cursor-lens`) — a new cursor lens supplies those three and its
own commands, nothing else. The tab-through snippet splice and the completion-slot plumbing both
scaffolds perform are shared the same way via `editor-snippet` — the Monaco glue over
`core/spec-snippet`'s tested edit math.
- **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.
- **Parameter scaffolding splits by family, because `params` has two homes.** Unlike a `transform`
(on every view node), a **variable** widget (slider/dropdown/radio/checkbox, bound via `bind`) is only
legal in the **root** `params[]` — a document-global input any view's `filter` can read — while a
**selection** (point/interval, via `select`) attaches to the **unit** whose marks it reads. So the
scaffold does not reuse `transformSiteAt`: `paramSiteAt` resolves both homes, and the CodeLens is
cursor-scoped to match — variable widgets show at the top level (or a single-view spec, where the
root _is_ the unit, so both families land on one line), selections on the unit the cursor is in, and a
nested unit hides the variable widgets it cannot hold. The completion offers both families in the root
array, selections only in a nested unit's. Defaults are data-seeded where the data allows (a slider's
`min`/`max` from the numeric field's extent, a point's `fields` from a categorical column) and
structured placeholders otherwise (a dropdown's options).
- **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