Files
astrolabe/docs/architecture/08-vega-editor-techniques.md
T
oleh 87b1b98c12 Editor: CodeLens to scaffold Vega-Lite data transforms
Add a discoverable '+ Add transform' / '+ filter/aggregate/...' CodeLens in the
spec editor that scaffolds field-typed transform[] steps, with a type-to-filter
completion on the same catalog as an accelerator. New pure core
(spec-data-transforms): step-slot and view-site classification, the field-typed
catalog, and comma affixing. Deliberately the one home the schema leaves bare —
inline channel transforms and the transform key stay with the JSON schema.

Rename the composition toolbar menu Transform -> Compose so it reads distinctly
from a data transform. Deferred (recorded at their sites): extract a shared
cursor-lens skeleton across the two installers; a shared completion replace-range
helper (now 3 sites); params scaffolding as the sibling feature.
2026-07-01 15:43:48 +03:00

35 KiB
Raw Blame History

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 0107. 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 storesnot 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.compilevega.parsenew 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 ?workernot 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 newsvega-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 descriptionmarkdownDescription 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):
    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 uris (.../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'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 §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-cursorfindViewRange (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.
  • 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-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.
  • 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 — 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.tsLocalLogger / 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