Toolbar actions gate on contextual: true, a boolean that says an action is gated but never by
what. Reading it means chasing toolbar.Base#contextualActionsVisible into tab.Container's three
hand-rolled focus handlers and then into isBodyFocusPath.
Three things follow, and the third is the reason this is worth doing rather than a rename.
1. The name is out of step with the engine's own vocabulary
form.field.trigger.Base already ships showOnHover — a per-item visibility trigger, defaulted
true on CopyToClipboard, consumed by Clear. The engine has a showOnX idiom for exactly this
concept; contextual is the odd one out, and a second name for one idea is a lookup every reader pays.
2. The gate is wired bespoke, so nothing but a TabContainer can use it
contextualActionsVisible has exactly one driver in the codebase — tab/Container.mjs, three
hand-rolled handlers. Any other toolbar setting contextual: true gets an action hidden forever,
because nothing drives its gate and the config defaults to false. A silent, permanently-absent
action is the worst available failure for a visibility feature: nothing errors, it simply never
appears.
Worse, the wiring re-derives from focus events what the framework already maintains as state.
manager.Focus.setComponentFocus keeps containsFocus — a reactive config on
component.Base — current on every component in the focus path. The gate is a subscription to
existing state, not a second interpretation of focus events.
3. Gating on the tab BODY is wrong, and it is the bug under the ugliness
isBodyFocusPath arms only when focus reaches the body container. That means a tab whose body
holds nothing focusable can never expose its own header actions — the actions exist, are correct,
and are unreachable. It also splits one concept across two edges (arm on body, disarm on the whole
container) purely to stop an action vanishing as the pointer reaches it.
The subject should be the TabContainer: focus anywhere inside it — a tab button, the body, one of
these actions — exposes the gated actions; only leaving hides them.
That also makes the two edges collapse into one subject, which is a property of manager.Focus
rather than a simplification: it fires focus transitions only up to the closest component common to
the old and new paths. Focus moving within the subject never clears the subject's own
containsFocus, so an action cannot vanish from under the pointer reaching for it — no arm/disarm
split required.
For a tab header toolbar the TabContainer is simply its parent, so the general default — observe
the parent — is exactly right, and tab.Container needs no focus code at all.
Scope
- Add
showOnFocus as the action-level config. Keep contextual working as a deprecated alias so
the shipped consumers (code/LivePreview.mjs, dashboard/dock/projection/LayoutAdapter.mjs) are
unaffected.
- Give
toolbar.Base a focusSubjectId defaulting to its parent, and drive
contextualActionsVisible by observing the subject's reactive containsFocus via observeConfig,
which also ties the subscription to the toolbar's lifecycle.
- Delete
tab.Container's onFocusEnter / onFocusMove / onFocusLeave / isBodyFocusPath.
Acceptance Criteria
Contract Ledger
Added 2026-08-30 at reviewer request (PR #17875 RA-2). This is the surface the implementation is held
to; the PR reconciles against these rows.
Action-level gate
| key |
type |
status |
contract |
showOnFocus |
Boolean |
current |
true ⇒ the action is exposed only while its toolbar's focus subject holds focus. Named to match the engine's existing showOnHover on form.field.trigger.Base. |
contextual |
Boolean |
deprecated alias |
Accepted as INPUT only. It is resolved into showOnFocus and deleted; no instance ever carries both, so no reader reconciles two spellings. |
Precedence — explicit, not positional
A config spread cannot express this: showOnFocus and contextual are different keys, so both
survive it and whichever deprecated value happened to be truthy would decide. Resolution is
therefore an ordered chain, nearest-first and current-name-before-alias:
| # |
source |
key |
| 1 |
the action's own config |
showOnFocus |
| 2 |
the action's own config |
contextual |
| 3 |
actionDefaults |
showOnFocus |
| 4 |
actionDefaults |
contextual |
| — |
none of the above |
no gate key is set; the action is persistent |
Rows 3–4 are the case with no "own" value to prefer — a subclass whose actionDefaults carry both
keys. The current key decides there too, in both directions.
tab.header.Toolbar sets actionDefaults: {showOnFocus: true}, so a tab header action is gated
unless it opts out. The two shipped callers (code/LivePreview.mjs, dashboard/dock/projection/LayoutAdapter.mjs)
opt out with the deprecated spelling and stay persistent.
Focus subject
| aspect |
contract |
focusSubjectId |
Component whose focus exposes this toolbar's gated actions. null resolves to the toolbar's parent — for a tab header toolbar that is the TabContainer itself. |
| resolution timing |
Idempotent and retried: attempted at the toolbar's onConstructed and again on mount, and by the owner once its children exist. A subject that cannot be resolved leaves the toolbar unwired rather than marked done. |
| both edges, one subject |
manager.Focus fires transitions only up to the closest component common to the old and new paths. Focus moving anywhere INSIDE the subject — including onto one of these actions — never clears the subject's containsFocus, so an action cannot vanish from under the pointer reaching for it. Only leaving the subject re-hides them. |
Visibility lifecycle
| aspect |
contract |
| source of truth |
The subject's reactive containsFocus config, consumed via observeConfig() — not re-derived from focusEnter / focusLeave handlers. The subscription is tied to the toolbar's lifecycle. |
| initial adoption |
A subject already holding focus at wiring time is adopted once, because a subscription reports only changes. |
contextualActionsVisible |
Remains a public reactive config, so a composition whose exposing signal is not focus can still drive it directly. |
| hidden representation |
visibility: hidden + pointer-events: none, plus aria-hidden, inert, tabIndex: -1. Layout extent is preserved, so arming and disarming never reflow the toolbar. Consumer-owned hidden and disabled state is untouched. |
Out of Scope
- Adding extra actions to Dock-projected tab headers.
LayoutAdapter builds headerActions only
from its close action and forwards only the close intent; that seam is a separate leaf.
- Other trigger kinds (
showOnHover for toolbar actions, selection- or edit-gated actions). The
subject model leaves room for them; none is implemented here.
Toolbar actions gate on
contextual: true, a boolean that says an action is gated but never by what. Reading it means chasingtoolbar.Base#contextualActionsVisibleintotab.Container's three hand-rolled focus handlers and then intoisBodyFocusPath.Three things follow, and the third is the reason this is worth doing rather than a rename.
1. The name is out of step with the engine's own vocabulary
form.field.trigger.Basealready shipsshowOnHover— a per-item visibility trigger, defaultedtrueonCopyToClipboard, consumed byClear. The engine has ashowOnXidiom for exactly this concept;contextualis the odd one out, and a second name for one idea is a lookup every reader pays.2. The gate is wired bespoke, so nothing but a TabContainer can use it
contextualActionsVisiblehas exactly one driver in the codebase —tab/Container.mjs, three hand-rolled handlers. Any other toolbar settingcontextual: truegets an action hidden forever, because nothing drives its gate and the config defaults tofalse. A silent, permanently-absent action is the worst available failure for a visibility feature: nothing errors, it simply never appears.Worse, the wiring re-derives from focus events what the framework already maintains as state.
manager.Focus.setComponentFocuskeepscontainsFocus— a reactive config oncomponent.Base— current on every component in the focus path. The gate is a subscription to existing state, not a second interpretation of focus events.3. Gating on the tab BODY is wrong, and it is the bug under the ugliness
isBodyFocusPatharms only when focus reaches the body container. That means a tab whose body holds nothing focusable can never expose its own header actions — the actions exist, are correct, and are unreachable. It also splits one concept across two edges (arm on body, disarm on the whole container) purely to stop an action vanishing as the pointer reaches it.The subject should be the TabContainer: focus anywhere inside it — a tab button, the body, one of these actions — exposes the gated actions; only leaving hides them.
That also makes the two edges collapse into one subject, which is a property of
manager.Focusrather than a simplification: it fires focus transitions only up to the closest component common to the old and new paths. Focus moving within the subject never clears the subject's owncontainsFocus, so an action cannot vanish from under the pointer reaching for it — no arm/disarm split required.For a tab header toolbar the TabContainer is simply its parent, so the general default — observe the parent — is exactly right, and
tab.Containerneeds no focus code at all.Scope
showOnFocusas the action-level config. Keepcontextualworking as a deprecated alias so the shipped consumers (code/LivePreview.mjs,dashboard/dock/projection/LayoutAdapter.mjs) are unaffected.toolbar.BaseafocusSubjectIddefaulting to its parent, and drivecontextualActionsVisibleby observing the subject's reactivecontainsFocusviaobserveConfig, which also ties the subscription to the toolbar's lifecycle.tab.Container'sonFocusEnter/onFocusMove/onFocusLeave/isBodyFocusPath.Acceptance Criteria
showOnFocus: trueand is exposed while its toolbar's focus subject holds focus.containsFocusconfig; it does not re-derive focus state fromfocusEnter/focusLeavehandlers.contextual: truekeeps working as a deprecated alias, and an explicit opt-out in either spelling outranksactionDefaults— proven for a subclass whose defaults still use the old spelling, since the two keys otherwise both survive the config spread.toolbar.Basewith ashowOnFocusaction is exposed when its parent gains focus and hidden when the parent loses it — i.e. the capability works with no TabContainer involved.tab.Containerno longer implements focus handlers orisBodyFocusPathfor this purpose.Contract Ledger
Added 2026-08-30 at reviewer request (PR #17875 RA-2). This is the surface the implementation is held to; the PR reconciles against these rows.
Action-level gate
showOnFocustrue⇒ the action is exposed only while its toolbar's focus subject holds focus. Named to match the engine's existingshowOnHoveronform.field.trigger.Base.contextualshowOnFocusand deleted; no instance ever carries both, so no reader reconciles two spellings.Precedence — explicit, not positional
A config spread cannot express this:
showOnFocusandcontextualare different keys, so both survive it and whichever deprecated value happened to be truthy would decide. Resolution is therefore an ordered chain, nearest-first and current-name-before-alias:showOnFocuscontextualactionDefaultsshowOnFocusactionDefaultscontextualRows 3–4 are the case with no "own" value to prefer — a subclass whose
actionDefaultscarry both keys. The current key decides there too, in both directions.tab.header.ToolbarsetsactionDefaults: {showOnFocus: true}, so a tab header action is gated unless it opts out. The two shipped callers (code/LivePreview.mjs,dashboard/dock/projection/LayoutAdapter.mjs) opt out with the deprecated spelling and stay persistent.Focus subject
focusSubjectIdnullresolves to the toolbar's parent — for a tab header toolbar that is the TabContainer itself.onConstructedand again on mount, and by the owner once its children exist. A subject that cannot be resolved leaves the toolbar unwired rather than marked done.manager.Focusfires transitions only up to the closest component common to the old and new paths. Focus moving anywhere INSIDE the subject — including onto one of these actions — never clears the subject'scontainsFocus, so an action cannot vanish from under the pointer reaching for it. Only leaving the subject re-hides them.Visibility lifecycle
containsFocusconfig, consumed viaobserveConfig()— not re-derived fromfocusEnter/focusLeavehandlers. The subscription is tied to the toolbar's lifecycle.contextualActionsVisiblevisibility: hidden+pointer-events: none, plusaria-hidden,inert,tabIndex: -1. Layout extent is preserved, so arming and disarming never reflow the toolbar. Consumer-ownedhiddenanddisabledstate is untouched.Out of Scope
LayoutAdapterbuildsheaderActionsonly from its close action and forwards only thecloseintent; that seam is a separate leaf.showOnHoverfor toolbar actions, selection- or edit-gated actions). The subject model leaves room for them; none is implemented here.