Editor: render a layer as one framed row of mark glyphs

A layer is one plotting space with several marks stacked in z-order, so it now renders as a single frame holding its child marks as a row of glyphs, badged with a new 'layers' glyph — instead of offset stacked cards that read as separate spaces and hid the lower glyphs. Each mark stays an individual treeitem, so selection and Alt+up/down z-order reorder are unchanged.
This commit is contained in:
2026-06-29 14:59:01 +03:00
parent 216797ff9b
commit 26b383ff33
7 changed files with 96 additions and 37 deletions
@@ -119,23 +119,38 @@
.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. */
/* Layer: one plotting space holding several marks (z-order). Render it as a single
frame — a row of the child mark glyphs, badged as layered — not as separate boxes,
so it reads as one space and the marks stay legible (vs. the concat box-per-view). */
.layerBox {
position: relative;
}
.layered {
display: grid;
padding: 0 14px 14px 0;
display: flex;
flex-flow: row wrap;
align-items: center;
justify-content: center;
gap: var(--space-3);
min-height: 40px;
}
.layered > * {
grid-area: 1 / 1;
/* A layer's mark is a bare glyph, not a boxed leaf — the frame is the box. The
transparent border keeps the hover/selection outline (from `.node`) consistent. */
.layerMark {
min-height: 0;
padding: var(--space-2) var(--space-3);
border-color: transparent;
background: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.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);
.layerBadge {
position: absolute;
top: var(--space-2);
right: var(--space-2);
color: var(--text-placeholder);
pointer-events: none;
}
/* Facet / repeat: one authored child stands for many generated cells — a card
@@ -20,6 +20,7 @@ import { markIconName } from './mark-icon';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const COMPOSED = JSON.stringify({ vconcat: [{ mark: 'point' }, { mark: 'bar' }] }, null, 2);
const LAYERED = JSON.stringify({ layer: [{ mark: 'bar' }, { mark: 'line' }] }, null, 2);
let container: HTMLDivElement;
let root: Root;
@@ -109,6 +110,26 @@ describe('CompositionWireframe', () => {
expect(COMPOSED.slice(target.offset, target.offset + target.length)).toContain('"bar"');
});
test('a layer discloses its marks as reorderable treeitems (z-order)', async () => {
// The layer renders as one frame of mark glyphs, but each mark stays a treeitem
// so selection and Alt+arrow z-order reorder keep working.
setEditableSpec(LAYERED);
await renderOpen();
expect(treeItems().map((el) => el.dataset.key)).toEqual(['root', 'layer|0', 'layer|1']);
act(() => {
item('root').dispatchEvent(key({ key: 'ArrowDown' }));
});
act(() => {
item('layer|0').dispatchEvent(key({ key: 'ArrowDown', altKey: true }));
});
expect(useAppStore.getState().composeRequest).toMatchObject({
kind: 'move',
arrayPath: ['layer'],
from: 0,
to: 1,
});
});
test('Alt+ArrowDown asks the editor to reorder the focused view down', async () => {
setEditableSpec(COMPOSED);
await renderOpen();
+16 -13
View File
@@ -75,11 +75,7 @@ const ariaLabelOf = (n: ViewNode): string =>
: 'view'
: `${n.op}, ${n.orientation}, ${n.children.length} views`;
// TODO (deferred polish — docs/exploration/visual-composition-editing-exploration.md §6a):
// 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). */
/** Children layout class per orientation (layered is a row of marks in one frame). */
const LAYOUT: Record<Orientation, string> = {
horizontal: styles.row,
vertical: styles.col,
@@ -407,10 +403,14 @@ function WireframeTree({ tree }: { tree: ViewNode }) {
const idx = n.path.at(-1);
const draggable = editable && parent != null && typeof idx === 'number';
const onTarget = drag?.resolution?.targetKey === key ? drag.resolution : null;
// A unit view inside a layer is a bare mark glyph in the layer's shared frame,
// not its own box — a layer is one plotting space with several marks stacked.
const compactMark = parent?.op === 'layer' && !container;
const cls = [
styles.node,
container ? styles.container : styles.leaf,
compactMark ? styles.layerMark : container ? styles.container : styles.leaf,
n.op === 'layer' ? styles.layerBox : '',
key === selectedKey ? styles.selected : '',
generated ? styles.generated : '',
]
@@ -451,13 +451,16 @@ function WireframeTree({ tree }: { tree: ViewNode }) {
}}
>
{container ? (
<div
role="group"
data-orientation={n.orientation}
className={`${styles.children} ${n.orientation ? LAYOUT[n.orientation] : ''}`}
>
{n.children.map((c) => renderNode(c, n))}
</div>
<>
{n.op === 'layer' && <Icon name="layers" className={styles.layerBadge} />}
<div
role="group"
data-orientation={n.orientation}
className={`${styles.children} ${n.orientation ? LAYOUT[n.orientation] : ''}`}
>
{n.children.map((c) => renderNode(c, n))}
</div>
</>
) : (
<Icon name={markIconName(n.mark)} size="md" className={styles.markIcon} />
)}
+16
View File
@@ -35,6 +35,7 @@ export type IconName =
| 'info' // about / information — Carbon Information (outline)
| 'revert' // revert draft to last published — Carbon Reset
| 'structure' // composition-structure wireframe disclosure (preview toolbar) — nested view blocks
| 'layers' // layered-composition badge (wireframe) — two stacked planes (one shared space)
// Mark sub-family (composition wireframe leaves) — a simplified glyph of a unit
// view's mark type, so which-is-which reads at a glance. Vega-Lite mark synonyms
// collapse onto these via `markIconName` (CompositionWireframe); unknown → generic.
@@ -177,6 +178,21 @@ const GLYPHS: Record<IconName, ReactNode> = {
<rect x="9" y="17" width="14" height="5" />
</>
),
// Two offset planes — a layer is one space with several marks stacked in z-order.
layers: (
<>
<rect x="6" y="6" width="15" height="15" fill="none" stroke="currentColor" strokeWidth={2} />
<rect
x="11"
y="11"
width="15"
height="15"
fill="none"
stroke="currentColor"
strokeWidth={2}
/>
</>
),
// Mark glyphs: simplified renderings of each Vega-Lite mark, drawn on the same
// 32-grid. Stroke-based where a line reads truer than a fill (line/rule/generic).
'mark-bar': (