diff --git a/docs/architecture/00-overview.md b/docs/architecture/00-overview.md
index 1d3b7dc..19b2778 100644
--- a/docs/architecture/00-overview.md
+++ b/docs/architecture/00-overview.md
@@ -44,7 +44,7 @@ about user-facing widgets. At that overlap, one rule keeps them from drifting:
| 05 | [Rendering, Theming & Preview](05-rendering-theming-preview.md) | vega-embed integration (`actions:false`, `view.finalize()`); field-name escaping; theme→config mapping; debounced non-blocking renderer; resilient error display. |
| 06 | [Type Inference & Profiling](06-type-inference.md) | Pure, portable column-type inference (number/text/date/boolean) and the dataset profile shape. |
| 07 | [Naming & Relationships](07-naming-and-relationships.md) | Unique-name enforcement + import auto-suffix; the bidirectional snippet↔dataset name link; rename propagation into specs. |
-| 08 | [vega/editor Techniques](08-vega-editor-techniques.md) | Reference brief: borrowable Monaco-schema wiring, vega-embed lifecycle, two-tier validation, and data-flow/debounce techniques distilled from the official Vega-Lite editor — plus where we do better. |
+| 08 | [vega/editor Techniques](08-vega-editor-techniques.md) | Reference brief: borrowable Monaco-schema wiring, vega-embed lifecycle, two-tier validation, and data-flow/debounce techniques distilled from the official Vega-Lite editor — plus where we do better, and our editor-augmentation layer (structural transforms + data-aware hints). |
| 09 | [Visual Design Language](09-visual-design.md) | The _visual_ contract: principles inspired by IBM/Carbon, deliberate divergences (square chrome, free color/theming), the token system (Plex type, 8px spacing, role-based color, motion), component conventions, and where to mine the Carbon/IBM source repos for more. Companion: [`visual-specimen.html`](visual-specimen.html). |
| 10 | [Interaction & Feedback](10-interaction-and-feedback.md) | The _interaction_ contract: the feedback-channel decision table, latency/feedback budgets, the non-happy-path triad, the recovery & data-safety contract, the keyboard/focus contract, and the resolved widget patterns (window splitter, toolbar, segmented controls, selectable lists, search, sort, empty states, modals). Cites `spec/` for behavior; owns the _how_. |
| 11 | [Learning Section](11-learning-section.md) | The `/learn/` deep-dive: a marketing-surface Vite entry reusing core + the landing chart embed; markdown-authored lessons (`import.meta.glob`) parsed into an ordered block model; the authoring/engine split (pure parser in core; `marked` only in `src/learn`). |
diff --git a/docs/architecture/08-vega-editor-techniques.md b/docs/architecture/08-vega-editor-techniques.md
index 6ef79e6..3355c0a 100644
--- a/docs/architecture/08-vega-editor-techniques.md
+++ b/docs/architecture/08-vega-editor-techniques.md
@@ -275,6 +275,60 @@ 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 and
+data-aware hints — the edits that are awkward in raw JSON and out of reach of the
+single-view visual builder. All transform 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.
+- `spec-cursor` — `findViewRange` (cursor offset → the enclosing view's byte range) and
+ `valueKeyAtOffset`/`stringValueAtOffset` (the JSON context at the cursor), over `jsonc-parser`.
+- `spec-fields` — field names a spec's own transforms introduce (their `as`).
+- `spec-inline-data` — the inline rows a spec carries (`data.values`, a `datasets` entry).
+- `spec-insert` — composition arrays + appending a view to one.
+
+**Services (app, store-aware via `getState`):** `spec-transform-actions` (the
+wrap/simplify/add-view operations and their surfaces), `spec-dataset-hints` (completion,
+hover, inlay providers), `active-dataset` (`dataInfo()` — the columns/types/stats + derived
+fields the draft sees). `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.
+- **Field source for hints.** `dataInfo()` reads columns from the named library dataset the
+ draft references, else profiles the spec's **inline data on the fly** (`spec-inline-data` +
+ `core/profile`) — a "ghost dataset" with nothing stored — and adds the spec's derived
+ fields. Memoized by draft text, since providers fire per keystroke and per scroll. 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.
+- **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 |
diff --git a/docs/architecture/editor-augmentation-demo.html b/docs/architecture/editor-augmentation-demo.html
new file mode 100644
index 0000000..2a6c85f
--- /dev/null
+++ b/docs/architecture/editor-augmentation-demo.html
@@ -0,0 +1,853 @@
+
+
+
+
+
+ Editor augmentation — Monaco interactivity sandbox
+
+
+
+
+
+
+
+
+
+
+
+
+ A live Vega-Lite spec bound to a fake weather dataset
+ (date, precipitation, temp_max,
+ temp_min, wind, weather). Move the
+ cursor around and try each surface — every action edits the real document and
+ is undoable with ⌘/Ctrl+Z.
+
+
+ Refactor & transform
+
+
+
⌘. Lightbulb code actions
+
+ The contextual refactor menu. Open it in the view and you'll see
+ Wrap in layer / hconcat / vconcat , plus value swaps when you're on a
+ mark, type, or field line. Select a
+ child view's JSON first to wrap just that part; with no selection it wraps the
+ whole document. This is the position-aware "give me ideas" surface.
+
+
Open at cursor
+
+
+
+
right-click / F1 Menu actions
+
+ The same transforms as durable menu items — a discoverable home with the
+ lightbulb as the accelerator. Right-click the editor, or press
+ F1 and type "wrap".
+
+
Command palette
+
+
+
+
inline CodeLens
+
+ Clickable affordances rendered above a line: + add layer /
+ + add color encoding over "mark", and
+ + add view over a composition array. Look just above the
+ "mark" line.
+
+
+
+ Beyond the schema
+
+
+
⌃Space Dataset-aware completion
+
+ In a "field" value, suggestions are the dataset's real columns
+ with their inferred types — something the Vega-Lite schema can't know. Also
+ augments type, mark, and aggregate
+ values. Click inside a field's quotes and press ⌃Space.
+
+
+
+
+
hover Field hover
+
+ Hover a column name to see its inferred type and sample values pulled from
+ the bound dataset (merged with, not replacing, the schema's own hover).
+
+
+
+
+
ghost Inlay hints
+
+ Each "field" gets a faint type annotation beside it — annotation
+ without touching the text. Toggle it from the header.
+
+
+
+
+
squiggle Diagnostics → quick fix
+
+ A field not in the dataset gets a warning squiggle and a "Change to …" quick
+ fix. The starter spec ships one typo ("wnd") so you can see it
+ immediately — open the lightbulb on that line.
+
+
+
+ Editor basics (for reference)
+
+
⇧⌥F Format & misc
+
Format the document, then notice folding, multi-cursor, and the minimap all come free with Monaco.
+
Format document
+
+
+
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index ad2bde0..dd1a39c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -22,6 +22,7 @@
"@fontsource/space-mono": "^5.2.9",
"@fontsource/spectral": "^5.2.8",
"json-stringify-pretty-compact": "^4.0.0",
+ "jsonc-parser": "^3.3.1",
"marked": "^18.0.5",
"monaco-editor": "^0.54.0",
"react": "^19.2.7",
@@ -6148,6 +6149,12 @@
"node": ">=6"
}
},
+ "node_modules/jsonc-parser": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
+ "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
+ "license": "MIT"
+ },
"node_modules/jsonfile": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
diff --git a/package.json b/package.json
index 9db9eac..c5729f1 100644
--- a/package.json
+++ b/package.json
@@ -38,6 +38,7 @@
"@fontsource/space-mono": "^5.2.9",
"@fontsource/spectral": "^5.2.8",
"json-stringify-pretty-compact": "^4.0.0",
+ "jsonc-parser": "^3.3.1",
"marked": "^18.0.5",
"monaco-editor": "^0.54.0",
"react": "^19.2.7",
diff --git a/src/app/components/SpecEditor.tsx b/src/app/components/SpecEditor.tsx
index 454ba9e..aba8e37 100644
--- a/src/app/components/SpecEditor.tsx
+++ b/src/app/components/SpecEditor.tsx
@@ -33,6 +33,14 @@ import {
runExtractConfigToTheme,
runMergeChartTheme,
} from '../services/spec-config-actions';
+import {
+ configureSpecTransformCodeActions,
+ installSpecTransformActions,
+ installSpecTransformCodeLens,
+ runUnwrap,
+ runWrap,
+} from '../services/spec-transform-actions';
+import { configureSpecDatasetHints } from '../services/spec-dataset-hints';
import { useAppStore } from '../stores/AppStore';
import { confirm } from '../stores/ConfirmStore';
import { useDatasetStore } from '../stores/DatasetStore';
@@ -149,6 +157,11 @@ function EditorSettings() {
configureVegaLiteJson();
// Register the compact JSON formatter once (Format Document + format-on-paste, §03A).
configureJsonFormatter();
+// Register the structural-transform refactors (the lightbulb) once, globally for
+// JSON — like the schema/formatter, not per editor (docs/architecture/08).
+configureSpecTransformCodeActions();
+// Register the dataset-aware completion/hover/inlay providers once (docs/architecture/08).
+configureSpecDatasetHints();
/** The two spec↔config operations, surfaced as an overflow menu (council:
* Carbon menu-buttons — overflow for additional options under space
@@ -174,6 +187,25 @@ const CONFIG_ACTIONS = [
type ConfigActionId = (typeof CONFIG_ACTIONS)[number]['value'];
+/** Structural transforms, surfaced as a sibling menu to Config (the discoverable
+ * home; the lightbulb and F1 palette are the accelerators — see
+ * services/spec-transform-actions). They act on the selection, else the whole
+ * spec. */
+const TRANSFORM_ACTIONS = [
+ { value: 'layer', label: 'Wrap in layer', detail: 'Overlay marks on shared scales' },
+ { value: 'hconcat', label: 'Wrap in horizontal concat', detail: 'Place views side by side' },
+ { value: 'vconcat', label: 'Wrap in vertical concat', detail: 'Stack views top to bottom' },
+ { value: 'facet', label: 'Wrap in facet', detail: 'Small multiples across a field' },
+ { value: 'repeat', label: 'Wrap in repeat', detail: 'Repeat the chart across fields' },
+ {
+ value: 'simplify',
+ label: 'Simplify composition',
+ detail: 'Collapse a single-child layer/concat back to a unit',
+ },
+] as const;
+
+type TransformActionId = (typeof TRANSFORM_ACTIONS)[number]['value'];
+
function EditorToolbar({
editorRef,
}: {
@@ -228,6 +260,13 @@ function EditorToolbar({
else runExtractConfigToTheme(editor);
};
+ const handleTransformAction = (action: TransformActionId) => {
+ const editor = editorRef.current;
+ if (!editor) return;
+ if (action === 'simplify') runUnwrap(editor);
+ else runWrap(editor, action);
+ };
+
const handleRevert = async () => {
const ok = await confirm({
title: 'Revert draft',
@@ -284,6 +323,16 @@ function EditorToolbar({
Extract to Dataset
)}
+
;
+ columnStats: ReadonlyArray;
+ /** Source columns plus derived fields, de-duplicated (source wins). */
+ fields: ReadonlyArray;
+}
+
+const EMPTY: DataInfo = { name: null, columnTypes: [], columnStats: [], fields: [] };
+
+function safeParse(text: string): unknown {
+ try {
+ return JSON.parse(text);
+ } catch {
+ return null;
+ }
+}
+
+/** The first library dataset the draft references, or null. */
+function libraryDataset(draftText: string): Dataset | null {
+ const refs = extractDatasetRefs(draftText);
+ if (refs.length === 0) return null;
+ const { datasets } = useDatasetStore.getState();
+ for (const ref of refs) {
+ const ds = datasets.find((d) => d.name === ref);
+ if (ds) return ds;
+ }
+ return null;
+}
+
+function compute(draftText: string): DataInfo {
+ const spec = safeParse(draftText);
+
+ // Source columns: a named library dataset wins; otherwise profile inline data.
+ let name: string | null = null;
+ let columnTypes: ReadonlyArray<{ name: string; type: ColumnType }> = [];
+ let columnStats: ReadonlyArray = [];
+ const library = libraryDataset(draftText);
+ if (library && library.columnTypes.length > 0) {
+ ({ name, columnTypes, columnStats } = library);
+ } else {
+ const rows = inlineDataRows(spec);
+ if (rows) {
+ const profile = profileData(rows, 0);
+ columnTypes = profile.columnTypes;
+ columnStats = profile.columnStats;
+ }
+ }
+
+ const fields: FieldHint[] = columnTypes.map((c) => ({
+ name: c.name,
+ type: c.type,
+ derived: false,
+ }));
+ const seen = new Set(fields.map((f) => f.name));
+ for (const derived of derivedFieldNames(spec)) {
+ if (!seen.has(derived)) {
+ fields.push({ name: derived, type: null, derived: true });
+ seen.add(derived);
+ }
+ }
+
+ return { name, columnTypes, columnStats, fields };
+}
+
+// Keyed by draft text alone: this assumes a bound dataset's profile is stable
+// for a given draft. A re-import that changes columns under unchanged text serves
+// stale hints until the next keystroke — harmless, since hints are additive.
+let cache: { text: string; info: DataInfo } | null = null;
+
+/** The active draft's data context, memoized by draft text. */
+export function dataInfo(): DataInfo {
+ const text = useSnippetStore.getState().draftText;
+ if (text.trim() === '') return EMPTY;
+ if (cache && cache.text === text) return cache.info;
+ const info = compute(text);
+ cache = { text, info };
+ return info;
+}
+
+/** Source columns + inferred types available to the draft (for facet/repeat defaults). */
+export function boundColumns(): ReadonlyArray<{ name: string; type: ColumnType }> {
+ return dataInfo().columnTypes;
+}
+
+/** Source columns plus the spec's derived fields (for completion/inlay). */
+export function availableFields(): ReadonlyArray {
+ return dataInfo().fields;
+}
diff --git a/src/app/services/spec-config-actions.ts b/src/app/services/spec-config-actions.ts
index aac4a96..5d52c2e 100644
--- a/src/app/services/spec-config-actions.ts
+++ b/src/app/services/spec-config-actions.ts
@@ -40,11 +40,16 @@ import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { notify } from '../stores/NotificationStore';
import { selectActiveSnippet, useSnippetStore } from '../stores/SnippetStore';
-/** Parse the model's JSON, or toast (and return null) when it isn't a JSON object. */
-function parseSpecObject(model: monaco.editor.ITextModel): Record | null {
+/**
+ * Parse spec JSON, or toast (and return null) when it isn't a JSON object. Takes
+ * the text (not the model) so it serves both whole-document and selection-scoped
+ * callers — the config actions pass `model.getValue()`, the transform actions a
+ * selection.
+ */
+export function parseSpecObject(text: string): Record | null {
let parsed: unknown;
try {
- parsed = JSON.parse(model.getValue());
+ parsed = JSON.parse(text);
} catch {
notify({
kind: 'error',
@@ -57,7 +62,7 @@ function parseSpecObject(model: monaco.editor.ITextModel): Record` beside each `field`, annotation without
+ * touching the text.
+ *
+ * All three read the active draft's bound dataset (services/active-dataset) and
+ * the cursor's JSON context (core/spec-cursor) at provide-time via `getState()` —
+ * outside React. They register **once, globally for JSON** (like the schema and
+ * formatter), not per editor. Suggestion-only: over- or under-listing is
+ * harmless, which is why there is deliberately no "unknown field" diagnostic
+ * (that would false-positive on every data-dependent derived column).
+ */
+
+import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
+import { defaultFieldType } from '@core/chart-builder';
+import { validateExpression } from '@core/expr-validate';
+import { stringValueAtOffset, valueKeyAtOffset } from '@core/spec-cursor';
+import type { ColumnType } from '@core/type-inference';
+import { useSnippetStore } from '../stores/SnippetStore';
+import { availableFields, dataInfo, type DataInfo, type FieldHint } from './active-dataset';
+
+/** Property values that reference a data field (where column names belong). */
+const FIELD_KEYS = new Set(['field', 'groupby']);
+/** Property values that hold a Vega expression (where the validity check fires). */
+const EXPR_KEYS = new Set(['calculate', 'filter', 'expr']);
+
+/** A short type label for a source field; null type (derived) is shown elsewhere. */
+const typeLabel = (type: ColumnType): string => defaultFieldType(type);
+
+/** Markdown hover for a field hint: type + stats for source, a note for derived. */
+function fieldHoverContents(hint: FieldHint, info: DataInfo): { value: string }[] {
+ if (hint.derived || hint.type === null) {
+ return [{ value: `**${hint.name}** · _derived by a transform_` }];
+ }
+ const lines = [`**${hint.name}** · \`${typeLabel(hint.type)}\``];
+ const stat = info.columnStats.find((s) => s.name === hint.name);
+ if (stat) {
+ if (stat.numericExtent)
+ lines.push(`Range ${stat.numericExtent.min} – ${stat.numericExtent.max}`);
+ lines.push(`${stat.distinct}${stat.distinctCapped ? '+' : ''} distinct`);
+ }
+ lines.push(info.name ? `_from dataset “${info.name}”_` : '_from the spec’s inline data_');
+ return lines.map((value) => ({ value }));
+}
+
+let registered = false;
+
+/** Register the dataset-aware completion / hover / inlay providers once. */
+export function configureSpecDatasetHints(): void {
+ if (registered) return;
+ registered = true;
+
+ monaco.languages.registerCompletionItemProvider('json', {
+ triggerCharacters: ['"'],
+ provideCompletionItems(model, position) {
+ // Field suggestions only edit the draft; nothing to offer on the published view.
+ if (useSnippetStore.getState().editorView !== 'draft') return { suggestions: [] };
+ const key = valueKeyAtOffset(model.getValue(), model.getOffsetAt(position));
+ if (key === null || !FIELD_KEYS.has(key)) return { suggestions: [] };
+ const fields = availableFields();
+ if (fields.length === 0) return { suggestions: [] };
+
+ const word = model.getWordUntilPosition(position);
+ const range = new monaco.Range(
+ position.lineNumber,
+ word.startColumn,
+ position.lineNumber,
+ word.endColumn,
+ );
+ return {
+ suggestions: fields.map((f) => ({
+ label: f.name,
+ kind: f.derived
+ ? monaco.languages.CompletionItemKind.Variable
+ : monaco.languages.CompletionItemKind.Field,
+ detail: f.derived || f.type === null ? 'derived field' : typeLabel(f.type),
+ insertText: f.name,
+ range,
+ })),
+ };
+ },
+ });
+
+ // All three providers annotate the draft only: they derive their field set from
+ // the draft buffer (dataInfo), so gating to the draft view keeps the hints
+ // consistent with the text they are computed from. The published view is a
+ // read-only reference, where field hints are marginal.
+ monaco.languages.registerHoverProvider('json', {
+ provideHover(model, position) {
+ if (useSnippetStore.getState().editorView !== 'draft') return null;
+ const text = model.getValue();
+ const offset = model.getOffsetAt(position);
+
+ // Over an expression value: a live validity check.
+ const key = valueKeyAtOffset(text, offset);
+ if (key !== null && EXPR_KEYS.has(key)) {
+ const expr = stringValueAtOffset(text, offset);
+ if (expr !== null) {
+ const result = validateExpression(expr);
+ return {
+ contents: [
+ {
+ value: result.valid
+ ? '✓ Valid Vega expression'
+ : `✗ ${result.error ?? 'Invalid expression'}`,
+ },
+ ],
+ };
+ }
+ }
+
+ // Over a field name: its type + stats.
+ const word = model.getWordAtPosition(position);
+ if (word) {
+ const info = dataInfo();
+ const hint = info.fields.find((f) => f.name === word.word);
+ if (hint) {
+ return {
+ range: new monaco.Range(
+ position.lineNumber,
+ word.startColumn,
+ position.lineNumber,
+ word.endColumn,
+ ),
+ contents: fieldHoverContents(hint, info),
+ };
+ }
+ }
+ return null;
+ },
+ });
+
+ monaco.languages.registerInlayHintsProvider('json', {
+ provideInlayHints(model, range) {
+ if (useSnippetStore.getState().editorView !== 'draft') return { hints: [], dispose() {} };
+ const types = new Map(availableFields().map((f) => [f.name, f.type]));
+ if (types.size === 0) return { hints: [], dispose() {} };
+ const hints: monaco.languages.InlayHint[] = [];
+ for (let line = range.startLineNumber; line <= range.endLineNumber; line++) {
+ // First `field` per line; the compact format keeps each encoding channel
+ // (and its lone field) on its own line, so one match per line suffices.
+ const match = /"field"\s*:\s*"([^"]+)"/.exec(model.getLineContent(line));
+ if (!match) continue;
+ const type = types.get(match[1]);
+ if (!type) continue; // unknown or derived (no type to annotate)
+ hints.push({
+ position: { lineNumber: line, column: match.index + match[0].length + 1 },
+ label: `: ${typeLabel(type)}`,
+ kind: monaco.languages.InlayHintKind.Type,
+ paddingLeft: true,
+ });
+ }
+ return { hints, dispose() {} };
+ },
+ });
+}
diff --git a/src/app/services/spec-transform-actions.ts b/src/app/services/spec-transform-actions.ts
new file mode 100644
index 0000000..3e1cfc8
--- /dev/null
+++ b/src/app/services/spec-transform-actions.ts
@@ -0,0 +1,399 @@
+/**
+ * Structural spec transforms as editor actions (docs/architecture/08 → editor
+ * augmentation) — the refactor counterpart to spec-config-actions. Wrap the
+ * focused view in a composition (layer / hconcat / vconcat / facet / repeat) or
+ * collapse a single-child composition back to a unit, over the portable core
+ * transforms (core/spec-transforms).
+ *
+ * **Scope** is the current selection when there is one (the user said exactly
+ * what to target); otherwise the view the cursor sits in — an element of a
+ * layer/concat or a facet/repeat child, resolved by core/spec-cursor — falling
+ * back to the whole document for a flat unit spec with no inner view.
+ *
+ * **Surfacing** mirrors spec-config-actions' three-tier model:
+ * - the editor toolbar's **Transform menu** (SpecEditor) is the discoverable
+ * home, calling `runWrap` / `runUnwrap`;
+ * - the **lightbulb** (`configureSpecTransformCodeActions`, registered once,
+ * global per-language) offers the same transforms contextually at the cursor;
+ * - the **F1 palette** (`installSpecTransformActions`, per editor) is the
+ * keyboard accelerator. These are *not* added to the right-click menu — the
+ * lightbulb already covers the in-place case, and config-actions hold the
+ * three context-menu slots; nine items there would be a thicket.
+ *
+ * Edits go through `executeEdits` (toolbar/palette) or a `WorkspaceEdit` (the
+ * lightbulb, which has no editor handle) — both build the replacement text via
+ * the one `buildNext` + `formatScoped` path, so there is a single transform
+ * path, only the application differs. ⌘Z restores the previous text; invalid
+ * JSON no-ops with a toast; `!editorReadonly` hides the actions on the published
+ * view, and the lightbulb is gated to the active draft.
+ */
+
+import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
+import { defaultFieldType } from '@core/chart-builder';
+import { formatJson } from '@core/json-format';
+import { isJsonObject, type JsonObject } from '@core/spec-config';
+import { findViewRange } from '@core/spec-cursor';
+import { appendView, compositionArrays, type SpecPath } from '@core/spec-insert';
+import {
+ unwrapSingleton,
+ wrapInConcat,
+ wrapInFacet,
+ wrapInLayer,
+ wrapInRepeat,
+} from '@core/spec-transforms';
+import { notify } from '../stores/NotificationStore';
+import { useSnippetStore } from '../stores/SnippetStore';
+import { boundColumns } from './active-dataset';
+import { parseSpecObject } from './spec-config-actions';
+
+/** The composition operators the wrap actions offer. */
+type WrapKind = 'layer' | 'hconcat' | 'vconcat' | 'facet' | 'repeat';
+
+/** The slice of the document a transform reads and rewrites. */
+interface Scope {
+ range: monaco.Range;
+ text: string;
+ /** Column the slice starts at (0-based), so re-indented output stays aligned. */
+ baseCol: number;
+}
+
+const isEmptyRange = (r: monaco.IRange): boolean =>
+ r.startLineNumber === r.endLineNumber && r.startColumn === r.endColumn;
+
+/** The whole document, as a scope. */
+function wholeDocument(model: monaco.editor.ITextModel): Scope {
+ const range = model.getFullModelRange();
+ return { range, text: model.getValue(), baseCol: 0 };
+}
+
+/**
+ * The slice a transform acts on: an explicit selection if present; else the view
+ * the cursor sits in (core/spec-cursor); else the whole document.
+ */
+function resolveScope(model: monaco.editor.ITextModel, range: monaco.IRange | null): Scope {
+ if (!range) return wholeDocument(model);
+ if (!isEmptyRange(range)) {
+ const r = monaco.Range.lift(range);
+ return { range: r, text: model.getValueInRange(r), baseCol: r.startColumn - 1 };
+ }
+ const offset = model.getOffsetAt({
+ lineNumber: range.startLineNumber,
+ column: range.startColumn,
+ });
+ const node = findViewRange(model.getValue(), offset);
+ if (!node) return wholeDocument(model);
+ const start = model.getPositionAt(node.offset);
+ const end = model.getPositionAt(node.offset + node.length);
+ const r = new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column);
+ return { range: r, text: model.getValueInRange(r), baseCol: start.column - 1 };
+}
+
+/** Indent every line after the first by `baseCol`, so a scoped edit stays aligned. */
+function reindent(text: string, baseCol: number): string {
+ if (baseCol <= 0) return text;
+ const pad = ' '.repeat(baseCol);
+ return text
+ .split('\n')
+ .map((line, i) => (i === 0 ? line : pad + line))
+ .join('\n');
+}
+
+/** Serialize the replacement in the app's compact JSON style, aligned to the scope. */
+function formatScoped(model: monaco.editor.ITextModel, scope: Scope, next: JsonObject): string {
+ const raw = JSON.stringify(next);
+ const formatted = formatJson(raw, { indent: model.getOptions().tabSize }) ?? raw;
+ return reindent(formatted, scope.baseCol);
+}
+
+/** A categorical column to facet by (first nominal/ordinal), or a placeholder. */
+function defaultFacet(): { field: string; type: string } {
+ const cols = boundColumns();
+ const categorical = cols.find((c) => {
+ const t = defaultFieldType(c.type);
+ return t === 'nominal' || t === 'ordinal';
+ });
+ const col = categorical ?? cols[0];
+ return col
+ ? { field: col.name, type: defaultFieldType(col.type) }
+ : { field: 'field', type: 'nominal' };
+}
+
+/** Quantitative columns to repeat over + the channel to rewire, with fallbacks. */
+function defaultRepeat(spec: JsonObject): { fields: string[]; channel: string | null } {
+ const cols = boundColumns();
+ const numeric = cols
+ .filter((c) => defaultFieldType(c.type) === 'quantitative')
+ .map((c) => c.name);
+ const fields = numeric.length > 0 ? numeric.slice(0, 3) : cols.slice(0, 2).map((c) => c.name);
+ const encoding = isJsonObject(spec.encoding) ? spec.encoding : null;
+ const channel = encoding ? ('y' in encoding ? 'y' : (Object.keys(encoding)[0] ?? null)) : null;
+ return { fields: fields.length > 0 ? fields : ['field1', 'field2'], channel };
+}
+
+/** Apply a wrap of the given kind to the parsed spec. */
+function buildNext(spec: JsonObject, kind: WrapKind): JsonObject {
+ switch (kind) {
+ case 'layer':
+ return wrapInLayer(spec);
+ case 'hconcat':
+ return wrapInConcat(spec, 'h');
+ case 'vconcat':
+ return wrapInConcat(spec, 'v');
+ case 'facet': {
+ const { field, type } = defaultFacet();
+ return wrapInFacet(spec, field, type);
+ }
+ case 'repeat': {
+ const { fields, channel } = defaultRepeat(spec);
+ return wrapInRepeat(spec, fields, channel);
+ }
+ }
+}
+
+const WRAP_NOUN: Record = {
+ layer: 'a layer',
+ hconcat: 'a horizontal concat',
+ vconcat: 'a vertical concat',
+ facet: 'a facet',
+ repeat: 'a repeat',
+};
+
+/** Replace the scope as one undoable edit (the toolbar / palette path). */
+function writeBack(
+ editor: monaco.editor.IStandaloneCodeEditor,
+ range: monaco.Range,
+ text: string,
+): void {
+ editor.pushUndoStop();
+ editor.executeEdits('spec-transform', [{ range, text }]);
+ editor.pushUndoStop();
+ editor.focus();
+}
+
+/**
+ * The model, focused scope, and the spec parsed off it — the shared prologue of
+ * the scoped actions, or null (after toasting on invalid JSON) when there is
+ * nothing to act on.
+ */
+function resolveTarget(
+ editor: monaco.editor.IStandaloneCodeEditor,
+): { model: monaco.editor.ITextModel; scope: Scope; spec: JsonObject } | null {
+ const model = editor.getModel();
+ if (!model) return null;
+ const scope = resolveScope(model, editor.getSelection());
+ const spec = parseSpecObject(scope.text);
+ return spec ? { model, scope, spec } : null;
+}
+
+/** Wrap the focused view (selection, else whole document) in a composition. */
+export function runWrap(editor: monaco.editor.IStandaloneCodeEditor, kind: WrapKind): void {
+ const target = resolveTarget(editor);
+ if (!target) return;
+ const { model, scope, spec } = target;
+ writeBack(editor, scope.range, formatScoped(model, scope, buildNext(spec, kind)));
+ notify({
+ kind: 'success',
+ title: 'View wrapped',
+ message: `Wrapped in ${WRAP_NOUN[kind]}. Undo with ⌘/Ctrl+Z.`,
+ });
+}
+
+/** Collapse a single-child layer/concat in the focused scope back to a unit. */
+export function runUnwrap(editor: monaco.editor.IStandaloneCodeEditor): void {
+ const target = resolveTarget(editor);
+ if (!target) return;
+ const { model, scope, spec } = target;
+ const next = unwrapSingleton(spec);
+ if (!next) {
+ notify({
+ kind: 'info',
+ title: 'Nothing to simplify',
+ message: 'Select a layer or concat with a single child to collapse it.',
+ });
+ return;
+ }
+ writeBack(editor, scope.range, formatScoped(model, scope, next));
+ notify({
+ kind: 'success',
+ title: 'Composition simplified',
+ message: 'Collapsed the single-child composition. Undo with ⌘/Ctrl+Z.',
+ });
+}
+
+/** Append an empty view to the composition at `path` (the CodeLens affordance). */
+function runAddView(editor: monaco.editor.IStandaloneCodeEditor, path: SpecPath): void {
+ const model = editor.getModel();
+ if (!model) return;
+ const spec = parseSpecObject(model.getValue());
+ if (!spec) return;
+ const next = appendView(spec, path);
+ if (!next) {
+ notify({
+ kind: 'info',
+ title: 'Could not add a view',
+ message: 'The composition changed — try the affordance again.',
+ });
+ return;
+ }
+ // Whole-document edit (the change is structural and deep); reformatted in the
+ // app's compact style, which is idempotent on an already-formatted draft.
+ writeBack(editor, model.getFullModelRange(), formatScoped(model, wholeDocument(model), next));
+ notify({
+ kind: 'success',
+ title: 'View added',
+ message: `Added an empty view to the ${path[path.length - 1]}. Undo with ⌘/Ctrl+Z.`,
+ });
+}
+
+/**
+ * Register the "+ Add view" CodeLens over each composition array (per editor —
+ * the lens command needs the editor handle to apply the edit). Returns a
+ * disposable; dispose on unmount.
+ */
+export function installSpecTransformCodeLens(
+ editor: monaco.editor.IStandaloneCodeEditor,
+): monaco.IDisposable {
+ const addViewCommand = editor.addCommand(0, (_accessor, path: SpecPath) =>
+ runAddView(editor, path),
+ );
+ const provider = monaco.languages.registerCodeLensProvider('json', {
+ provideCodeLenses(model) {
+ if (useSnippetStore.getState().editorView !== 'draft') return { lenses: [], dispose() {} };
+ const lenses = compositionArrays(model.getValue()).map((array) => {
+ const { lineNumber } = model.getPositionAt(array.offset);
+ return {
+ range: new monaco.Range(lineNumber, 1, lineNumber, 1),
+ command: {
+ id: addViewCommand ?? '',
+ title: '$(add) Add view',
+ arguments: [array.path],
+ },
+ };
+ });
+ return { lenses, dispose() {} };
+ },
+ });
+ return { dispose: () => provider.dispose() };
+}
+
+/**
+ * Register the F1-palette actions on the editor (the keyboard accelerator).
+ * Returns a disposable; dispose on editor unmount, like the other per-editor
+ * installs. Deliberately no `contextMenuGroupId` — see the module header.
+ */
+export function installSpecTransformActions(
+ editor: monaco.editor.IStandaloneCodeEditor,
+): monaco.IDisposable {
+ const actions = [
+ editor.addAction({
+ id: 'astrolabe.wrap-layer',
+ label: 'Wrap View in a Layer',
+ precondition: '!editorReadonly',
+ run: () => runWrap(editor, 'layer'),
+ }),
+ editor.addAction({
+ id: 'astrolabe.wrap-hconcat',
+ label: 'Wrap View in Horizontal Concat',
+ precondition: '!editorReadonly',
+ run: () => runWrap(editor, 'hconcat'),
+ }),
+ editor.addAction({
+ id: 'astrolabe.wrap-vconcat',
+ label: 'Wrap View in Vertical Concat',
+ precondition: '!editorReadonly',
+ run: () => runWrap(editor, 'vconcat'),
+ }),
+ editor.addAction({
+ id: 'astrolabe.wrap-facet',
+ label: 'Wrap View in a Facet',
+ precondition: '!editorReadonly',
+ run: () => runWrap(editor, 'facet'),
+ }),
+ editor.addAction({
+ id: 'astrolabe.wrap-repeat',
+ label: 'Wrap View in a Repeat',
+ precondition: '!editorReadonly',
+ run: () => runWrap(editor, 'repeat'),
+ }),
+ editor.addAction({
+ id: 'astrolabe.unwrap',
+ label: 'Simplify Single-Child Composition',
+ precondition: '!editorReadonly',
+ run: () => runUnwrap(editor),
+ }),
+ ];
+ return {
+ dispose() {
+ for (const action of actions) action.dispose();
+ },
+ };
+}
+
+/**
+ * The lightbulb has no editor handle, so it returns a `WorkspaceEdit` instead of
+ * calling `executeEdits`; both paths build the replacement through `buildNext` +
+ * `formatScoped`.
+ */
+function editAction(
+ model: monaco.editor.ITextModel,
+ scope: Scope,
+ title: string,
+ next: JsonObject,
+): monaco.languages.CodeAction {
+ return {
+ title,
+ kind: 'refactor.rewrite',
+ edit: {
+ edits: [
+ {
+ resource: model.uri,
+ versionId: model.getVersionId(),
+ textEdit: { range: scope.range, text: formatScoped(model, scope, next) },
+ },
+ ],
+ },
+ };
+}
+
+let codeActionsRegistered = false;
+
+/**
+ * Register the wrap/simplify refactors as code actions (the lightbulb) once,
+ * globally for JSON — like the schema and formatter, not per editor. Idempotent.
+ */
+export function configureSpecTransformCodeActions(): void {
+ if (codeActionsRegistered) return;
+ codeActionsRegistered = true;
+
+ monaco.languages.registerCodeActionProvider('json', {
+ provideCodeActions(model, range) {
+ const empty = { actions: [], dispose() {} };
+ // Global provider, no editor handle: gate on the store the way the run*
+ // path is gated by `!editorReadonly` — only on the active snippet's draft.
+ const snippet = useSnippetStore.getState();
+ if (snippet.activeSnippetId === null || snippet.editorView !== 'draft') return empty;
+
+ const scope = resolveScope(model, range);
+ let spec: unknown;
+ try {
+ spec = JSON.parse(scope.text);
+ } catch {
+ return empty;
+ }
+ if (!isJsonObject(spec)) return empty;
+
+ const actions: monaco.languages.CodeAction[] = [
+ editAction(model, scope, 'Wrap view in a layer', buildNext(spec, 'layer')),
+ editAction(model, scope, 'Wrap view in horizontal concat', buildNext(spec, 'hconcat')),
+ editAction(model, scope, 'Wrap view in vertical concat', buildNext(spec, 'vconcat')),
+ editAction(model, scope, 'Wrap view in a facet', buildNext(spec, 'facet')),
+ editAction(model, scope, 'Wrap view in a repeat', buildNext(spec, 'repeat')),
+ ];
+ const collapsed = unwrapSingleton(spec);
+ if (collapsed)
+ actions.push(editAction(model, scope, 'Simplify single-child composition', collapsed));
+
+ return { actions, dispose() {} };
+ },
+ });
+}
diff --git a/src/core/spec-cursor.test.ts b/src/core/spec-cursor.test.ts
new file mode 100644
index 0000000..5fb92d5
--- /dev/null
+++ b/src/core/spec-cursor.test.ts
@@ -0,0 +1,107 @@
+import { describe, expect, it } from 'vitest';
+import { findViewRange, valueKeyAtOffset } from './spec-cursor';
+
+/** Parse the slice a range points at, for asserting which view was targeted. */
+function sliceObject(text: string, range: { offset: number; length: number }) {
+ return JSON.parse(text.slice(range.offset, range.offset + range.length)) as Record<
+ string,
+ unknown
+ >;
+}
+
+const layered = JSON.stringify(
+ {
+ $schema: 'https://vega.github.io/schema/vega-lite/v6.json',
+ description: 'A simple bar chart.',
+ data: { values: [{ category: 'A', value: 28 }] },
+ layer: [
+ {
+ name: 'outer1',
+ mark: 'bar',
+ encoding: {
+ x: { field: 'category', type: 'nominal' },
+ y: { field: 'value', type: 'quantitative' },
+ },
+ },
+ { mark: 'point', encoding: {}, name: 'outer2' },
+ ],
+ },
+ null,
+ 2,
+);
+
+describe('findViewRange', () => {
+ it('targets the layer element the cursor sits in, not the whole doc', () => {
+ const at = layered.indexOf('"outer2"'); // cursor inside the second layer element
+ const range = findViewRange(layered, at)!;
+ expect(range).not.toBeNull();
+ expect(sliceObject(layered, range).name).toBe('outer2');
+ });
+
+ it('climbs out of a deeply nested property to the enclosing view', () => {
+ const at = layered.indexOf('"nominal"'); // deep inside outer1's x encoding
+ const range = findViewRange(layered, at)!;
+ expect(sliceObject(layered, range).name).toBe('outer1');
+ });
+
+ it('returns null at a top-level key (caller wraps the whole document)', () => {
+ const at = layered.indexOf('"description"');
+ expect(findViewRange(layered, at)).toBeNull();
+ });
+
+ it('returns null for a flat unit spec (no inner view)', () => {
+ const unit = JSON.stringify(
+ { mark: 'bar', encoding: { x: { field: 'a', type: 'nominal' } } },
+ null,
+ 2,
+ );
+ expect(findViewRange(unit, unit.indexOf('"field"'))).toBeNull();
+ });
+
+ it('targets the facet/repeat child spec', () => {
+ const faceted = JSON.stringify(
+ { facet: { field: 'g', type: 'nominal' }, spec: { mark: 'bar', encoding: {} } },
+ null,
+ 2,
+ );
+ const range = findViewRange(faceted, faceted.indexOf('"bar"'))!;
+ expect(sliceObject(faceted, range).mark).toBe('bar');
+ });
+
+ it('still resolves a complete inner view while the outer doc is mid-edit', () => {
+ // Trailing junk makes the whole document unparseable; the inner object is intact.
+ const broken = layered.replace(/}\s*$/, '} ,');
+ const at = broken.indexOf('"outer2"');
+ const range = findViewRange(broken, at)!;
+ expect(sliceObject(broken, range).name).toBe('outer2');
+ });
+});
+
+describe('valueKeyAtOffset', () => {
+ const spec = JSON.stringify(
+ { mark: 'bar', encoding: { x: { field: 'category', type: 'nominal' } }, transform: [] },
+ null,
+ 2,
+ );
+
+ it('reports the property whose value the cursor is in', () => {
+ // inside the "category" value of "field": "category"
+ const at = spec.indexOf('category') + 2;
+ expect(valueKeyAtOffset(spec, at)).toBe('field');
+ });
+
+ it('reports the array key for an element position', () => {
+ const grouped = JSON.stringify(
+ { transform: [{ aggregate: [], groupby: ['a', 'b'] }] },
+ null,
+ 2,
+ );
+ const at = grouped.indexOf('"b"') + 1;
+ expect(valueKeyAtOffset(grouped, at)).toBe('groupby');
+ });
+
+ it('is null on a property key, not its value', () => {
+ const at = spec.indexOf('"field"') + 1; // on the key itself
+ expect(valueKeyAtOffset(spec, at)).toBe(null);
+ });
+});
diff --git a/src/core/spec-cursor.ts b/src/core/spec-cursor.ts
new file mode 100644
index 0000000..7dae7f1
--- /dev/null
+++ b/src/core/spec-cursor.ts
@@ -0,0 +1,95 @@
+/**
+ * Cursor → enclosing-view mapping (docs/architecture/08 → editor augmentation).
+ *
+ * Portable core: no browser APIs, no React, no Monaco — just `jsonc-parser`'s
+ * error-tolerant scanner (so this keeps working while the draft is briefly
+ * unparseable mid-edit). Given the spec text and a cursor offset, it finds the
+ * range of the *view* the cursor sits in, so a structural transform can act on
+ * "the element I'm focused on" rather than the whole document.
+ *
+ * A **view** is a spec object you can meaningfully wrap in a composition: an
+ * element of a `layer`/`hconcat`/`vconcat`/`concat` array, or the `spec` child of
+ * a facet/repeat. Encoding blocks, `data`, a single `field` — the other objects
+ * the cursor might land in — are not views and are skipped; the walk climbs to
+ * the nearest enclosing one. When the cursor is in a top-level unit spec (no
+ * nesting), there is no inner view and the result is null — the caller falls back
+ * to the whole document, which is the right target for a flat spec.
+ *
+ * Only the byte range is returned; turning it into a Monaco range is the editor
+ * integration's job (app/services/spec-transform-actions).
+ */
+
+import { findNodeAtOffset, getLocation, parseTree, type Node } from 'jsonc-parser';
+import { ARRAY_COMPOSITIONS } from './spec-transforms';
+
+/** A byte range into the spec text. */
+interface NodeRange {
+ offset: number;
+ length: number;
+}
+
+/** The property key a node sits under, when it is a property's value. */
+function propertyKey(node: Node | undefined): string | undefined {
+ if (node?.type === 'property') {
+ const key = node.children?.[0];
+ return typeof key?.value === 'string' ? key.value : undefined;
+ }
+ return undefined;
+}
+
+/**
+ * Is this object node a view — an element of a composition array, or a facet /
+ * repeat `spec` child? (The object's parent is the array/property that frames it.)
+ */
+function isViewObject(node: Node): boolean {
+ const parent = node.parent;
+ if (!parent) return false; // the root object: not an *inner* view
+ if (parent.type === 'property') return propertyKey(parent) === 'spec';
+ if (parent.type === 'array') {
+ const key = propertyKey(parent.parent);
+ return key !== undefined && ARRAY_COMPOSITIONS.includes(key);
+ }
+ return false;
+}
+
+/**
+ * The range of the nested view enclosing `offset`, or null when the cursor is not
+ * inside one (a flat unit spec, or whitespace between top-level keys) — the
+ * caller then targets the whole document.
+ */
+export function findViewRange(text: string, offset: number): NodeRange | null {
+ const tree = parseTree(text);
+ if (!tree) return null;
+ let node: Node | undefined = findNodeAtOffset(tree, offset);
+ while (node) {
+ if (node.type === 'object' && isViewObject(node)) {
+ return { offset: node.offset, length: node.length };
+ }
+ node = node.parent;
+ }
+ return null;
+}
+
+/**
+ * The property key whose *value* the cursor sits in, or null when the cursor is
+ * on a key, at the top level, or otherwise not in a value. For an array element
+ * the key is the array's property (so a `groupby: ["a", "b|"]` element reports
+ * `groupby`). Drives the field-position field hints — "am I completing a
+ * `field`?". Error-tolerant, so it works mid-edit.
+ */
+export function valueKeyAtOffset(text: string, offset: number): string | null {
+ const { path, isAtPropertyKey } = getLocation(text, offset);
+ if (isAtPropertyKey || path.length === 0) return null;
+ const last = path[path.length - 1];
+ // An array element reports the array's own key (e.g. groupby[i] → "groupby").
+ const key = typeof last === 'number' ? path[path.length - 2] : last;
+ return typeof key === 'string' ? key : null;
+}
+
+/** The string literal the cursor is inside, or null when it is not on a string. */
+export function stringValueAtOffset(text: string, offset: number): string | null {
+ const tree = parseTree(text);
+ if (!tree) return null;
+ const node = findNodeAtOffset(tree, offset);
+ return node?.type === 'string' && typeof node.value === 'string' ? node.value : null;
+}
diff --git a/src/core/spec-fields.test.ts b/src/core/spec-fields.test.ts
new file mode 100644
index 0000000..d8fd94c
--- /dev/null
+++ b/src/core/spec-fields.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from 'vitest';
+import { derivedFieldNames } from './spec-fields';
+
+describe('derivedFieldNames', () => {
+ it('collects calculate / timeUnit / bin as-names', () => {
+ const spec = {
+ transform: [
+ { calculate: 'datum.a + 1', as: 'plusOne' },
+ { timeUnit: 'month', field: 'date', as: 'mo' },
+ { bin: true, field: 'x', as: ['x_start', 'x_end'] },
+ ],
+ };
+ expect(derivedFieldNames(spec).sort()).toEqual(['mo', 'plusOne', 'x_end', 'x_start']);
+ });
+
+ it('reaches into op-list transforms (aggregate/window/joinaggregate)', () => {
+ const spec = {
+ transform: [
+ { aggregate: [{ op: 'mean', field: 'v', as: 'meanV' }], groupby: ['g'] },
+ { window: [{ op: 'rank', as: 'rk' }] },
+ { joinaggregate: [{ op: 'sum', field: 'v', as: 'total' }] },
+ ],
+ };
+ expect(derivedFieldNames(spec).sort()).toEqual(['meanV', 'rk', 'total']);
+ });
+
+ it('defaults fold output to key/value when as is omitted', () => {
+ expect(derivedFieldNames({ transform: [{ fold: ['a', 'b'] }] }).sort()).toEqual([
+ 'key',
+ 'value',
+ ]);
+ expect(derivedFieldNames({ transform: [{ fold: ['a'], as: ['k', 'v'] }] }).sort()).toEqual([
+ 'k',
+ 'v',
+ ]);
+ });
+
+ it('recurses into per-view transforms and de-duplicates', () => {
+ const spec = {
+ transform: [{ calculate: 'x', as: 'shared' }],
+ layer: [
+ { transform: [{ calculate: 'y', as: 'inner' }], mark: 'bar' },
+ { transform: [{ calculate: 'z', as: 'shared' }], mark: 'line' },
+ ],
+ };
+ expect(derivedFieldNames(spec).sort()).toEqual(['inner', 'shared']);
+ });
+
+ it('returns nothing for a spec with no transforms', () => {
+ expect(derivedFieldNames({ mark: 'bar', encoding: { x: { field: 'a' } } })).toEqual([]);
+ });
+});
diff --git a/src/core/spec-fields.ts b/src/core/spec-fields.ts
new file mode 100644
index 0000000..94beef1
--- /dev/null
+++ b/src/core/spec-fields.ts
@@ -0,0 +1,68 @@
+/**
+ * Fields a spec introduces through its own transforms (docs/architecture/08 →
+ * editor augmentation).
+ *
+ * Portable core: pure spec analysis, no data. The editor's field hints
+ * (completion/hover/inlay) suggest the bound dataset's source columns *plus* the
+ * fields the spec derives — so a column you just created with a `calculate` is
+ * offered too. This collects the statically-named ones: every transform `as`
+ * (`calculate`, `timeUnit`, `bin`, `stack`, `fold`, `flatten`, `regression`, …)
+ * and the nested `as` of the op-list transforms (`aggregate`, `window`,
+ * `joinaggregate`). Transforms can sit at the top level or inside any view, so
+ * the walk recurses the whole spec.
+ *
+ * Out of scope by design: **data-dependent** derived columns — `pivot`'s
+ * one-column-per-value output and `lookup`'s imported fields — which only exist
+ * once the pipeline runs over the actual data. Naming those would mean executing
+ * transforms, far beyond a static hint.
+ */
+
+/** Add an `as` value (a string, or the `[start, end]` / key-value pair array). */
+function addAs(names: Set, as: unknown): void {
+ if (typeof as === 'string') names.add(as);
+ else if (Array.isArray(as)) for (const a of as) if (typeof a === 'string') names.add(a);
+}
+
+/** Collect the field name(s) a single transform entry introduces. */
+function collectFromTransform(transform: unknown, names: Set): void {
+ if (transform === null || typeof transform !== 'object') return;
+ const t = transform as Record;
+ addAs(names, t.as);
+ // `fold` defaults its output to ['key', 'value'] when `as` is omitted.
+ if ('fold' in t && !('as' in t)) {
+ names.add('key');
+ names.add('value');
+ }
+ // Op-list transforms nest their `as` inside each operation.
+ for (const key of ['aggregate', 'window', 'joinaggregate']) {
+ const ops = t[key];
+ if (Array.isArray(ops)) {
+ for (const op of ops) {
+ if (op !== null && typeof op === 'object') addAs(names, (op as Record).as);
+ }
+ }
+ }
+}
+
+/**
+ * Every field name the spec derives via its transforms, de-duplicated. Accepts a
+ * parsed spec object (callers parse the draft once).
+ */
+export function derivedFieldNames(spec: unknown): string[] {
+ const names = new Set();
+ const walk = (node: unknown): void => {
+ if (Array.isArray(node)) {
+ for (const item of node) walk(item);
+ return;
+ }
+ if (node !== null && typeof node === 'object') {
+ const obj = node as Record;
+ if (Array.isArray(obj.transform)) {
+ for (const t of obj.transform) collectFromTransform(t, names);
+ }
+ for (const key of Object.keys(obj)) walk(obj[key]);
+ }
+ };
+ walk(spec);
+ return [...names];
+}
diff --git a/src/core/spec-inline-data.test.ts b/src/core/spec-inline-data.test.ts
new file mode 100644
index 0000000..19d799c
--- /dev/null
+++ b/src/core/spec-inline-data.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest';
+import { inlineDataRows } from './spec-inline-data';
+
+describe('inlineDataRows', () => {
+ it('reads top-level data.values', () => {
+ const rows = inlineDataRows({ data: { values: [{ a: 1 }, { a: 2 }] }, mark: 'bar' });
+ expect(rows).toEqual([{ a: 1 }, { a: 2 }]);
+ });
+
+ it('falls back to a nested data.values when there is no top-level one', () => {
+ const spec = { layer: [{ data: { values: [{ b: 1 }] }, mark: 'bar' }] };
+ expect(inlineDataRows(spec)).toEqual([{ b: 1 }]);
+ });
+
+ it('prefers top-level over nested', () => {
+ const spec = {
+ data: { values: [{ top: 1 }] },
+ layer: [{ data: { values: [{ nested: 1 }] } }],
+ };
+ expect(inlineDataRows(spec)).toEqual([{ top: 1 }]);
+ });
+
+ it('reads a top-level datasets entry when no data.values exist', () => {
+ const spec = { datasets: { ds: [{ c: 1 }] }, data: { name: 'ds' } };
+ expect(inlineDataRows(spec)).toEqual([{ c: 1 }]);
+ });
+
+ it('returns null for URL data, empty values, or non-object rows', () => {
+ expect(inlineDataRows({ data: { url: 'x.csv' } })).toBeNull();
+ expect(inlineDataRows({ data: { values: [] } })).toBeNull();
+ expect(inlineDataRows({ data: { values: [1, 2, 3] } })).toBeNull();
+ expect(inlineDataRows({ mark: 'bar' })).toBeNull();
+ });
+});
diff --git a/src/core/spec-inline-data.ts b/src/core/spec-inline-data.ts
new file mode 100644
index 0000000..77d0116
--- /dev/null
+++ b/src/core/spec-inline-data.ts
@@ -0,0 +1,70 @@
+/**
+ * Inline data carried by a spec (docs/architecture/08 → editor augmentation).
+ *
+ * Portable core. When a spec has no named library dataset, its columns still
+ * exist — inline, in `data.values` or a top-level `datasets` entry. This pulls
+ * those rows out so the editor can profile them (core/profile) and offer the same
+ * field hints a library-bound spec gets — a "ghost dataset" derived from the spec
+ * itself, with nothing stored.
+ *
+ * Resolution order mirrors what the renderer sees first: the nearest `data.values`
+ * (top level, then any nested view — pruning the `data` payload from the walk like
+ * spec-refs does), then a top-level `datasets` entry. URL data has no rows to read
+ * statically, and `values` given as a CSV/TSV string needs format-aware parsing —
+ * both out of scope here.
+ */
+
+import { isJsonObject } from './spec-config';
+
+/** An array of row objects, or null when the value is not tabular inline data. */
+function asRows(value: unknown): Record[] | null {
+ if (Array.isArray(value) && value.length > 0 && value.every(isJsonObject)) {
+ return value;
+ }
+ return null;
+}
+
+/** Rows from a `data` object's `values`, or null. */
+function rowsFromData(data: unknown): Record[] | null {
+ return isJsonObject(data) ? asRows(data.values) : null;
+}
+
+/** The first `data.values` reachable from `node`, top level before nested. */
+function firstDataValues(node: unknown): Record[] | null {
+ if (Array.isArray(node)) {
+ for (const item of node) {
+ const rows = firstDataValues(item);
+ if (rows) return rows;
+ }
+ return null;
+ }
+ if (isJsonObject(node)) {
+ const here = rowsFromData(node.data);
+ if (here) return here;
+ for (const key of Object.keys(node)) {
+ if (key === 'data') continue; // its `values` are payload, already taken above
+ const rows = firstDataValues(node[key]);
+ if (rows) return rows;
+ }
+ }
+ return null;
+}
+
+/** The first non-empty table among the spec's top-level `datasets`, or null. */
+function firstNamedDataset(spec: Record): Record[] | null {
+ if (!isJsonObject(spec.datasets)) return null;
+ for (const value of Object.values(spec.datasets)) {
+ const rows = asRows(value);
+ if (rows) return rows;
+ }
+ return null;
+}
+
+/**
+ * The spec's inline data rows — `data.values` (top level or nested), else a
+ * top-level `datasets` entry — or null when the spec carries none.
+ */
+export function inlineDataRows(spec: unknown): Record[] | null {
+ if (!isJsonObject(spec)) return null;
+ return firstDataValues(spec) ?? firstNamedDataset(spec);
+}
diff --git a/src/core/spec-insert.test.ts b/src/core/spec-insert.test.ts
new file mode 100644
index 0000000..98ef76d
--- /dev/null
+++ b/src/core/spec-insert.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest';
+import { appendView, compositionArrays } from './spec-insert';
+
+const layered = JSON.stringify(
+ {
+ data: { name: 'd' },
+ hconcat: [{ layer: [{ mark: 'bar' }] }, { mark: 'point' }],
+ },
+ null,
+ 2,
+);
+
+describe('compositionArrays', () => {
+ it('finds every composition array with its path, including nested', () => {
+ const found = compositionArrays(layered);
+ const byKey = found.map((c) => ({ key: c.key, path: c.path }));
+ expect(byKey).toContainEqual({ key: 'hconcat', path: ['hconcat'] });
+ expect(byKey).toContainEqual({ key: 'layer', path: ['hconcat', 0, 'layer'] });
+ });
+
+ it('returns offsets that point inside the source text', () => {
+ const [first] = compositionArrays(layered);
+ expect(layered[first.offset]).toBe('['); // the array node starts at its bracket
+ });
+
+ it('is empty for a flat unit spec', () => {
+ expect(compositionArrays(JSON.stringify({ mark: 'bar' }))).toEqual([]);
+ });
+});
+
+describe('appendView', () => {
+ it('adds a placeholder view to the array at the given path', () => {
+ const spec = { hconcat: [{ mark: 'bar' }] };
+ const out = appendView(spec, ['hconcat'])!;
+ expect(out.hconcat).toEqual([{ mark: 'bar' }, { mark: 'point', encoding: {} }]);
+ expect(spec.hconcat).toHaveLength(1); // input untouched
+ });
+
+ it('reaches a nested composition array', () => {
+ const spec = { hconcat: [{ layer: [{ mark: 'bar' }] }] };
+ const out = appendView(spec, ['hconcat', 0, 'layer'])!;
+ const layer = (out.hconcat as { layer: unknown[] }[])[0].layer;
+ expect(layer).toHaveLength(2);
+ });
+
+ it('returns null when the path is not an array', () => {
+ expect(appendView({ mark: 'bar' }, ['layer'])).toBeNull();
+ });
+});
diff --git a/src/core/spec-insert.ts b/src/core/spec-insert.ts
new file mode 100644
index 0000000..4b54b67
--- /dev/null
+++ b/src/core/spec-insert.ts
@@ -0,0 +1,72 @@
+/**
+ * Locating composition arrays and appending a view to one (docs/architecture/08 →
+ * editor augmentation). Powers the "+ Add view" CodeLens: an always-visible
+ * affordance over each `layer`/`hconcat`/`vconcat`/`concat` for adding a sibling
+ * view — the complement to the wrap refactors (which create a composition; this
+ * grows an existing one).
+ *
+ * Portable core. `compositionArrays` uses jsonc-parser to find each array and its
+ * path (so the CodeLens knows where to sit and what to grow); `appendView` is a
+ * plain immutable push at that path. JSON-text formatting is the editor's job.
+ */
+
+import { parseTree, type Node } from 'jsonc-parser';
+import { isJsonObject, type JsonObject } from './spec-config';
+import { ARRAY_COMPOSITIONS, placeholderView } from './spec-transforms';
+
+/** A spec path: object keys and array indices, from the root. */
+export type SpecPath = (string | number)[];
+
+/** A composition array found in the spec text. */
+interface CompositionArray {
+ /** The operator key (`layer`, `hconcat`, …). */
+ key: string;
+ /** Start offset of the array node, for placing the affordance. */
+ offset: number;
+ /** Path to the array, for `appendView`. */
+ path: SpecPath;
+}
+
+/** Every composition array in the spec, with its path and start offset. */
+export function compositionArrays(text: string): CompositionArray[] {
+ const tree = parseTree(text);
+ if (!tree) return [];
+ const found: CompositionArray[] = [];
+
+ const walk = (node: Node, path: SpecPath): void => {
+ if (node.type === 'object') {
+ for (const prop of node.children ?? []) {
+ const key: unknown = prop.children?.[0]?.value;
+ const value = prop.children?.[1];
+ if (typeof key !== 'string' || !value) continue;
+ if (ARRAY_COMPOSITIONS.includes(key) && value.type === 'array') {
+ found.push({ key, offset: value.offset, path: [...path, key] });
+ }
+ walk(value, [...path, key]);
+ }
+ } else if (node.type === 'array') {
+ (node.children ?? []).forEach((child, i) => walk(child, [...path, i]));
+ }
+ };
+
+ walk(tree, []);
+ return found;
+}
+
+/**
+ * Append an empty placeholder view to the array at `path`. Returns a new spec, or
+ * null when the path does not lead to an array (the text changed since the path
+ * was computed). Input is not mutated.
+ */
+export function appendView(spec: JsonObject, path: SpecPath): JsonObject | null {
+ const next = JSON.parse(JSON.stringify(spec)) as JsonObject;
+ let node: unknown = next;
+ for (const segment of path) {
+ if (Array.isArray(node)) node = node[segment as number];
+ else if (isJsonObject(node)) node = node[segment as string];
+ else return null;
+ }
+ if (!Array.isArray(node)) return null;
+ node.push(placeholderView());
+ return next;
+}
diff --git a/src/core/spec-transforms.test.ts b/src/core/spec-transforms.test.ts
new file mode 100644
index 0000000..96512ad
--- /dev/null
+++ b/src/core/spec-transforms.test.ts
@@ -0,0 +1,127 @@
+import { describe, expect, it } from 'vitest';
+import {
+ unwrapSingleton,
+ wrapInConcat,
+ wrapInFacet,
+ wrapInLayer,
+ wrapInRepeat,
+} from './spec-transforms';
+
+const unit = () => ({
+ $schema: 'https://vega.github.io/schema/vega-lite/v6.json',
+ data: { name: 'weather' },
+ title: 'Rainfall',
+ width: 400,
+ height: 300,
+ mark: 'bar',
+ encoding: {
+ x: { field: 'date', type: 'temporal' },
+ y: { field: 'precipitation', type: 'quantitative' },
+ },
+});
+
+describe('wrapInLayer', () => {
+ it('moves the view into layer[0] and adds an empty layer', () => {
+ const out = wrapInLayer(unit());
+ expect(Array.isArray(out.layer)).toBe(true);
+ const layers = out.layer as Record[];
+ expect(layers).toHaveLength(2);
+ expect(layers[0].mark).toBe('bar');
+ expect(layers[0].encoding).toBeDefined();
+ expect(layers[1]).toEqual({ mark: 'point', encoding: {} });
+ });
+
+ it('keeps data, title, and the shared plotting size on the wrapper', () => {
+ const out = wrapInLayer(unit());
+ expect(out.data).toEqual({ name: 'weather' });
+ expect(out.title).toBe('Rainfall');
+ expect(out.width).toBe(400); // layers share one plotting area
+ expect(out.height).toBe(300);
+ const layers = out.layer as Record[];
+ expect(layers[0]).not.toHaveProperty('width');
+ });
+
+ it('does not mutate the input', () => {
+ const spec = unit();
+ wrapInLayer(spec);
+ expect(spec).not.toHaveProperty('layer');
+ expect(spec.mark).toBe('bar');
+ });
+});
+
+describe('wrapInConcat', () => {
+ it('wraps into hconcat/vconcat with an empty sibling view', () => {
+ expect(wrapInConcat(unit(), 'h')).toHaveProperty('hconcat');
+ const out = wrapInConcat(unit(), 'v');
+ const views = out.vconcat as Record[];
+ expect(views).toHaveLength(2);
+ expect(views[1]).toEqual({ mark: 'point', encoding: {} });
+ });
+
+ it('pushes the size down to each concat view (they size independently)', () => {
+ const out = wrapInConcat(unit(), 'h');
+ expect(out).not.toHaveProperty('width');
+ const views = out.hconcat as Record[];
+ expect(views[0].width).toBe(400);
+ expect(out.data).toEqual({ name: 'weather' }); // data still shared on top
+ });
+});
+
+describe('wrapInFacet', () => {
+ it('lifts data to the wrapper and holds the view as spec', () => {
+ const out = wrapInFacet(unit(), 'weather', 'nominal');
+ expect(out.facet).toEqual({ field: 'weather', type: 'nominal' });
+ expect(out.data).toEqual({ name: 'weather' });
+ const child = out.spec as Record;
+ expect(child.mark).toBe('bar');
+ expect(child).not.toHaveProperty('data');
+ });
+});
+
+describe('wrapInRepeat', () => {
+ it('rewires the named channel to the repeat reference', () => {
+ const out = wrapInRepeat(unit(), ['precipitation', 'wind'], 'y');
+ expect(out.repeat).toEqual(['precipitation', 'wind']);
+ const child = out.spec as { encoding: Record> };
+ expect(child.encoding.y.field).toEqual({ repeat: 'repeat' });
+ expect(child.encoding.x.field).toBe('date'); // other channels untouched
+ });
+
+ it('wraps as-is when the channel is absent or unspecified', () => {
+ const out = wrapInRepeat(unit(), ['a', 'b'], null);
+ const child = out.spec as { encoding: Record> };
+ expect(child.encoding.y.field).toBe('precipitation');
+ });
+});
+
+describe('unwrapSingleton', () => {
+ it('collapses only a single-child array composition', () => {
+ expect(unwrapSingleton({ layer: [{ mark: 'bar' }] })).toEqual({ mark: 'bar' });
+ expect(unwrapSingleton({ hconcat: [{ mark: 'bar' }] })).toEqual({ mark: 'bar' });
+ expect(unwrapSingleton({ layer: [{ mark: 'bar' }, { mark: 'line' }] })).toBeNull();
+ expect(unwrapSingleton(unit())).toBeNull();
+ });
+
+ it('collapses the lone child up, child keys winning', () => {
+ const wrapped = {
+ data: { name: 'weather' },
+ layer: [{ data: { name: 'other' }, mark: 'bar' }],
+ };
+ const out = unwrapSingleton(wrapped);
+ expect(out).toEqual({ data: { name: 'other' }, mark: 'bar' });
+ expect(out).not.toHaveProperty('layer');
+ });
+
+ it('round-trips a freshly wrapped-then-trimmed layer back to the unit', () => {
+ const wrapped = wrapInLayer(unit());
+ (wrapped.layer as unknown[]).pop(); // user deletes the placeholder layer
+ const out = unwrapSingleton(wrapped)!;
+ expect(out.mark).toBe('bar');
+ expect(out.width).toBe(400);
+ expect(out).not.toHaveProperty('layer');
+ });
+
+ it('returns null when there is nothing to collapse', () => {
+ expect(unwrapSingleton(unit())).toBeNull();
+ });
+});
diff --git a/src/core/spec-transforms.ts b/src/core/spec-transforms.ts
new file mode 100644
index 0000000..732c5f3
--- /dev/null
+++ b/src/core/spec-transforms.ts
@@ -0,0 +1,145 @@
+/**
+ * Structural spec transforms (docs/architecture/08 → editor augmentation).
+ *
+ * Pure object-in/object-out rewrites that wrap a Vega-Lite view in a composition
+ * operator — layer, hconcat, vconcat, facet, repeat — or collapse a single-child
+ * composition back to a unit. These are the structural edits that are awkward to
+ * make by hand in JSON and out of reach of the visual builder (which is
+ * single-view); the editor surfaces them as refactor actions
+ * (app/services/spec-transform-actions). JSON text handling (parse, format, undo)
+ * is the editor integration's job, exactly as for spec-config.
+ *
+ * Each wrap partitions the source spec's keys: the *shared* top-level keys (data,
+ * $schema, title, config, …) stay on the wrapper, and the *view* keys (mark,
+ * encoding, …) move into the new child. The partition differs per operator —
+ * layered children share the plotting area, so width/height/view stay on top;
+ * concat children are independent specs that size themselves, so those move down;
+ * facet/repeat lift the data to the wrapper and hold a single child `spec`. The
+ * result is the user's compact authored form, never vega-lite's normalized
+ * expansion.
+ */
+
+import { isJsonObject, type JsonObject } from './spec-config';
+
+/** Concat direction: horizontal or vertical. */
+type ConcatDir = 'h' | 'v';
+
+/**
+ * The array-valued composition operators — the single source of this domain fact,
+ * shared with the cursor (`spec-cursor`) and insertion (`spec-insert`) walks.
+ */
+export const ARRAY_COMPOSITIONS: readonly string[] = ['layer', 'hconcat', 'vconcat', 'concat'];
+
+/**
+ * Keys that belong to the wrapper for every operator: pure top-level metadata
+ * plus the shared data source and styling. `data` stays on top because layered,
+ * concatenated, faceted, and repeated children all inherit the parent's data.
+ */
+const SHARED_TOP = [
+ '$schema',
+ 'name',
+ 'description',
+ 'title',
+ 'config',
+ 'usermeta',
+ 'background',
+ 'padding',
+ 'autosize',
+ 'datasets',
+ 'data',
+ 'resolve',
+ 'params',
+];
+
+/** Layers share one plotting area, so the size/view also stay on the wrapper. */
+const LAYER_TOP = [...SHARED_TOP, 'width', 'height', 'view'];
+
+/** A fresh placeholder view for the empty slot a wrap (or add-view) opens up. */
+export const placeholderView = (): JsonObject => ({ mark: 'point', encoding: {} });
+
+/** Split a spec's keys into those that stay on the wrapper and the rest. */
+function partition(
+ spec: JsonObject,
+ topKeys: readonly string[],
+): { top: JsonObject; view: JsonObject } {
+ const topSet = new Set(topKeys);
+ const top: JsonObject = {};
+ const view: JsonObject = {};
+ for (const key of Object.keys(spec)) {
+ if (topSet.has(key)) top[key] = spec[key];
+ else view[key] = spec[key];
+ }
+ return { top, view };
+}
+
+/** Wrap the view in a two-layer composition (the view plus an empty layer). */
+export function wrapInLayer(spec: JsonObject): JsonObject {
+ const { top, view } = partition(spec, LAYER_TOP);
+ return { ...top, layer: [view, placeholderView()] };
+}
+
+/** Wrap the view in a horizontal or vertical concat (the view plus an empty view). */
+export function wrapInConcat(spec: JsonObject, dir: ConcatDir): JsonObject {
+ const key = dir === 'h' ? 'hconcat' : 'vconcat';
+ const { top, view } = partition(spec, SHARED_TOP);
+ return { ...top, [key]: [view, placeholderView()] };
+}
+
+/**
+ * Wrap the view in a facet (small multiples across `field`). The caller resolves
+ * a sensible field + type from the bound dataset; the field is left editable.
+ */
+export function wrapInFacet(spec: JsonObject, field: string, type: string): JsonObject {
+ const { top, view } = partition(spec, SHARED_TOP);
+ return { ...top, facet: { field, type }, spec: view };
+}
+
+/**
+ * Wrap the view in a repeat (the same chart across `fields`). When `channel` is
+ * given and the view encodes it, that channel's field is rewired to
+ * `{ repeat: 'repeat' }` so the repeat actually varies the chart; otherwise the
+ * view is wrapped as-is for the caller to wire up.
+ */
+export function wrapInRepeat(
+ spec: JsonObject,
+ fields: string[],
+ channel: string | null,
+): JsonObject {
+ const { top, view } = partition(spec, SHARED_TOP);
+ if (channel && isJsonObject(view.encoding)) {
+ const encoding = { ...view.encoding };
+ if (isJsonObject(encoding[channel])) {
+ encoding[channel] = { ...encoding[channel], field: { repeat: 'repeat' } };
+ view.encoding = encoding;
+ }
+ }
+ return { ...top, repeat: fields, spec: view };
+}
+
+/**
+ * The single array-composition key that holds exactly one child, or null — the
+ * gate for `unwrapSingleton`: a `layer`/`concat` whittled down to one member is
+ * the redundant structure worth collapsing.
+ */
+function unwrappableKey(spec: JsonObject): string | null {
+ for (const key of ARRAY_COMPOSITIONS) {
+ const value = spec[key];
+ if (Array.isArray(value) && value.length === 1 && isJsonObject(value[0])) return key;
+ }
+ return null;
+}
+
+/**
+ * Collapse a single-child `layer`/`concat` back into a unit: the lone child is
+ * merged up onto the wrapper, the child's keys winning on conflict (it is the
+ * more specific view). Returns null when there is no single-child composition to
+ * collapse — the inverse of the wraps above.
+ */
+export function unwrapSingleton(spec: JsonObject): JsonObject | null {
+ const key = unwrappableKey(spec);
+ if (!key) return null;
+ const child = (spec[key] as JsonObject[])[0];
+ const rest: JsonObject = {};
+ for (const k of Object.keys(spec)) if (k !== key) rest[k] = spec[k];
+ return { ...rest, ...child };
+}