The gap
Qt-parity dock UIs ship a maximize affordance on every dock pane; src/dashboard/dock has none — grep -rn maximize src/dashboard/ yields one hit, and it is the assignment-math wording in TopologyReconciler, not a feature. Consumers can hand-roll an overlay through the host header-action seam, but each one re-derives the same design decisions (state, restore, animation, keyboard). The dock should own it, the way it owns close behind enableDockCloseAction.
Design sketch (the pieces already exist)
- Affordance: an engine-owned header action behind
enableDockMaximizeAction, rendered by projection.LayoutAdapter next to close and owned by dashboard.dock.Workspace#onDockHeaderAction rather than re-emitted (the close precedent, including the reserved-name guard the adapter already throws for). Icon pair + toggle semantics as in dialog.Base (maximizeCls/minimizeCls).
- Mechanism — presentation, not topology: maximize the tabs node in place via a class toggle (
neo-dock-maximized) plus a MEASURED rect: position: fixed with top/left/width/height set to the workspace root's viewport rect at toggle time — never inset: 0, which answers to the viewport or an incidental fixed containing block instead of the workspace. The class carries everything static (z-index, transitions); the four rect values are per-instance inline style. The header stays visible carrying the restore button.
- Hard constraint — no DOM re-parenting: a maximized pane must keep DOM identity. Panes hosting an iframe reload their browsing context on re-parent (losing embedded-app state); canvas and video lose state the same way. This rules out floating-container/DOM-move implementations and rules out expressing maximize as a committed topology mutation the reconciler could satisfy by rebuilding nodes.
- State: a workspace-local transient (public
maximizedNodeId, defined as maximizedNodeId_ in static config), deliberately not part of the committed dock document — maximize is presentation. Perspectives, persistence, and topology diffs never see it. Re-projection is deterministic: after any re-projection, the workspace re-applies the maximize presentation iff maximizedNodeId_ still resolves to a projected tabs node, and clears the transient otherwise (the node was closed/merged away) — never a third outcome, never baked into the model.
- Motion: FLIP from the node's pre-maximize rect (the
DockFlip addon already animates dock re-layouts; a first/last transform against one node is its trivial case). Instant-path fallbacks per DockFlip's own contract (prefers-reduced-motion, hidden documents).
- Input contract (frozen, see ledger): in-strip tab reordering stays live while maximized (it never leaves the node); cross-zone drag sources and tear-out affordances for the maximized node are suppressed while maximized — every drop target is under the overlay, so offering the gesture would be fixture-dishonest.
Escape restores, owned by the workspace's KeyboardCommands scope and active only while maximizedNodeId is set; after restore, focus returns to the restored tabs node's active header button. The maximized plane z-orders above rails and the reveal overlay; engaging maximize closes an in-progress reveal (one overlay at a time). Any committed dock operation that reaches beyond the maximized node clears maximize BEFORE applying, and that clear is terminal — the re-projection rule re-applies only a transient that survived, never one an operation cleared. Operations confined to the maximized node (activating one of its tabs; closing, reordering or adding an item within it) do not pre-clear: they defer to the same deterministic rule, which re-applies onto the surviving node and clears when the node collapsed away — without this confinement, switching tabs inside a maximized pane would restore it on the second click of any real session. Double-click on the tab header stays an optional second affordance.
Acceptance Criteria
Contract Ledger (consumed cells)
| Cell |
Producer |
Behavior |
Fallback |
Docs |
Evidence |
| header action surface |
projection.LayoutAdapter renders engine-owned actions next to host actions; ownership dispatch in dashboard.dock.Workspace#onDockHeaderAction (the close precedent, reserved-name guard included) |
enableDockMaximizeAction: false (default) renders nothing — byte-identical projection for every current consumer; true renders the toggle on tabs-node headers |
Off by default |
The option name + the reserved-name guard error text |
close/enableDockCloseAction shipping today; the guard already throws on host-action collisions |
| maximized presentation + geometry |
dashboard.dock.Workspace stamps neo-dock-maximized on the target tabs node's projected root AND supplies the rect: it measures its own root el's viewport rect (DomAccess.getBoundingClientRect on the workspace id — the workspace is the authority, no containing-block assumption) and sets top/left/width/height inline |
The node paints the measured workspace rect; header stays visible carrying the restore toggle; NO DOM re-parenting (spec pins element identity + iframe-not-reloaded). Resize while maximized: a NEW Workspace-owned observation — no existing one covers this: on maximize, the Workspace registers its root el with the ResizeObserver main-thread addon (the grid.Container#addResizeObserver idiom: getAddon('ResizeObserver', windowId) + register({id})); the handler re-measures and re-applies the four values; restore, node-clear, and workspace destroy all unregister — the observation exists exactly as long as the presentation does, no standing cost while un-maximized. Fail-safe: an unresolvable measurement (workspace unmounted mid-gesture, zero-area rect) clears maximizedNodeId and removes the class — the restore path, never a half state |
Cleared state = normal layout |
SCSS block comment naming the no-reparent + measured-rect constraints |
dialog.Base#afterSetMaximized for the cls-toggle half; DockFlip's rect measurement for the measure-then-style idiom |
| input while maximized |
dashboard.dock.Workspace guards its drag-source registration and key scope on maximizedNodeId |
In-strip tab reorder stays live (never leaves the node); cross-zone + tear-out sources for the maximized node suppressed (all drop targets sit under the overlay); Escape restores (workspace KeyboardCommands, armed only while maximized), focus returns to the restored node's active header button; maximize z-orders above rails/reveal overlay and closes an in-progress reveal; a committed dock operation beyond the maximized node clears maximize BEFORE applying, terminally for the re-projection rule; an operation confined to the maximized node (setActiveItem on it, closeItem/moveItem/addTab within it) defers to that rule instead of pre-clearing |
Suppressions lift on restore |
The config jsdoc carries the input contract |
The reveal state machine + KeyboardCommands surfaces exist; suppression is a guard, not new machinery |
| transient state × re-projection |
maximizedNodeId_ on the workspace instance — never written into the committed document |
Deterministic re-projection rule above: re-apply iff the node id resolves in the new projection, clear otherwise. Perspectives capture/restore byte-identical documents regardless of maximize state (AC) |
Cleared state = normal layout |
The config's jsdoc carries the rule |
AC pins document + perspective byte-equality and both re-projection outcomes |
| motion |
main.addon.DockFlip FLIP from the pre-toggle rect |
Instant-path degradations per DockFlip's own contract (prefers-reduced-motion, hidden documents, frame-starved windows) |
Instant land |
DockFlip class jsdoc (already written) |
DockFlip ships with these paths spec-covered |
Origin
Requested as proper engine support after a consumer-workspace exploration (agent session 914dffcd-0057-46a5-a0c6-9ca807fd5c91) concluded a client-side overlay would re-derive dock-owned decisions. Related: #13158 (Qt-parity docking polish epic — this is a sibling capability), #17418 (header-toolbar flat actions substrate), the host header-action seam in LayoutAdapter/Workspace, dialog.Base maximize precedent.
— Vega (Fable 5, Claude Code) 🌿
The gap
Qt-parity dock UIs ship a maximize affordance on every dock pane;
src/dashboard/dockhas none —grep -rn maximize src/dashboard/yields one hit, and it is the assignment-math wording inTopologyReconciler, not a feature. Consumers can hand-roll an overlay through the host header-action seam, but each one re-derives the same design decisions (state, restore, animation, keyboard). The dock should own it, the way it ownsclosebehindenableDockCloseAction.Design sketch (the pieces already exist)
enableDockMaximizeAction, rendered byprojection.LayoutAdapternext tocloseand owned bydashboard.dock.Workspace#onDockHeaderActionrather than re-emitted (thecloseprecedent, including the reserved-name guard the adapter already throws for). Icon pair + toggle semantics as indialog.Base(maximizeCls/minimizeCls).neo-dock-maximized) plus a MEASURED rect:position: fixedwithtop/left/width/heightset to the workspace root's viewport rect at toggle time — neverinset: 0, which answers to the viewport or an incidental fixed containing block instead of the workspace. The class carries everything static (z-index, transitions); the four rect values are per-instance inline style. The header stays visible carrying the restore button.maximizedNodeId, defined asmaximizedNodeId_in static config), deliberately not part of the committed dock document — maximize is presentation. Perspectives, persistence, and topology diffs never see it. Re-projection is deterministic: after any re-projection, the workspace re-applies the maximize presentation iffmaximizedNodeId_still resolves to a projected tabs node, and clears the transient otherwise (the node was closed/merged away) — never a third outcome, never baked into the model.DockFlipaddon already animates dock re-layouts; a first/last transform against one node is its trivial case). Instant-path fallbacks per DockFlip's own contract (prefers-reduced-motion, hidden documents).Escaperestores, owned by the workspace'sKeyboardCommandsscope and active only whilemaximizedNodeIdis set; after restore, focus returns to the restored tabs node's active header button. The maximized plane z-orders above rails and the reveal overlay; engaging maximize closes an in-progress reveal (one overlay at a time). Any committed dock operation that reaches beyond the maximized node clears maximize BEFORE applying, and that clear is terminal — the re-projection rule re-applies only a transient that survived, never one an operation cleared. Operations confined to the maximized node (activating one of its tabs; closing, reordering or adding an item within it) do not pre-clear: they defer to the same deterministic rule, which re-applies onto the surviving node and clears when the node collapsed away — without this confinement, switching tabs inside a maximized pane would restore it on the second click of any real session. Double-click on the tab header stays an optional second affordance.Acceptance Criteria
enableDockMaximizeActionrenders an engine-owned toggle action on tabs-node headers; the reserved-name guard covers it likeclose.Escaperestores.Contract Ledger (consumed cells)
projection.LayoutAdapterrenders engine-owned actions next to host actions; ownership dispatch indashboard.dock.Workspace#onDockHeaderAction(thecloseprecedent, reserved-name guard included)enableDockMaximizeAction: false(default) renders nothing — byte-identical projection for every current consumer;truerenders the toggle on tabs-node headersclose/enableDockCloseActionshipping today; the guard already throws on host-action collisionsdashboard.dock.Workspacestampsneo-dock-maximizedon the target tabs node's projected root AND supplies the rect: it measures its own root el's viewport rect (DomAccess.getBoundingClientRecton the workspace id — the workspace is the authority, no containing-block assumption) and setstop/left/width/heightinlineResizeObservermain-thread addon (thegrid.Container#addResizeObserveridiom:getAddon('ResizeObserver', windowId)+register({id})); the handler re-measures and re-applies the four values; restore, node-clear, and workspace destroy all unregister — the observation exists exactly as long as the presentation does, no standing cost while un-maximized. Fail-safe: an unresolvable measurement (workspace unmounted mid-gesture, zero-area rect) clearsmaximizedNodeIdand removes the class — the restore path, never a half statedialog.Base#afterSetMaximizedfor the cls-toggle half;DockFlip's rect measurement for the measure-then-style idiomdashboard.dock.Workspaceguards its drag-source registration and key scope onmaximizedNodeIdEscaperestores (workspaceKeyboardCommands, armed only while maximized), focus returns to the restored node's active header button; maximize z-orders above rails/reveal overlay and closes an in-progress reveal; a committed dock operation beyond the maximized node clears maximize BEFORE applying, terminally for the re-projection rule; an operation confined to the maximized node (setActiveItem on it, closeItem/moveItem/addTab within it) defers to that rule instead of pre-clearingmaximizedNodeId_on the workspace instance — never written into the committed documentmain.addon.DockFlipFLIP from the pre-toggle rectprefers-reduced-motion, hidden documents, frame-starved windows)Origin
Requested as proper engine support after a consumer-workspace exploration (agent session 914dffcd-0057-46a5-a0c6-9ca807fd5c91) concluded a client-side overlay would re-derive dock-owned decisions. Related: #13158 (Qt-parity docking polish epic — this is a sibling capability), #17418 (header-toolbar flat actions substrate), the host header-action seam in
LayoutAdapter/Workspace,dialog.Basemaximize precedent.— Vega (Fable 5, Claude Code) 🌿