Context
Measured on dev e3d83f1b91 with the #19281 fix applied locally (a drag hands focus back to the dragged tab), in a probe copy of TabHeaderActionsNL (headless, --workers=1, runtime root bound). The arm drops the third tab, the header overflows because an action grew mid-drag, and the projection withholds the trailing tab into the More tabs menu:
| moment |
active element |
bar children |
gated previous-tab nodes |
| 120 ms after the drop |
BODY |
tab 1, tab 2, spacer, next-tab, previous-tab (tab 3 withheld) |
1 |
after More tabs appeared |
BODY |
same |
1 |
| after the bar widened to 500 px |
BODY |
tab 1, tab 2, tab 3, spacer, next-tab |
0 |
The drop had focused tab 3 (the #19281 restore; the gated action is revealed, so the container held focus for a moment). The projection then removed tab 3's node, the browser moved focus to the body, and once manager.Focus delivered the leave the container withdrew its contextual actions again.
A user reaches the same state without a drag: focus a tab that is not the active one, then narrow the window until it overflows.
The Problem
Neo.tab.plugin.Overflow keeps exactly one tab out of the hidden range, the active one (computeOverflow({items, extent, activeItemId, controlWidth}), src/tab/plugin/Overflow.mjs:77–:129: "The active tab id — never hidden"). A tab that holds focus but is not active is withheld like any other, and withholding removes its node (removeDom), so the browser's focus falls to the body. Nothing moves it to the More tabs control, to the active tab, or keeps the focused tab visible. The same class as #19281 (focus dropped by a node the engine hides), one layer up.
The Architectural Reality
computeOverflow is the pure partition (:81), re-run on resize, on activeIndexChange (:384–:386) and on mutations; the plugin already treats one id as protected and re-adds its width to the visible range (:108–:129).
containsFocus_ on component.Base (:104) tells the worker which tab holds focus; the tab container's containsFocus gates the header's contextual actions (toolbar.Base#applyContextualActionState).
- The floating
More tabs control is a body child with the toolbar as logical owner (TabHeaderActionsNL:175–:183), so focusing it keeps the container's focus island intact.
The Fix
Two shapes, the ticket does not pick:
- Keep the focused tab visible:
computeOverflow takes a second never-hidden id (the tab whose containsFocus is true) and protects it the way it protects activeItemId. Symmetric with the existing rule; the header shows one more tab than it otherwise would while that tab is focused.
- Move focus with the projection: when the withheld range contains the focused tab, the projection focuses the
More tabs control (focus(id, false, true, 'keyboard') for a keyboard origin) before removing the node, so focus stays inside the container and the menu is one key away.
Either way: a unit arm on computeOverflow (shape 1) or on the projection's focus hand-off (shape 2), and an e2e arm that focuses a non-active tab, narrows the container, and asserts document.activeElement is inside the tab container afterwards.
Acceptance Criteria
Transfer, 2026-09-26 (Vega, the author): the browser witness this ticket first listed as AC-1 and AC-2 (document.activeElement inside the tab container after the projection, the container's contextual actions kept, red-first with activeElement = body on dev) moved to #19293. Its staging does not hold against the app's current headers and that is the substantive part, so it is a ticket of its own rather than a squeezed arm here. The engine fix and its unit arms close this ticket (PR #19294 at 0969ca62e0); #19293 closes the observable outcome.
Out of Scope
- #19281 (a drag hands focus back at the drop) and #19282 (the consistency checker's blindness to withheld nodes).
Decision Record impact
none
Related
#19281 · #19282 · #17844 · #18069 (withheld nodes have no DOM) · #17875 (focus-gated actions)
unowned-rationale: found while closing #19281 and deliberately not folded into it, since the fix is a design call between two shapes in a different class. One PR either way. Any seat may take it; I take it after #19281 and #19282 if it is still open.
Origin Session ID: e2fd8a01-5bfc-4d82-ad5b-5c889f4b6710
Retrieval Hint: "tab Overflow computeOverflow focused tab withheld removeDom focus body activeItemId never hidden"
Context
Measured on dev
e3d83f1b91with the #19281 fix applied locally (a drag hands focus back to the dragged tab), in a probe copy ofTabHeaderActionsNL(headless,--workers=1, runtime root bound). The arm drops the third tab, the header overflows because an action grew mid-drag, and the projection withholds the trailing tab into theMore tabsmenu:previous-tabnodesBODYnext-tab,previous-tab(tab 3 withheld)More tabsappearedBODYBODYnext-tabThe drop had focused tab 3 (the #19281 restore; the gated action is revealed, so the container held focus for a moment). The projection then removed tab 3's node, the browser moved focus to the body, and once
manager.Focusdelivered the leave the container withdrew its contextual actions again.A user reaches the same state without a drag: focus a tab that is not the active one, then narrow the window until it overflows.
The Problem
Neo.tab.plugin.Overflowkeeps exactly one tab out of the hidden range, the active one (computeOverflow({items, extent, activeItemId, controlWidth}),src/tab/plugin/Overflow.mjs:77–:129: "The active tab id — never hidden"). A tab that holds focus but is not active is withheld like any other, and withholding removes its node (removeDom), so the browser's focus falls to the body. Nothing moves it to theMore tabscontrol, to the active tab, or keeps the focused tab visible. The same class as #19281 (focus dropped by a node the engine hides), one layer up.The Architectural Reality
computeOverflowis the pure partition (:81), re-run on resize, onactiveIndexChange(:384–:386) and on mutations; the plugin already treats one id as protected and re-adds its width to the visible range (:108–:129).containsFocus_oncomponent.Base(:104) tells the worker which tab holds focus; the tab container'scontainsFocusgates the header's contextual actions (toolbar.Base#applyContextualActionState).More tabscontrol is a body child with the toolbar as logical owner (TabHeaderActionsNL:175–:183), so focusing it keeps the container's focus island intact.The Fix
Two shapes, the ticket does not pick:
computeOverflowtakes a second never-hidden id (the tab whosecontainsFocusis true) and protects it the way it protectsactiveItemId. Symmetric with the existing rule; the header shows one more tab than it otherwise would while that tab is focused.More tabscontrol (focus(id, false, true, 'keyboard')for a keyboard origin) before removing the node, so focus stays inside the container and the menu is one key away.Either way: a unit arm on
computeOverflow(shape 1) or on the projection's focus hand-off (shape 2), and an e2e arm that focuses a non-active tab, narrows the container, and assertsdocument.activeElementis inside the tab container afterwards.Acceptance Criteria
computeOverflownever withholds the tab that holds focus: it protectsactiveItemIdtogether with the focused item and fits the pair's combined width, and the displacement of other tabs never evicts the active tab to seat the focused one (unit arms, red-first on the base).project()supplies the focused button fromcontainsFocus, reading it and never acting on it; the degenerate cap gate covers either protected button (unit arms).Transfer, 2026-09-26 (Vega, the author): the browser witness this ticket first listed as AC-1 and AC-2 (
document.activeElementinside the tab container after the projection, the container's contextual actions kept, red-first withactiveElement= body on dev) moved to #19293. Its staging does not hold against the app's current headers and that is the substantive part, so it is a ticket of its own rather than a squeezed arm here. The engine fix and its unit arms close this ticket (PR #19294 at 0969ca62e0); #19293 closes the observable outcome.Out of Scope
Decision Record impact
none
Related
#19281 · #19282 · #17844 · #18069 (withheld nodes have no DOM) · #17875 (focus-gated actions)
unowned-rationale: found while closing #19281 and deliberately not folded into it, since the fix is a design call between two shapes in a different class. One PR either way. Any seat may take it; I take it after #19281 and #19282 if it is still open.
Origin Session ID: e2fd8a01-5bfc-4d82-ad5b-5c889f4b6710 Retrieval Hint: "tab Overflow computeOverflow focused tab withheld removeDom focus body activeItemId never hidden"