A Dock host cannot put its own actions into a projected tab header. The tab subsystem supports it —
tab.Container#headerActions is a public reactive config, and #17874 makes focus gating a general
showOnFocus — but the Dock's projection owns that slot and admits exactly one action.
Three gaps, all in the projection
| # |
site |
what it does |
| 1 |
dashboard/dock/projection/LayoutAdapter.mjs:942 |
builds headerActions only from enableDockCloseAction; no option carries additional actions |
| 2 |
dashboard/dock/projection/LayoutAdapter.mjs:1012 |
the headerAction listener forwards only data.action === 'close'; every other intent is swallowed |
| 3 |
dashboard/dock/Workspace.mjs:1505 |
onDockHeaderAction is wired inside the enableDockCloseAction spread, so a host wanting custom actions without the close action gets no handler at all |
Gap 2 is the one that makes a workaround impossible: even if a host got an action rendered, pressing
it would reach nothing.
What already works, and should not be rebuilt
enableDockCloseAction projects a persistent close action, routes it through the model as
closeItem on the resolved active item, owns focus succession, and keeps hidden synchronised
against each item's closable policy. A host wanting only "an always-visible close button that
closes the active tab" needs no new engine code — just the flag.
- Focus gating is generic after #17874: an action carrying
showOnFocus: true (the tab header
default) is exposed while its TabContainer holds focus. No Dock-specific gating is needed.
So this ticket is only about admitting host-authored actions into the projection and routing their
intent back out.
Shape
getDockProjectionOptions() is the documented host hook ("the default contributes nothing"), so the
option should arrive through it and be threaded into LayoutAdapter.project. Per-tabs-node
resolution matters: a host will want different actions on different zones, so an array alone is
likely too weak — a resolver receiving the node id is the shape to weigh against it.
Intent should travel the existing onDockHeaderAction seam rather than a parallel callback; the
Workspace already returns null for actions it does not own, so forwarding everything is safe and
keeps one path.
Contract Ledger
Added 2026-08-30 at reviewer request (PR #17879 RA-1). This is the consumed surface the
implementation is held to; the PR reconciles against these rows.
resolveDockHeaderActions — the host resolver
| aspect |
contract |
| supplied via |
DockWorkspace#getDockProjectionOptions(), the documented host hook. Absent ⇒ projection byte-identical to before this feature. |
| signature |
(nodeId: String) => Object[] | null | undefined |
why nodeId alone |
The active item is deliberately not passed. A resolver that could see it would be invited to return a per-item action LIST, and a list that changes between projections replaces the action group — destroying the stable instances actionVisibilityChange consumers such as tab Overflow bind to. Per-item behaviour belongs on the instance. |
| return |
Action configs in the shape toolbar.Base#createActionItemConfig accepts. null / undefined / [] all mean "no host actions for this node". |
| lifecycle |
Node-static. Resolved per tabs node; the resulting action instances live for that node's retained lifetime and are not rebuilt on reprojection. Vary an action per active item by moving hidden / disabled on its stable instance — the mechanism Workspace#syncDockCloseAction already uses for the engine's own close action. |
| re-resolution |
The resolver IS asked again when a commit re-projects a node — measured, not assumed. For a retained node the returned set is then discarded: the existing action instances win. So a resolver returning a different set on a later call has no effect, which is the mechanical reason node-static is a contract the machinery can actually keep rather than a convention. |
| ordering |
Host actions project before the close action, so close keeps the rightmost position it already occupies for every consumer that enables it. |
| focus gating |
Inherited from the tab header's actionDefaults (showOnFocus: true), so a host action is exposed with its TabContainer unless it opts out. No Dock-specific gating exists. |
Name rules — enforced, not documented
| rule |
behaviour |
every host action carries a semantic action name |
missing ⇒ throws at projection |
| names unique per tabs node |
duplicate ⇒ throws, naming the action and the node |
close reserved while enableDockCloseAction is on |
⇒ throws, naming the node |
close usable when enableDockCloseAction is off |
allowed — scoped rule, nothing to shadow |
Throwing rather than dropping is deliberate and matches toolbar.Base#createActionItemConfig, which
already throws for an unknown action and for an icon-only action with no accessible name. Names
address actions — getActionItem(name) returns the first match and intents route by name — so a
duplicate leaves one control permanently unreachable, and a host close projecting first would
capture both the engine's hidden policy sync and its close intent. Both failures are silent to a
host that only sees a header missing a control.
Intent — the dockHeaderAction event
| aspect |
contract |
| emitted by |
DockWorkspace#onDockHeaderAction, for every action this class does not own |
| owned by the class |
exactly close, and only while enableDockCloseAction is on; that one is routed through the model and is not re-emitted |
| payload |
{action, dockNodeId, tabContainer} plus the emitter's own source (the workspace id), added by fire |
| return |
onDockHeaderAction returns null for an unowned action — the workspace declines to act, it does not swallow |
| host requirement |
none: listen on the workspace. No subclass and no protected-method override. |
| wiring |
bound unconditionally, so a host projecting only its own actions — with the close action off — still receives intents. |
Acceptance Criteria
Out of Scope
- The gating vocabulary itself — #17874 owns
showOnFocus.
- Changing close-action semantics.
A Dock host cannot put its own actions into a projected tab header. The tab subsystem supports it —
tab.Container#headerActionsis a public reactive config, and #17874 makes focus gating a generalshowOnFocus— but the Dock's projection owns that slot and admits exactly one action.Three gaps, all in the projection
dashboard/dock/projection/LayoutAdapter.mjs:942headerActionsonly fromenableDockCloseAction; no option carries additional actionsdashboard/dock/projection/LayoutAdapter.mjs:1012headerActionlistener forwards onlydata.action === 'close'; every other intent is swalloweddashboard/dock/Workspace.mjs:1505onDockHeaderActionis wired inside theenableDockCloseActionspread, so a host wanting custom actions without the close action gets no handler at allGap 2 is the one that makes a workaround impossible: even if a host got an action rendered, pressing it would reach nothing.
What already works, and should not be rebuilt
enableDockCloseActionprojects a persistent close action, routes it through the model ascloseItemon the resolved active item, owns focus succession, and keepshiddensynchronised against each item'sclosablepolicy. A host wanting only "an always-visible close button that closes the active tab" needs no new engine code — just the flag.showOnFocus: true(the tab header default) is exposed while its TabContainer holds focus. No Dock-specific gating is needed.So this ticket is only about admitting host-authored actions into the projection and routing their intent back out.
Shape
getDockProjectionOptions()is the documented host hook ("the default contributes nothing"), so the option should arrive through it and be threaded intoLayoutAdapter.project. Per-tabs-node resolution matters: a host will want different actions on different zones, so an array alone is likely too weak — a resolver receiving the node id is the shape to weigh against it.Intent should travel the existing
onDockHeaderActionseam rather than a parallel callback; the Workspace already returnsnullfor actions it does not own, so forwarding everything is safe and keeps one path.Contract Ledger
Added 2026-08-30 at reviewer request (PR #17879 RA-1). This is the consumed surface the implementation is held to; the PR reconciles against these rows.
resolveDockHeaderActions— the host resolverDockWorkspace#getDockProjectionOptions(), the documented host hook. Absent ⇒ projection byte-identical to before this feature.(nodeId: String) => Object[] | null | undefinednodeIdaloneactionVisibilityChangeconsumers such as tab Overflow bind to. Per-item behaviour belongs on the instance.toolbar.Base#createActionItemConfigaccepts.null/undefined/[]all mean "no host actions for this node".hidden/disabledon its stable instance — the mechanismWorkspace#syncDockCloseActionalready uses for the engine's own close action.node-staticis a contract the machinery can actually keep rather than a convention.closekeeps the rightmost position it already occupies for every consumer that enables it.actionDefaults(showOnFocus: true), so a host action is exposed with its TabContainer unless it opts out. No Dock-specific gating exists.Name rules — enforced, not documented
actionnameclosereserved whileenableDockCloseActionis oncloseusable whenenableDockCloseActionis offThrowing rather than dropping is deliberate and matches
toolbar.Base#createActionItemConfig, which already throws for an unknown action and for an icon-only action with no accessible name. Names address actions —getActionItem(name)returns the first match and intents route by name — so a duplicate leaves one control permanently unreachable, and a hostcloseprojecting first would capture both the engine'shiddenpolicy sync and its close intent. Both failures are silent to a host that only sees a header missing a control.Intent — the
dockHeaderActioneventDockWorkspace#onDockHeaderAction, for every action this class does not ownclose, and only whileenableDockCloseActionis on; that one is routed through the model and is not re-emitted{action, dockNodeId, tabContainer}plus the emitter's ownsource(the workspace id), added byfireonDockHeaderActionreturnsnullfor an unowned action — the workspace declines to act, it does not swallowAcceptance Criteria
close.enableDockCloseAction: both present together, and either usable alone. In particular a host with custom actions and no close action still receives intents.actionVisibilityChangeconsumers such as tab Overflow keep working.Out of Scope
showOnFocus.