Skip to content

Commit e225ba4

Browse files
feat(dashboard): auto-hide rail + reveal/dismiss + pin — the atomic interaction arc (#14654) (#14906)
* feat(dashboard): DockRail edge-tab affordance with executor-routed restore (#14654) * refactor(dashboard): rebuild DockRail on container.Base + button.Base composition (#14654) * feat(dashboard): auto-hide reveal/dismiss + pin escape — atomic interaction contract (#14660) * fix(dashboard): embodied focus + dismiss paths, live-pane parking, fraction threading (#14660) * fix(dashboard): reveal pane mirrors projectItem cascade — blueprint fallback + recoverable placeholder (#14660)
1 parent 5c54256 commit e225ba4

16 files changed

Lines changed: 2719 additions & 65 deletions

examples/dashboard/dock/MainContainer.mjs

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,33 +8,39 @@ import '../../../src/tab/Container.mjs'; // registers the `tab-container` ntype
88
import '../../../src/toolbar/Base.mjs'; // registers the `toolbar` ntype used by the perspective toolbar
99

1010
/**
11-
* A representative dock-zone document (`neo.harness.dockZone.v1`): a horizontal split of a two-tab main zone and a
12-
* vertical side-split of two single-tab zones, over four items. The shape `Neo.dashboard.DockLayoutAdapter.project`
13-
* consumes — see its spec for the full contract. Used as the example's INITIAL committed document; the live document
14-
* advances on each splitter resize (see `MainContainer#dockModel`).
11+
* A representative dock-zone document (`neo.harness.dockZone.v1`): an edge-zone root whose center is a
12+
* horizontal split of a two-tab main zone and a vertical side-split of two single-tab zones, plus a
13+
* right edge band holding a single-tab inspector zone — the auto-hide surface (committing
14+
* `setItemAutoHidden` on an edge-band item collapses it to a `Neo.dashboard.DockRail` edge tab).
15+
* The shape `Neo.dashboard.DockLayoutAdapter.project` consumes — see its spec for the full contract.
16+
* Used as the example's INITIAL committed document; the live document advances on each commit
17+
* (see `MainContainer#dockModel`).
1518
* @type {Object}
1619
*/
1720
const initialDockModel = {
1821
schema: 'neo.harness.dockZone.v1',
1922
root : 'root',
2023
items : {
21-
strategy: {componentRef: 'Strategy', title: 'Strategy', kind: 'panel'},
22-
swarm : {componentRef: 'Swarm', title: 'Swarm', kind: 'panel'},
23-
terminal: {componentRef: 'Terminal', title: 'Terminal', kind: 'terminal'},
24-
logs : {componentRef: 'Logs', title: 'Logs', kind: 'panel'}
24+
strategy : {componentRef: 'Strategy', title: 'Strategy', kind: 'panel'},
25+
swarm : {componentRef: 'Swarm', title: 'Swarm', kind: 'panel'},
26+
terminal : {componentRef: 'Terminal', title: 'Terminal', kind: 'terminal'},
27+
logs : {componentRef: 'Logs', title: 'Logs', kind: 'panel'},
28+
inspector: {componentRef: 'Inspector', title: 'Inspector', kind: 'panel'}
2529
},
2630
nodes: {
27-
root : {type: 'split', orientation: 'horizontal', children: ['main-tabs', 'side-split'], sizes: [0.65, 0.35]},
28-
'main-tabs' : {type: 'tabs', items: ['strategy', 'swarm'], activeItemId: 'strategy'},
29-
'side-split' : {type: 'split', orientation: 'vertical', children: ['terminal-tabs', 'logs-tabs'], sizes: [0.6, 0.4]},
30-
'terminal-tabs': {type: 'tabs', items: ['terminal'], activeItemId: 'terminal'},
31-
'logs-tabs' : {type: 'tabs', items: ['logs'], activeItemId: 'logs'}
31+
root : {type: 'edge-zone', zones: {center: 'root-split', right: 'inspector-tabs'}},
32+
'root-split' : {type: 'split', orientation: 'horizontal', children: ['main-tabs', 'side-split'], sizes: [0.65, 0.35]},
33+
'main-tabs' : {type: 'tabs', items: ['strategy', 'swarm'], activeItemId: 'strategy'},
34+
'side-split' : {type: 'split', orientation: 'vertical', children: ['terminal-tabs', 'logs-tabs'], sizes: [0.6, 0.4]},
35+
'terminal-tabs' : {type: 'tabs', items: ['terminal'], activeItemId: 'terminal'},
36+
'logs-tabs' : {type: 'tabs', items: ['logs'], activeItemId: 'logs'},
37+
'inspector-tabs': {type: 'tabs', items: ['inspector'], activeItemId: 'inspector'}
3238
}
3339
};
3440

3541
const reviewDockModel = DockZoneModel.clone(initialDockModel);
3642

37-
reviewDockModel.nodes.root.sizes = [0.48, 0.52];
43+
reviewDockModel.nodes['root-split'].sizes = [0.48, 0.52];
3844
reviewDockModel.nodes['main-tabs'].activeItemId = 'swarm';
3945
reviewDockModel.nodes['side-split'].sizes = [0.42, 0.58];
4046

resources/scss/src/dashboard/Container.scss

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,63 @@
1414
cursor: ns-resize;
1515
}
1616
}
17+
18+
// Demo-consumable minimal hooks only — the real visual language lands with the F-tranche.
19+
// Flow direction comes from the rail's layout (vbox/vertical, hbox/horizontal edges); the
20+
// buttons bring their own base theming — these hooks only shape the strip.
21+
.neo-dashboard-dock-edge-rail {
22+
flex-shrink: 0;
23+
gap : 2px;
24+
25+
&-left,
26+
&-right {
27+
width: 14px;
28+
29+
.neo-dashboard-dock-rail-tab {
30+
writing-mode: vertical-rl;
31+
}
32+
}
33+
34+
&-top,
35+
&-bottom {
36+
height: 14px;
37+
}
38+
}
39+
40+
.neo-dashboard-dock-rail-tab {
41+
font-size: 11px;
42+
padding : 4px 1px;
43+
}
44+
45+
.neo-dashboard-dock-edge-band {
46+
&-left,
47+
&-right {
48+
width: 280px;
49+
}
50+
51+
&-top,
52+
&-bottom {
53+
height: 200px;
54+
}
55+
}
56+
57+
.neo-dashboard-dock-reveal-overlay {
58+
--neo-dock-reveal-ms : 200ms;
59+
--neo-dock-dismiss-ms: 160ms;
60+
61+
position: absolute;
62+
z-index : 20;
63+
64+
&-hidden {
65+
display: none;
66+
}
67+
68+
&-left { inset: 0 auto 0 14px; }
69+
&-right { inset: 0 14px 0 auto; }
70+
&-top { inset: 14px 0 auto 0; }
71+
&-bottom { inset: auto 0 14px 0; }
72+
}
73+
74+
.neo-dashboard-dock-reveal-header {
75+
gap: 4px;
76+
}

src/dashboard/DockLayoutAdapter.mjs

Lines changed: 119 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Base from '../core/Base.mjs';
2+
import DockRail from './DockRail.mjs';
23
import DockSplitter from './DockSplitter.mjs';
34
import DockTabSortZone from './DockTabSortZone.mjs';
45
import DockZoneModel from './DockZoneModel.mjs';
@@ -208,6 +209,8 @@ class DockLayoutAdapter extends Base {
208209

209210
return this.projectNode(model.root, {
210211
applyDockZoneOperation : options.applyDockZoneOperation,
212+
autoHideRevealOnHover : options.autoHideRevealOnHover === true,
213+
defaultRevealFraction : Number.isFinite(options.defaultRevealFraction) ? options.defaultRevealFraction : null,
211214
dockZoneDocument : options.dockZoneDocument || model,
212215
items : model.items || {},
213216
nodes : model.nodes,
@@ -257,10 +260,12 @@ class DockLayoutAdapter extends Base {
257260
}
258261

259262
/**
260-
* Projects one auto-hidden item id into a collapsed rail-tab affordance.
263+
* Projects one auto-hidden item id into the rail-tab metadata `DockRail` renders.
261264
*
262-
* The rail tab carries stable `dockItemId` + `dockEdge` metadata so a later reveal/pin slice
263-
* can convert a click into a transient reveal or a `setItemPinned` operation.
265+
* The metadata carries stable `dockItemId` + `dockEdge` so the rail's click (and the follow-up
266+
* reveal/pin slice) can address the item semantically, plus the `restorable` policy projection
267+
* (`pinnable !== false`) — a tab whose restore the model would reject renders disabled instead
268+
* of lying about the affordance.
264269
* No DOMRect, hover, or open geometry is emitted — reveal/open state stays runtime-only per the
265270
* JSON-first guardrail (HarnessDockZoneModel.md §Serializable vs Runtime).
266271
* @param {String} itemId
@@ -274,18 +279,21 @@ class DockLayoutAdapter extends Base {
274279
let item = context.items[itemId] || {};
275280

276281
return {
277-
cls : ['neo-dashboard-dock-rail-tab'],
278-
data : {dockEdge: edge, dockItemId: itemId, dockRailTab: true},
279-
dockEdge : edge,
280-
dockItemId : itemId,
281-
dockNodeType: 'edge-rail-tab',
282-
ntype : 'button',
283-
text : item.title || itemId
282+
dockEdge : edge,
283+
dockItemId: itemId,
284+
restorable: item.pinnable !== false,
285+
title : item.title || itemId
284286
}
285287
}
286288

287289
/**
288-
* Projects a set of auto-hidden item ids into a thin edge-rail strip for the owning edge.
290+
* Projects a set of auto-hidden item ids into a `Neo.dashboard.DockRail` affordance for the
291+
* owning edge.
292+
*
293+
* Mirrors `createSplitterAffordance()`: the reducer callbacks thread from projection context into
294+
* the component so restore commits ride the workspace's single operation path — no parallel
295+
* mutation grammar. Rail extent and tab writing-mode are CSS concerns keyed off the per-edge cls
296+
* hook (JSON-first: no pixel geometry in the projection).
289297
* @param {String[]} itemIds
290298
* @param {String} edge One of `top`, `right`, `bottom`, `left`.
291299
* @param {Object} context
@@ -294,16 +302,75 @@ class DockLayoutAdapter extends Base {
294302
* @static
295303
*/
296304
static createEdgeRail(itemIds, edge, context) {
297-
let isVertical = edge === 'left' || edge === 'right';
298-
299305
return {
300-
cls : ['neo-dashboard-dock-edge-rail', `neo-dashboard-dock-edge-rail-${edge}`],
301-
dockEdge : edge,
302-
dockNodeType: 'edge-rail',
303-
items : itemIds.map(itemId => this.createRailTab(itemId, edge, context)),
304-
layout : {ntype: isVertical ? 'vbox' : 'hbox', align: 'start'},
305-
ntype : 'container'
306+
applyDockZoneOperation : context.applyDockZoneOperation,
307+
autoHideRevealOnHover : context.autoHideRevealOnHover === true,
308+
defaultRevealFraction : context.defaultRevealFraction ?? null,
309+
dockEdge : edge,
310+
dockNodeType : 'edge-rail',
311+
dockZoneDocument : context.dockZoneDocument,
312+
edge,
313+
module : DockRail,
314+
ntype : 'dashboard-dock-rail',
315+
onDockZoneDocumentChange: context.onDockZoneDocumentChange,
316+
railItems : itemIds.map(itemId => this.createRailTab(itemId, edge, context)),
317+
resolveComponentRef : context.resolveComponentRef
318+
}
319+
}
320+
321+
/**
322+
* Resolves the reveal overlay's free-dimension extent for an item: the share its owning
323+
* subtree holds at the nearest ancestor split, per that split's committed `sizes` — the
324+
* "last committed extent, still in the document" rule. Returns `null` when no ancestor split
325+
* carries usable sizes (e.g. a tabs node sitting directly in an edge-zone slot); the overlay
326+
* then falls back to its workspace-configurable default fraction. Never reads DOM geometry.
327+
* @param {Object} model Committed dock-zone document.
328+
* @param {String} itemId
329+
* @returns {Number|null} Fraction in `(0, 1]`, or `null`.
330+
* @static
331+
*/
332+
static resolveRevealExtent(model, itemId) {
333+
let nodes = model?.nodes || {},
334+
childId = Object.keys(nodes).find(nodeId =>
335+
nodes[nodeId].type === 'tabs' && (nodes[nodeId].items || []).includes(itemId)),
336+
parent;
337+
338+
const findParent = id => {
339+
for (const [nodeId, node] of Object.entries(nodes)) {
340+
if (node.type === 'split' && (node.children || []).includes(id)) {
341+
return {index: node.children.indexOf(id), nodeId, split: true}
342+
}
343+
if (node.type === 'edge-zone' && Object.values(node.zones || {}).includes(id)) {
344+
return {nodeId, split: false}
345+
}
346+
}
347+
return null
348+
};
349+
350+
while (childId) {
351+
parent = findParent(childId);
352+
353+
if (!parent) {
354+
return null
355+
}
356+
357+
if (parent.split) {
358+
let sizes = nodes[parent.nodeId].sizes;
359+
360+
if (Array.isArray(sizes) && sizes.length) {
361+
let total = sizes.reduce((sum, value) => sum + (Number(value) || 0), 0),
362+
share = Number(sizes[parent.index]);
363+
364+
return total > 0 && Number.isFinite(share) && share > 0 ? share / total : null
365+
}
366+
367+
return null
368+
}
369+
370+
childId = parent.nodeId
306371
}
372+
373+
return null
307374
}
308375

309376
/**
@@ -340,20 +407,27 @@ class DockLayoutAdapter extends Base {
340407
// Pass the railed set down so projectTabsNode drops those items from the tab flow.
341408
let childContext = railedItemIds.size ? {...context, railedItemIds} : context,
342409
middleItems = [],
343-
rows = [];
410+
rows = [],
411+
centerConfig;
344412

345413
if (railsByEdge.left) middleItems.push(this.createEdgeRail(railsByEdge.left, 'left', context));
346-
if (zones.left) middleItems.push(this.projectNode(zones.left, childContext));
347-
if (zones.center) middleItems.push(this.projectNode(zones.center, childContext));
348-
if (zones.right) middleItems.push(this.projectNode(zones.right, childContext));
414+
if (zones.left) middleItems.push(this.projectEdgeBand(zones.left, 'left', childContext));
415+
416+
if (zones.center) {
417+
centerConfig = this.projectNode(zones.center, childContext);
418+
centerConfig.flex = 1;
419+
middleItems.push(centerConfig)
420+
}
421+
422+
if (zones.right) middleItems.push(this.projectEdgeBand(zones.right, 'right', childContext));
349423
if (railsByEdge.right) middleItems.push(this.createEdgeRail(railsByEdge.right, 'right', context));
350424

351425
if (railsByEdge.top) {
352426
rows.push(this.createEdgeRail(railsByEdge.top, 'top', context))
353427
}
354428

355429
if (zones.top) {
356-
rows.push(this.projectNode(zones.top, childContext))
430+
rows.push(this.projectEdgeBand(zones.top, 'top', childContext))
357431
}
358432

359433
if (middleItems.length === 1) {
@@ -369,7 +443,7 @@ class DockLayoutAdapter extends Base {
369443
}
370444

371445
if (zones.bottom) {
372-
rows.push(this.projectNode(zones.bottom, childContext))
446+
rows.push(this.projectEdgeBand(zones.bottom, 'bottom', childContext))
373447
}
374448

375449
if (railsByEdge.bottom) {
@@ -386,6 +460,26 @@ class DockLayoutAdapter extends Base {
386460
}
387461
}
388462

463+
/**
464+
* Marks an edge-band zone projection: bands keep a fixed cross-extent (the
465+
* `neo-dashboard-dock-edge-band(-edge)` CSS hooks) instead of flexing against the center —
466+
* an unsized band silently eats workspace geometry the center owns.
467+
* @param {String} zoneId
468+
* @param {String} edge One of `top`, `right`, `bottom`, `left`.
469+
* @param {Object} context
470+
* @returns {Object}
471+
* @protected
472+
* @static
473+
*/
474+
static projectEdgeBand(zoneId, edge, context) {
475+
let config = this.projectNode(zoneId, context);
476+
477+
config.cls = [...(config.cls || []), 'neo-dashboard-dock-edge-band', `neo-dashboard-dock-edge-band-${edge}`];
478+
config.flex = 'none';
479+
480+
return config
481+
}
482+
389483
/**
390484
* Projects one dock item id into a Neo config or recoverable placeholder.
391485
* @param {String} itemId

0 commit comments

Comments
 (0)