From 57604a80a6131f53981c81adb7f6bb1b4aef2a45 Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Mon, 29 Jun 2026 12:39:59 +0300 Subject: [PATCH] Editor: composition structure wireframe (read-only) --- .../architecture/08-vega-editor-techniques.md | 5 + docs/architecture/09-visual-design.md | 34 +-- .../10-interaction-and-feedback.md | 13 + .../CompositionWireframe.module.css | 130 +++++++++ .../components/CompositionWireframe.test.tsx | 102 +++++++ src/app/components/CompositionWireframe.tsx | 255 ++++++++++++++++++ src/app/components/Icon.tsx | 14 + src/app/components/LivePreview.tsx | 5 +- src/app/components/SpecEditor.tsx | 18 ++ src/app/stores/AppStore.test.ts | 16 ++ src/app/stores/AppStore.ts | 11 + src/core/spec-view-tree.test.ts | 84 ++++++ src/core/spec-view-tree.ts | 104 +++++++ 13 files changed, 774 insertions(+), 17 deletions(-) create mode 100644 src/app/components/CompositionWireframe.module.css create mode 100644 src/app/components/CompositionWireframe.test.tsx create mode 100644 src/app/components/CompositionWireframe.tsx create mode 100644 src/core/spec-view-tree.test.ts create mode 100644 src/core/spec-view-tree.ts diff --git a/docs/architecture/08-vega-editor-techniques.md b/docs/architecture/08-vega-editor-techniques.md index a47d4b8..e4013c2 100644 --- a/docs/architecture/08-vega-editor-techniques.md +++ b/docs/architecture/08-vega-editor-techniques.md @@ -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, diff --git a/docs/architecture/09-visual-design.md b/docs/architecture/09-visual-design.md index 78f20bf..4a3f4d0 100644 --- a/docs/architecture/09-visual-design.md +++ b/docs/architecture/09-visual-design.md @@ -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 diff --git a/docs/architecture/10-interaction-and-feedback.md b/docs/architecture/10-interaction-and-feedback.md index dc162e6..1c78325 100644 --- a/docs/architecture/10-interaction-and-feedback.md +++ b/docs/architecture/10-interaction-and-feedback.md @@ -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 diff --git a/src/app/components/CompositionWireframe.module.css b/src/app/components/CompositionWireframe.module.css new file mode 100644 index 0000000..6d0cbc3 --- /dev/null +++ b/src/app/components/CompositionWireframe.module.css @@ -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); +} diff --git a/src/app/components/CompositionWireframe.test.tsx b/src/app/components/CompositionWireframe.test.tsx new file mode 100644 index 0000000..c8665c2 --- /dev/null +++ b/src/app/components/CompositionWireframe.test.tsx @@ -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('[role="treeitem"]')); +const item = (key: string) => document.body.querySelector(`[data-key="${key}"]`)!; + +async function renderOpen() { + await act(async () => { + root.render(); + 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()); + expect(container.querySelector('button')).toBeNull(); + }); + + test('shows a structure trigger for a composed spec', () => { + act(() => root.render()); + 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 view’s 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"'); + }); +}); diff --git a/src/app/components/CompositionWireframe.tsx b/src/app/components/CompositionWireframe.tsx new file mode 100644 index 0000000..583dba6 --- /dev/null +++ b/src/app/components/CompositionWireframe.tsx @@ -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 = { + 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(null); + const [focusedKey, setFocusedKey] = useState(null); + const [hoverKey, setHoverKey] = useState(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) => { + 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(`[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 ( +
{ + e.stopPropagation(); + select(n); + }} + onMouseEnter={(e) => { + e.stopPropagation(); + setHoverKey(key); + }} + onMouseLeave={() => setHoverKey(null)} + onFocus={(e) => { + e.stopPropagation(); + setFocusedKey(key); + }} + > + {container && ( +
+ {n.children.map(renderNode)} +
+ )} +
+ ); + }; + + const captionNode = + (hoverKey ?? selectedKey) ? flat.find((f) => f.key === (hoverKey ?? selectedKey))?.node : null; + + return ( + <> +
+ {renderNode(tree)} +
+ + + ); +} + +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 ( +
+ + + + {open && + createPortal( +
+

Structure

+ +
, + document.body, + )} +
+ ); +} diff --git a/src/app/components/Icon.tsx b/src/app/components/Icon.tsx index 8945b1b..268a7c5 100644 --- a/src/app/components/Icon.tsx +++ b/src/app/components/Icon.tsx @@ -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 = { ), + // 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: ( + <> + + + + + + + + + ), moon: ( ), diff --git a/src/app/components/LivePreview.tsx b/src/app/components/LivePreview.tsx index 568c5e4..3fd6d0b 100644 --- a/src/app/components/LivePreview.tsx +++ b/src/app/components/LivePreview.tsx @@ -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() {
- {/* 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. */}
+
diff --git a/src/app/components/SpecEditor.tsx b/src/app/components/SpecEditor.tsx index 4d84c53..1fda358 100644 --- a/src/app/components/SpecEditor.tsx +++ b/src/app/components/SpecEditor.tsx @@ -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 (
diff --git a/src/app/stores/AppStore.test.ts b/src/app/stores/AppStore.test.ts index 04813d0..30d39c8 100644 --- a/src/app/stores/AppStore.test.ts +++ b/src/app/stores/AppStore.test.ts @@ -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); + }); +}); diff --git a/src/app/stores/AppStore.ts b/src/app/stores/AppStore.ts index 41758e0..dac868c 100644 --- a/src/app/stores/AppStore.ts +++ b/src/app/stores/AppStore.ts @@ -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((set) => ({ @@ -93,6 +101,7 @@ export const useAppStore = create((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((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 } })), })); diff --git a/src/core/spec-view-tree.test.ts b/src/core/spec-view-tree.test.ts new file mode 100644 index 0000000..3f158e8 --- /dev/null +++ b/src/core/spec-view-tree.test.ts @@ -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); + }); +}); diff --git a/src/core/spec-view-tree.ts b/src/core/spec-view-tree.ts new file mode 100644 index 0000000..5b3a358 --- /dev/null +++ b/src/core/spec-view-tree.ts @@ -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 = { + 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, []); +}