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
@@ -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} />