Editor: composition structure wireframe (read-only)

This commit is contained in:
2026-06-29 12:39:59 +03:00
parent 345ecef5a3
commit 57604a80a6
13 changed files with 774 additions and 17 deletions
@@ -299,6 +299,11 @@ thin app-layer services.
- `spec-insert` — the composition the cursor is in (`compositionTargetAt`), inserting a view
at an index (`insertView`) and reordering siblings (`moveView`), plus `elementOffset` to
re-find a view after the edit.
- `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 read-only 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`. Interaction contract in [arch 10](10-interaction-and-feedback.md).
**Services (app, store-aware via `getState`):** `spec-transform-actions` (the
wrap/simplify/add-view operations and their surfaces), `spec-dataset-hints` (completion,
+18 -16
View File
@@ -328,21 +328,22 @@ Carbon and drawn `fill: currentColor`.
**Core set** — recurring, cross-surface:
| Meaning | Carbon glyph | Form | Surfaces |
| -------------------- | --------------- | ------------------------ | -------------------------------------------------------------------------------------------- |
| Close / dismiss | `Close` (✕) | icon-only ⭐ | `ModalShell`, `Toaster` |
| Theme → dark | `Asleep` (moon) | icon-only ⭐ | `ThemeToggle` (shown when light) |
| Theme → light | `Light` (sun) | icon-only ⭐ | `ThemeToggle` (shown when dark) |
| References a dataset | `DataTable` | icon + text | Library row marker, Linked-datasets list, header **Datasets**, editor **Extract**⁴ |
| Add / create-new | `Add` | icon + text → icon-only⁴ | Library "Create New Snippet" (collapses to "+" when the pane is narrow), Datasets "New …" |
| Delete | `TrashCan` | icon-only ⭐ (danger) | Library row delete¹ — text "Delete" in the panel² |
| Import workspace | `Upload` | icon + text | Header **Import** (a file is brought into the app) |
| Export workspace | `Download` | icon + text | Header **Export** (the workspace is written out) |
| About / information | `Information` | icon + text | Header **About** |
| Revert draft | `Reset` | icon + text → icon-only⁴ | Editor toolbar **Revert** (restore last published) |
| Live search | `Search` | icon-in-field⁵ | Library search box (leading magnifier; the input's `aria-label`/placeholder names the field) |
| Settings (gear) | `Settings` | icon-only ⭐ | Per-pane settings disclosures (Editor, Preview, Library dates) |
| Unpublished draft | (CSS dot) | status-glyph | Library row (paired with a hidden label) |
| Meaning | Carbon glyph | Form | Surfaces |
| --------------------- | --------------- | ------------------------ | -------------------------------------------------------------------------------------------- |
| Close / dismiss | `Close` (✕) | icon-only ⭐ | `ModalShell`, `Toaster` |
| Theme → dark | `Asleep` (moon) | icon-only ⭐ | `ThemeToggle` (shown when light) |
| Theme → light | `Light` (sun) | icon-only ⭐ | `ThemeToggle` (shown when dark) |
| References a dataset | `DataTable` | icon + text | Library row marker, Linked-datasets list, header **Datasets**, editor **Extract**⁴ |
| Add / create-new | `Add` | icon + text → icon-only⁴ | Library "Create New Snippet" (collapses to "+" when the pane is narrow), Datasets "New …" |
| Delete | `TrashCan` | icon-only ⭐ (danger) | Library row delete¹ — text "Delete" in the panel² |
| Import workspace | `Upload` | icon + text | Header **Import** (a file is brought into the app) |
| Export workspace | `Download` | icon + text | Header **Export** (the workspace is written out) |
| About / information | `Information` | icon + text | Header **About** |
| Revert draft | `Reset` | icon + text → icon-only⁴ | Editor toolbar **Revert** (restore last published) |
| Live search | `Search` | icon-in-field⁵ | Library search box (leading magnifier; the input's `aria-label`/placeholder names the field) |
| Settings (gear) | `Settings` | icon-only ⭐ | Per-pane settings disclosures (Editor, Preview, Library dates) |
| Unpublished draft | (CSS dot) | status-glyph | Library row (paired with a hidden label) |
| Composition structure | (custom frame) | icon-only ⭐ | Preview toolbar — composition-wireframe disclosure (a frame holding nested view blocks) |
**Pane-toggle set** — the one **custom** sub-family (not single Carbon glyphs): a
panel frame with one of three regions filled, where the filled bar's _position_
@@ -379,7 +380,8 @@ the `Icon` registry**:
⭐ = **icon-only set** (the glyph alone names the control, via `aria-label`): the
**universal** glyphs `close` + `theme`; the conventional disclosure/affordance
glyphs `settings` (gear) and the **pane-toggle** trio (position is the meaning);
glyphs `settings` (gear) and `structure` (the composition-wireframe frame) and the
**pane-toggle** trio (position is the meaning);
and **delete** as a deliberate destructive-row exception — a dense, repeated list
action where a label would cost more than it gives. `search` is _not_ ⭐: its
magnifier is a decorative lead-in to a labelled input (footnote ⁵), not a control
@@ -221,6 +221,19 @@ below) like `PaneSplitHandle`, with the gesture in `useRowResizeDrag` (the row t
_(Consulted via `/council` → WAI-ARIA APG `windowsplitter`. This bullet is the contract;
cite it, not the APG file.)_
**Resolved — composition structure wireframe.** The preview toolbar's structure disclosure (a
read-only schematic of the spec's multi-view composition — `CompositionWireframe`, arch 08) is a
**WAI-ARIA APG `tree`** inside a disclosure popover (`usePopover`): bare nested boxes are
`tree``treeitem``group`, single-select via `aria-selected`, **one tab stop with a roving
tabindex**, arrow keys in **logical (document) order** — Up/Down between nodes, Left → parent,
Right → first child, Home/End, Enter/Space activate — not spatial, since a mixed horizontal/
vertical layout makes spatial arrows ambiguous. Selecting a box reveals + selects that view's
source range in the editor (`AppStore.requestRevealView`) but **does not steal focus**, so the
wireframe stays the active surface while the editor scrolls to follow; the editor selection is the
single source of truth. The toolbar glyph appears **only for a composed spec** — a single-view
spec hides the affordance rather than disclosing an empty tree. _(Council: APG treeview; the
cursor-scoping reachability rationale is in [arch 08](08-vega-editor-techniques.md).)_
**Resolved — pane toggle strip.** The persistent show/hide strip (spec §01A) is a **WAI-ARIA
APG `toolbar`** (`role="toolbar"`, `aria-orientation="vertical"`, an `aria-label` such as
"Workspace panes") — **not** a row of independently-tabbable buttons. Grouping into a toolbar
@@ -0,0 +1,130 @@
.wrap {
display: inline-flex;
}
/* Disclosure panel portaled to body, positioned `fixed` by usePopover. The shared
disclosure-popover surface (arch 10): elevated --layer-01, hairline border, --radius. */
.pop {
position: fixed;
z-index: 1000;
width: 264px;
max-height: 60vh;
overflow: auto;
padding: var(--space-4);
background: var(--layer-01);
border: var(--border-width) solid var(--border);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.title {
margin: 0 0 var(--space-3);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary);
}
.tree {
font-size: 12px;
}
/* Bare wireframe: a crisp box, fill only the canvas. Passive structure, so the
boxes carry the function (drop targets, later) `--border-strong`, no chrome. */
.node {
border: 1px solid var(--border-strong);
background: var(--bg);
}
.container {
padding: var(--space-2);
cursor: pointer;
}
.leaf {
min-height: 48px;
cursor: pointer;
}
.node:hover {
border-color: var(--text-secondary);
}
/* Selection mirrors the library's active-row language (arch 09 §4). */
.node.selected {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent);
}
.leaf.selected {
background: var(--accent-soft);
}
.node:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
.children {
gap: var(--space-2);
}
.row {
display: flex;
flex-direction: row;
}
.row > * {
flex: 1 1 0;
min-width: 0;
}
.col {
display: flex;
flex-direction: column;
}
/* General concat wraps into a grid (honors `columns`). */
.grid {
display: flex;
flex-flow: row wrap;
}
.grid > * {
flex: 1 1 84px;
}
/* Layer: children share one plotting area overlap them in a single grid cell so
the stack reads as depth, not as siblings. */
.layered {
display: grid;
padding: 0 14px 14px 0;
}
.layered > * {
grid-area: 1 / 1;
}
.layered > *:nth-child(2) {
transform: translate(7px, 7px);
}
.layered > *:nth-child(3) {
transform: translate(14px, 14px);
}
.layered > *:nth-child(n + 4) {
transform: translate(21px, 21px);
}
/* Facet / repeat: one authored child stands for many generated cells a card
peeking out behind hints at the multiples. */
.generated {
position: relative;
}
.generated::before {
content: '';
position: absolute;
inset: 5px -5px -5px 5px;
border: 1px solid var(--border);
z-index: -1;
}
.caption {
margin: var(--space-3) 0 0;
min-height: 1.4em;
color: var(--text-secondary);
font-size: 11px;
}
.caption code {
font-family: var(--font-mono);
color: var(--text);
}
.muted {
color: var(--text-placeholder);
}
@@ -0,0 +1,102 @@
/**
* CompositionWireframe the read-only structure tree (arch 08 / arch 10 §5).
*
* Guards the load-bearing behavior: the affordance is hidden for a single-view
* spec (nothing to schematize), the disclosed panel is an APG tree with one
* roving tab stop, arrow keys walk it in document order, and activating a box
* asks the editor to reveal that view's exact source range. The viewTree model
* itself is covered in core (spec-view-tree.test.ts).
*/
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { useAppStore } from '../stores/AppStore';
import { usePopoverStore } from '../stores/PopoverStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { CompositionWireframe } from './CompositionWireframe';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const COMPOSED = JSON.stringify({ vconcat: [{ mark: 'point' }, { mark: 'bar' }] }, null, 2);
let container: HTMLDivElement;
let root: Root;
const setSpec = (text: string) => {
useSnippetStore.getState().reset();
useSnippetStore.setState({ draftText: text });
};
const treeItems = () =>
Array.from(document.body.querySelectorAll<HTMLElement>('[role="treeitem"]'));
const item = (key: string) => document.body.querySelector<HTMLElement>(`[data-key="${key}"]`)!;
async function renderOpen() {
await act(async () => {
root.render(<CompositionWireframe />);
await Promise.resolve();
});
await act(async () => {
usePopoverStore.getState().show('composition-wireframe');
await Promise.resolve();
});
}
beforeEach(() => {
usePopoverStore.setState({ openId: null });
useAppStore.setState({ revealTarget: null });
setSpec(COMPOSED);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
usePopoverStore.setState({ openId: null });
useAppStore.setState({ revealTarget: null });
useSnippetStore.getState().reset();
});
describe('CompositionWireframe', () => {
test('renders nothing for a single-view spec', () => {
setSpec('{"mark":"point"}');
act(() => root.render(<CompositionWireframe />));
expect(container.querySelector('button')).toBeNull();
});
test('shows a structure trigger for a composed spec', () => {
act(() => root.render(<CompositionWireframe />));
expect(container.querySelector('button[aria-controls="composition-wireframe"]')).not.toBeNull();
});
test('discloses an APG tree: the vconcat root and its two leaf views', async () => {
await renderOpen();
expect(document.body.querySelector('[role="tree"]')).not.toBeNull();
expect(treeItems().map((el) => el.dataset.key)).toEqual(['root', 'vconcat|0', 'vconcat|1']);
});
test('roving tabindex: exactly one treeitem is in the tab order', async () => {
await renderOpen();
expect(treeItems().filter((el) => el.tabIndex === 0)).toHaveLength(1);
expect(item('root').tabIndex).toBe(0); // the root holds it on open
});
test('ArrowDown moves the roving tab stop in document order', async () => {
await renderOpen();
act(() => {
item('root').dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
});
expect(item('root').tabIndex).toBe(-1);
expect(item('vconcat|0').tabIndex).toBe(0);
});
test('activating a box asks the editor to reveal that views source range', async () => {
await renderOpen();
act(() => item('vconcat|1').click());
const target = useAppStore.getState().revealTarget!;
expect(COMPOSED.slice(target.offset, target.offset + target.length)).toContain('"bar"');
});
});
+255
View File
@@ -0,0 +1,255 @@
/**
* Composition wireframe a read-only schematic of the active spec's multi-view
* structure (docs/architecture/08 editor augmentation). Bare nested boxes for
* `layer`/`hconcat`/`vconcat`/`concat`/`facet`/`repeat` down to the unit views; no
* labels in the boxes (the caption names the hovered/selected one). Clicking a box
* selects it and reveals that view's source range in the editor
* (`AppStore.requestRevealView`) the editor stays the source of truth.
*
* A disclosure popover (`usePopover`) off a glyph in the preview toolbar; the panel
* is the WAI-ARIA APG **tree** widget `tree`/`treeitem`/`group`, single-select,
* roving tabindex, arrow-key nav in logical (document) order (arch 10 §5). Phase A
* is read-only navigation; drag-reorder and resize land on this same tree.
*/
import {
useCallback,
useEffect,
useMemo,
useState,
type KeyboardEvent,
type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
import type { SpecPath } from '@core/spec-insert';
import { viewTree, type Orientation, type ViewNode } from '@core/spec-view-tree';
import { usePopover } from '../hooks/usePopover';
import { useAppStore } from '../stores/AppStore';
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { Icon } from './Icon';
import { IconButton } from './IconButton';
import styles from './CompositionWireframe.module.css';
const POPOVER_ID = 'composition-wireframe';
const INITIAL_FOCUS = ['[role="treeitem"]'] as const;
/** A DOM-safe, unique key for a node from its path. */
const keyOf = (path: SpecPath): string => (path.length ? path.join('|') : 'root');
/** A readable path like `vconcat[1].hconcat[0]` for the caption. */
function pathLabel(path: SpecPath): string {
if (path.length === 0) return 'root';
let out = '';
for (const seg of path) out += typeof seg === 'number' ? `[${seg}]` : out ? `.${seg}` : seg;
return out;
}
const descriptor = (n: ViewNode): string =>
n.kind === 'unit' ? (n.mark ? `${n.mark} view` : 'view') : `${n.op} · ${n.children.length} views`;
const ariaLabelOf = (n: ViewNode): string =>
n.kind === 'unit'
? n.mark
? `${n.mark} view`
: 'view'
: `${n.op}, ${n.orientation}, ${n.children.length} views`;
// TODO (deferred polish — docs/exploration/visual-composition-editing-exploration.md §6a):
// (1) render a simplified mark-type glyph inside each leaf box for at-a-glance ID;
// (2) replace the offset-rectangle `layered` look with a "stacked planes" primitive
// (overlapping sheets/disks, like the database glyph) to read as one shared space.
/** Children layout class per orientation (layered overlaps in one grid cell). */
const LAYOUT: Record<Orientation, string> = {
horizontal: styles.row,
vertical: styles.col,
grid: styles.grid,
layered: styles.layered,
};
interface Flat {
node: ViewNode;
key: string;
parentKey: string | null;
}
/** Pre-order flatten — drives roving keyboard nav (next/prev/parent/first-child). */
function flatten(root: ViewNode): Flat[] {
const out: Flat[] = [];
const walk = (n: ViewNode, parentKey: string | null) => {
const key = keyOf(n.path);
out.push({ node: n, key, parentKey });
for (const c of n.children) walk(c, key);
};
walk(root, null);
return out;
}
function WireframeTree({ tree }: { tree: ViewNode }) {
const requestRevealView = useAppStore((s) => s.requestRevealView);
const flat = useMemo(() => flatten(tree), [tree]);
const rootKey = keyOf(tree.path);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [focusedKey, setFocusedKey] = useState<string | null>(null);
const [hoverKey, setHoverKey] = useState<string | null>(null);
const effectiveFocus =
(focusedKey && flat.some((f) => f.key === focusedKey) && focusedKey) || rootKey;
const select = useCallback(
(n: ViewNode) => {
setSelectedKey(keyOf(n.path));
setFocusedKey(keyOf(n.path));
requestRevealView(n.offset, n.length);
},
[requestRevealView],
);
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const i = flat.findIndex((f) => f.key === effectiveFocus);
if (i < 0) return;
const moveTo = (j: number) => {
const target = flat[j];
if (!target) return;
e.preventDefault();
setFocusedKey(target.key);
e.currentTarget.querySelector<HTMLElement>(`[data-key="${target.key}"]`)?.focus();
};
switch (e.key) {
case 'ArrowDown':
return moveTo(i + 1);
case 'ArrowUp':
return moveTo(i - 1);
case 'Home':
return moveTo(0);
case 'End':
return moveTo(flat.length - 1);
case 'ArrowRight': // first child is the next node in pre-order
return flat[i].node.children.length ? moveTo(i + 1) : undefined;
case 'ArrowLeft': {
const pk = flat[i].parentKey;
return pk ? moveTo(flat.findIndex((f) => f.key === pk)) : undefined;
}
case 'Enter':
case ' ':
e.preventDefault();
return select(flat[i].node);
}
};
const renderNode = (n: ViewNode): ReactNode => {
const key = keyOf(n.path);
const container = n.kind === 'composition';
const generated = n.op === 'facet' || n.op === 'repeat';
const cls = [
styles.node,
container ? styles.container : styles.leaf,
key === selectedKey ? styles.selected : '',
generated ? styles.generated : '',
]
.filter(Boolean)
.join(' ');
return (
<div
key={key}
data-key={key}
role="treeitem"
aria-label={ariaLabelOf(n)}
aria-selected={key === selectedKey}
aria-expanded={container ? true : undefined}
tabIndex={key === effectiveFocus ? 0 : -1}
className={cls}
onClick={(e) => {
e.stopPropagation();
select(n);
}}
onMouseEnter={(e) => {
e.stopPropagation();
setHoverKey(key);
}}
onMouseLeave={() => setHoverKey(null)}
onFocus={(e) => {
e.stopPropagation();
setFocusedKey(key);
}}
>
{container && (
<div
role="group"
className={`${styles.children} ${n.orientation ? LAYOUT[n.orientation] : ''}`}
>
{n.children.map(renderNode)}
</div>
)}
</div>
);
};
const captionNode =
(hoverKey ?? selectedKey) ? flat.find((f) => f.key === (hoverKey ?? selectedKey))?.node : null;
return (
<>
<div role="tree" aria-label="View composition" className={styles.tree} onKeyDown={onKeyDown}>
{renderNode(tree)}
</div>
<p className={styles.caption} aria-hidden="true">
{captionNode ? (
<>
<code>{pathLabel(captionNode.path)}</code> {descriptor(captionNode)}
</>
) : (
<span className={styles.muted}>Hover a block to identify it · click to reveal it</span>
)}
</p>
</>
);
}
export function CompositionWireframe() {
const { open, toggle, close, triggerRef, setPopNode } = usePopover({
id: POPOVER_ID,
align: 'right',
flip: true,
initialFocus: INITIAL_FOCUS,
});
const shownText = useSnippetStore(selectShownText);
const tree = useMemo(() => viewTree(shownText), [shownText]);
const hasComposition = !!tree && tree.kind === 'composition';
// The wireframe is meaningless for a single-view spec — hide the affordance, and
// close it if it was open when the composition is unwrapped away.
useEffect(() => {
if (!hasComposition && open) close();
}, [hasComposition, open, close]);
if (!tree || tree.kind !== 'composition') return null;
return (
<div className={styles.wrap}>
<IconButton
ref={triggerRef}
label="View composition structure"
aria-expanded={open}
aria-controls={POPOVER_ID}
onClick={toggle}
>
<Icon name="structure" />
</IconButton>
{open &&
createPortal(
<div
ref={setPopNode}
id={POPOVER_ID}
className={styles.pop}
role="group"
aria-label="Composition structure"
>
<h4 className={styles.title}>Structure</h4>
<WireframeTree tree={tree} />
</div>,
document.body,
)}
</div>
);
}
+14
View File
@@ -34,6 +34,7 @@ export type IconName =
| 'export' // export the workspace to a file — Carbon Download (a file comes out)
| 'info' // about / information — Carbon Information (outline)
| 'revert' // revert draft to last published — Carbon Reset
| 'structure' // composition-structure wireframe disclosure (preview toolbar) — nested view blocks
// Pane-toggle sub-family (spec §01A): a panel frame with one region filled, so the
// glyph shows *which* pane it controls by position (left / centre / right).
| 'pane-library' // toggle the library pane (left)
@@ -150,6 +151,19 @@ const GLYPHS: Record<IconName, ReactNode> = {
<rect x="21" y="10" width="4" height="12" />
</>
),
// Composition structure: a panel frame (matching the pane family) holding nested
// view blocks — two side by side over one wide — the wireframe in miniature.
structure: (
<>
<rect x="4" y="6" width="24" height="2" />
<rect x="4" y="24" width="24" height="2" />
<rect x="4" y="6" width="2" height="20" />
<rect x="26" y="6" width="2" height="20" />
<rect x="9" y="10" width="6" height="5" />
<rect x="17" y="10" width="6" height="5" />
<rect x="9" y="17" width="14" height="5" />
</>
),
moon: (
<path d="M13.5025,5.4136A15.0755,15.0755,0,0,0,25.096,23.6082a11.1134,11.1134,0,0,1-7.9749,3.3893c-.1385,0-.2782.0051-.4178,0A11.0944,11.0944,0,0,1,13.5025,5.4136M14.98,3a1.0024,1.0024,0,0,0-.1746.0156A13.0959,13.0959,0,0,0,16.63,28.9973c.1641.006.3282,0,.4909,0a13.0724,13.0724,0,0,0,10.702-5.5556,1.0094,1.0094,0,0,0-.7833-1.5644A13.08,13.08,0,0,1,15.8892,4.38,1.0149,1.0149,0,0,0,14.98,3Z" />
),
+4 -1
View File
@@ -37,6 +37,7 @@ import { usePreviewStore } from '../stores/PreviewStore';
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { useUserSettingsStore } from '../stores/UserSettingsStore';
import { ChartExport } from './ChartExport';
import { CompositionWireframe } from './CompositionWireframe';
import { DataInspector } from './DataInspector';
import { InspectorSplitHandle } from './InspectorSplitHandle';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
@@ -451,10 +452,12 @@ export function LivePreview() {
<div className={styles.preview}>
<div className={styles.header}>
<FitControl />
{/* Right cluster: chart theme, export this chart, then the settings gear. */}
{/* Right cluster: chart theme, export this chart, the structure wireframe,
then the settings gear. */}
<div className={styles.headerEnd}>
<ChartThemeControl />
<ChartExport chartReady={chartReady} getImageUrl={getImageUrl} />
<CompositionWireframe />
<PreviewSettings />
</div>
</div>
+18
View File
@@ -384,6 +384,7 @@ export function SpecEditor() {
const editorView = useSnippetStore((s) => s.editorView);
const bufferEpoch = useSnippetStore((s) => s.bufferEpoch);
const uiTheme = useAppStore((s) => s.uiTheme);
const revealTarget = useAppStore((s) => s.revealTarget);
const error = usePreviewStore((s) => s.error);
// Editor preferences (spec §07 → Editor); applied live below as they change.
const editorPrefs = useUserSettingsStore((s) => s.saved.editor);
@@ -495,6 +496,23 @@ export function SpecEditor() {
monaco.editor.setTheme(effective === 'dark' ? 'vs-dark' : 'vs');
}, [uiTheme, editorPrefs.theme]);
// Select + reveal a view's source range when the composition wireframe asks
// (arch 08 → composition wireframe). The nonce makes a repeat request re-fire.
useEffect(() => {
const editor = editorRef.current;
if (!editor || !revealTarget) return;
const model = editor.getModel();
if (!model) return;
const range = monaco.Range.fromPositions(
model.getPositionAt(revealTarget.offset),
model.getPositionAt(revealTarget.offset + revealTarget.length),
);
editor.setSelection(range);
editor.revealRangeInCenterIfOutsideViewport(range);
// Deliberately no focus(): the wireframe stays the active surface so the user
// can keep browsing blocks while the editor scrolls/selects to follow.
}, [revealTarget]);
return (
<div className={styles.editorPane}>
<EditorToolbar editorRef={editorRef} />
+16
View File
@@ -42,3 +42,19 @@ describe('data inspector height (divider math)', () => {
expect(inspectorHeightValue(96, 150)).toBeNull();
});
});
describe('requestRevealView (wireframe → editor)', () => {
beforeEach(() => useAppStore.setState({ revealTarget: null }));
test('carries the requested range', () => {
store().requestRevealView(40, 12);
expect(store().revealTarget).toMatchObject({ offset: 40, length: 12 });
});
test('bumps the nonce on a repeat request for the same range, so it re-fires', () => {
store().requestRevealView(40, 12);
const first = store().revealTarget!.nonce;
store().requestRevealView(40, 12);
expect(store().revealTarget!.nonce).toBe(first + 1);
});
});
+11
View File
@@ -65,6 +65,12 @@ export interface AppState {
dataInspectorHeight: number;
/** The currently open modal, or null. */
activeModal: ModalName | null;
/**
* A request from the composition wireframe to select and reveal a view's source
* range in the editor (wireframe editor; arch 08 editor augmentation). The
* nonce makes a repeat request for the same range re-fire. Null until the first.
*/
revealTarget: { offset: number; length: number; nonce: number } | null;
setTheme: (theme: UiTheme) => void;
/** Flip between light and dark — the header ThemeToggle's action. */
@@ -84,6 +90,8 @@ export interface AppState {
* which calls this; arrives with the modal system in M3.
*/
setActiveModal: (modal: ModalName | null) => void;
/** Ask the editor to select + reveal a view's source range (composition wireframe). */
requestRevealView: (offset: number, length: number) => void;
}
export const useAppStore = create<AppState>((set) => ({
@@ -93,6 +101,7 @@ export const useAppStore = create<AppState>((set) => ({
dataInspectorOpen: false,
dataInspectorHeight: DATA_INSPECTOR_DEFAULT_HEIGHT,
activeModal: null,
revealTarget: null,
setTheme: (uiTheme) => set({ uiTheme }),
toggleTheme: () => set((s) => ({ uiTheme: s.uiTheme === 'dark' ? 'light' : 'dark' })),
@@ -101,4 +110,6 @@ export const useAppStore = create<AppState>((set) => ({
setDataInspectorOpen: (dataInspectorOpen) => set({ dataInspectorOpen }),
setDataInspectorHeight: (dataInspectorHeight) => set({ dataInspectorHeight }),
setActiveModal: (activeModal) => set({ activeModal }),
requestRevealView: (offset, length) =>
set((s) => ({ revealTarget: { offset, length, nonce: (s.revealTarget?.nonce ?? 0) + 1 } })),
}));
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest';
import { viewTree, type ViewNode } from './spec-view-tree';
const json = (o: unknown) => JSON.stringify(o, null, 2);
describe('viewTree', () => {
it('returns a single unit node for a flat spec, with its mark', () => {
const t = viewTree(json({ mark: 'bar', encoding: {} }))!;
expect(t.kind).toBe('unit');
expect(t.mark).toBe('bar');
expect(t.children).toHaveLength(0);
});
it('reads mark from an object mark (mark.type)', () => {
const t = viewTree(json({ mark: { type: 'line', point: true } }))!;
expect(t.mark).toBe('line');
});
it('builds an array composition with per-element paths and orientation', () => {
const t = viewTree(json({ vconcat: [{ mark: 'point' }, { mark: 'bar' }] }))!;
expect(t.kind).toBe('composition');
expect(t.op).toBe('vconcat');
expect(t.orientation).toBe('vertical');
expect(t.children.map((c) => c.path)).toEqual([
['vconcat', 0],
['vconcat', 1],
]);
expect(t.children.map((c) => c.mark)).toEqual(['point', 'bar']);
});
it('recurses into nested compositions', () => {
const t = viewTree(
json({ vconcat: [{ mark: 'point' }, { hconcat: [{ mark: 'bar' }, { mark: 'line' }] }] }),
)!;
const nested = t.children[1];
expect(nested.kind).toBe('composition');
expect(nested.op).toBe('hconcat');
expect(nested.orientation).toBe('horizontal');
expect(nested.children.map((c) => c.path)).toEqual([
['vconcat', 1, 'hconcat', 0],
['vconcat', 1, 'hconcat', 1],
]);
});
it('marks a layer as layered', () => {
const t = viewTree(json({ layer: [{ mark: 'area' }, { mark: 'line' }] }))!;
expect(t.op).toBe('layer');
expect(t.orientation).toBe('layered');
expect(t.children).toHaveLength(2);
});
it('treats facet/repeat as a composition with one `spec` child', () => {
const facet = viewTree(
json({ facet: { field: 'c', type: 'nominal' }, spec: { mark: 'bar' } }),
)!;
expect(facet.op).toBe('facet');
expect(facet.children).toHaveLength(1);
expect(facet.children[0].path).toEqual(['spec']);
expect(facet.children[0].mark).toBe('bar');
const repeat = viewTree(json({ repeat: ['a', 'b'], spec: { mark: 'point' } }))!;
expect(repeat.op).toBe('repeat');
expect(repeat.children[0].path).toEqual(['spec']);
});
it('exposes byte ranges that point at each node object', () => {
const text = json({ vconcat: [{ mark: 'point' }, { mark: 'bar' }] });
const t = viewTree(text)!;
const child = t.children[1];
expect(text[child.offset]).toBe('{'); // the element object's opening brace
expect(text.slice(child.offset, child.offset + child.length)).toContain('"bar"');
});
it('returns null for empty or non-object input', () => {
expect(viewTree('')).toBeNull();
expect(viewTree('[1, 2]')).toBeNull();
});
it('is tolerant of a mid-edit trailing comma', () => {
const t = viewTree('{ "vconcat": [ { "mark": "point" }, ] }');
expect(t?.op).toBe('vconcat');
expect((t as ViewNode).children.length).toBeGreaterThanOrEqual(1);
});
});
+104
View File
@@ -0,0 +1,104 @@
/**
* Spec view tree (docs/architecture/08 editor augmentation). Builds the
* recursive structure of a Vega-Lite spec's composition the model behind the
* composition wireframe: nested boxes for `layer`/`hconcat`/`vconcat`/`concat`
* (array children) and `facet`/`repeat` (a single generated `spec` child), down to
* the unit views at the leaves.
*
* Portable core. Built over `jsonc-parser`'s error-tolerant tree (so it keeps
* working while the draft is briefly unparseable mid-edit), and each node carries
* its byte range so a surface can map a box to the editor selection and back. The
* complement to `spec-insert` (which mutates one composition array): this reads the
* whole tree at once.
*/
import { findNodeAtLocation, parseTree, type Node } from 'jsonc-parser';
import type { SpecPath } from './spec-insert';
import { ARRAY_COMPOSITIONS } from './spec-transforms';
/** A composition operator. Array ops hold a child array; facet/repeat a `spec`. */
type CompositionOp = 'layer' | 'hconcat' | 'vconcat' | 'concat' | 'facet' | 'repeat';
/** How a composition lays its children out (a hint for the wireframe renderer). */
export type Orientation = 'horizontal' | 'vertical' | 'layered' | 'grid';
/** A node in the view tree: a composition (with children) or a leaf unit view. */
export interface ViewNode {
kind: 'composition' | 'unit';
/** The operator, for a composition node. */
op?: CompositionOp;
/** Layout hint, for a composition node. */
orientation?: Orientation;
/** Path from the root to this node (object keys + array indices). */
path: SpecPath;
/** Byte range of the node's object in the source, for editor selection sync. */
offset: number;
length: number;
/** The mark type, for a unit node (string mark or `mark.type`); else undefined. */
mark?: string;
children: ViewNode[];
}
const ORIENTATION: Record<CompositionOp, Orientation> = {
hconcat: 'horizontal',
vconcat: 'vertical',
concat: 'grid', // general concat wraps into a grid (honors `columns`)
layer: 'layered',
facet: 'grid',
repeat: 'grid',
};
/** Facet and repeat hold their single child view under `spec` (not an array). */
const SPEC_CHILD_OPS: readonly CompositionOp[] = ['facet', 'repeat'];
/** A unit's mark type: a string `mark`, or `mark.type`; undefined when absent. */
function markOf(node: Node): string | undefined {
const mark = findNodeAtLocation(node, ['mark']);
if (!mark) return undefined;
if (mark.type === 'string') return mark.value as string;
const type = findNodeAtLocation(mark, ['type']);
return typeof type?.value === 'string' ? type.value : undefined;
}
function buildNode(node: Node, path: SpecPath): ViewNode {
const base = { path, offset: node.offset, length: node.length };
// Array compositions: children are the array elements.
for (const op of ARRAY_COMPOSITIONS as CompositionOp[]) {
const arr = findNodeAtLocation(node, [op]);
if (arr?.type === 'array') {
return {
kind: 'composition',
op,
orientation: ORIENTATION[op],
...base,
children: (arr.children ?? []).map((el, i) => buildNode(el, [...path, op, i])),
};
}
}
// Facet / repeat: the single child view lives under `spec`.
for (const op of SPEC_CHILD_OPS) {
if (findNodeAtLocation(node, [op])) {
const spec = findNodeAtLocation(node, ['spec']);
return {
kind: 'composition',
op,
orientation: ORIENTATION[op],
...base,
children: spec ? [buildNode(spec, [...path, 'spec'])] : [],
};
}
}
// Otherwise a leaf unit view.
return { kind: 'unit', ...base, mark: markOf(node), children: [] };
}
/**
* The view tree for the spec `text`, or null when it has no object root (empty or
* unparseable beyond recovery). A flat unit spec yields a single `unit` node with
* no children the caller treats that as "no composition to show".
*/
export function viewTree(text: string): ViewNode | null {
const root = parseTree(text);
if (!root || root.type !== 'object') return null;
return buildNode(root, []);
}