#17844's census lists TabHeaderActionsNL red. Since #19277 its first error is :269: after the drag terminal, no More tabs control appears ("the queued projection drains after SortZone restores natural layout"). Measured today on dev e3d83f1b91 with a probe copy of the spec (headless, --workers=1, runtime root bound), reading document.activeElement and the gated previous-tab action's node count at each step of the arm:
step
active element
previous-tab nodes
before the mouse-down (after the body card click)
the card
1
after mouse.down() on the last tab
the tab button (<button role="tab" tabindex="0">)
1
150 ms later
the tab button
1
after the drag move to the rail
BODY
0
500 ms after mouse.up()
BODY
0
Identical at ab2ae8c73c (2026-09-05), so this is not a recent regression of the drag. The rest of the arm follows from it: the plugin's sortDragProjectionQueued goes true mid-drag and false 500 ms after the terminal, the drain runs, and it measures a header that fits (three 73 px tabs + the 80 px grown action + a 12 px spacer against a 310 px bar). The arm's mid-drag growth overflows that bar only with the 20 px gated action present.
Control: the same probe with one card.click() right after mouse.up() (focus back in the container) passes the entire spec on e3d83f1b91, growth 80 px and all assertions after :269 included.
The Problem
A mouse-down on a tab header focuses its <button>. Neo.draggable.container.SortZone#onDragStart then hides the dragged original (itemStyle.visibility = 'hidden', src/draggable/container/SortZone.mjs:972). The browser moves focus off an element that becomes visibility: hidden, so focus lands on the body; manager.Focus delivers the leave, the TabContainer's containsFocus turns false, and toolbar.Base#applyContextualActionState withdraws the focus-gated actions (node-less since #18069). Nothing at the terminal hands focus back: after the drop the user's focus is on the body.
Three consequences:
A pointer user dragging a tab watches the header's contextual actions vanish mid-drag and stay gone after the drop, although they are still working in that container.
Keyboard focus is lost after every tab drag; a drop should leave focus on the moved item, as a plain click on it would.
The census arm cannot witness the drain: its overflow depends on the gated action staying revealed.
History, from the file's log: the arm was authored 2026-08-20 (#17423) while the actions were persistent; focus gating arrived 2026-08-30 (#17875); a withdrawn action stopped taking rail space 2026-09-01 (#18016), which is when this arm's overflow began depending on focus; the :65 hidden-node read (2026-09-02, #18069) masked it until #19277.
The Architectural Reality
src/draggable/container/SortZone.mjs: onDragStart sets me.dragComponent = draggedItem (:881) and hides it (:972); processDragEnd restores the item styles and the dragged component's visibility (:597–:602), reorders, resets the gesture state and waits one frame (:647) before dragEnd. No focus handling exists anywhere under src/draggable (grep).
Neo.component.Base#focus(id, children, preventScroll, modality) (src/component/Base.mjs:1458) routes to main.DomAccess.focus; modality: 'pointer' suppresses the accidental focus ring, which is what a pointer gesture wants.
containsFocus_ (src/component/Base.mjs:104) is the worker's reactive view of focus ownership, kept by manager.Focus; on the dragged tab it is true at drag start (the mouse-down's focusin has settled, see the table).
tab/header/Button.mjs renders <button role="tab">; test/playwright/setup.mjs:165 stubs Neo.main.DomAccess for the unit tier.
The Fix
src/draggable/container/SortZone.mjs:
onDragStart, beside :881: record whether the gesture took focus, me.dragFocusOwner = draggedItem.containsFocus ? draggedItem : null.
processDragEnd, after the one-frame wait at :647 (the visibility restore has flushed by then): if dragFocusOwner is set and not destroyed, dragFocusOwner.focus(dragFocusOwner.id, false, true, 'pointer'); clear the field. A cancelled drag restores the same way.
Coverage:
test/playwright/unit/draggable/container/SortZone.spec.mjs: one arm records the stub's focus calls, runs a drag start and end for an item with containsFocus: true and asserts exactly one call for that item with modality: 'pointer', then the same for an item without focus and asserts none.
The e2e witness is the census arm itself, unchanged: TabHeaderActionsNL passes at the head with the runtime root bound.
Acceptance Criteria
AC-1: test/playwright/e2e/tab/TabHeaderActionsNL.spec.mjs, unchanged, passes :269 at the head (headless, --workers=1, NEO_AGENTOS_RUNTIME_ROOT bound; CI does not select it) and its first error moves to :288, the consistency read that #19282 owns; with #19282 applied the spec passes. Measured after filing: the restored focus sits on the dropped tab, which the overflow projection then withholds, so focus is lost a second time (#19285); that loss does not reach any assertion once #19282 lands.
AC-2: the unit arm: a drag whose dragged item owned focus at start ends with one DomAccess.focus call for that item, modality pointer; a drag whose item did not own focus ends with none; a cancelled drag behaves like a committed one.
AC-3: red-first: the unit arm fails on dev, and the e2e spec fails at :269 on dev, both with the fix reverted.
Out of Scope
Keeping the gated actions revealed during the drag. That needs either a hiding mechanism the browser does not blur for (visibility: hidden is what removes the original from the accessibility tree today) or parking focus on a still-visible node at drag start; a separate call, once the terminal is right.
verifyComponentConsistency reporting a removeDom vdom node as a DOM mismatch: #19282. The projection withholding a focused tab and dropping focus to the body: #19285.
#17844 (the census row) · #19282 (the checker) · #19285 (the overflow projection's focus) · #19276 / #19277 (the read that masked this arm) · #17423 (the arm's origin) · #17875 (focus gating) · #18016 (a withdrawn action takes no rail space) · #18069 (a withdrawn action has no node)
Origin Session ID: e2fd8a01-5bfc-4d82-ad5b-5c889f4b6710
Retrieval Hint: "tab drag focus body SortZone dragFocusOwner TabHeaderActionsNL More tabs drain"
tobiu referenced in commit 8feb763 - "fix(draggable): a drag that took focus with its item hands it back at the drop (#19281) (#19286) on Sep 26, 2026, 3:49 PM
Context
#17844's census lists
TabHeaderActionsNLred. Since #19277 its first error is:269: after the drag terminal, noMore tabscontrol appears ("the queued projection drains after SortZone restores natural layout"). Measured today on deve3d83f1b91with a probe copy of the spec (headless,--workers=1, runtime root bound), readingdocument.activeElementand the gatedprevious-tabaction's node count at each step of the arm:previous-tabnodesmouse.down()on the last tab<button role="tab" tabindex="0">)BODYmouse.up()BODYIdentical at
ab2ae8c73c(2026-09-05), so this is not a recent regression of the drag. The rest of the arm follows from it: the plugin'ssortDragProjectionQueuedgoestruemid-drag andfalse500 ms after the terminal, the drain runs, and it measures a header that fits (three 73 px tabs + the 80 px grown action + a 12 px spacer against a 310 px bar). The arm's mid-drag growth overflows that bar only with the 20 px gated action present.Control: the same probe with one
card.click()right aftermouse.up()(focus back in the container) passes the entire spec one3d83f1b91, growth 80 px and all assertions after:269included.The Problem
A mouse-down on a tab header focuses its
<button>.Neo.draggable.container.SortZone#onDragStartthen hides the dragged original (itemStyle.visibility = 'hidden',src/draggable/container/SortZone.mjs:972). The browser moves focus off an element that becomesvisibility: hidden, so focus lands on the body;manager.Focusdelivers the leave, theTabContainer'scontainsFocusturns false, andtoolbar.Base#applyContextualActionStatewithdraws the focus-gated actions (node-less since #18069). Nothing at the terminal hands focus back: after the drop the user's focus is on the body.Three consequences:
History, from the file's log: the arm was authored 2026-08-20 (#17423) while the actions were persistent; focus gating arrived 2026-08-30 (#17875); a withdrawn action stopped taking rail space 2026-09-01 (#18016), which is when this arm's overflow began depending on focus; the
:65hidden-node read (2026-09-02, #18069) masked it until #19277.The Architectural Reality
src/draggable/container/SortZone.mjs:onDragStartsetsme.dragComponent = draggedItem(:881) and hides it (:972);processDragEndrestores the item styles and the dragged component's visibility (:597–:602), reorders, resets the gesture state and waits one frame (:647) beforedragEnd. No focus handling exists anywhere undersrc/draggable(grep).Neo.component.Base#focus(id, children, preventScroll, modality)(src/component/Base.mjs:1458) routes tomain.DomAccess.focus;modality: 'pointer'suppresses the accidental focus ring, which is what a pointer gesture wants.containsFocus_(src/component/Base.mjs:104) is the worker's reactive view of focus ownership, kept bymanager.Focus; on the dragged tab it istrueat drag start (the mouse-down's focusin has settled, see the table).tab/header/Button.mjsrenders<button role="tab">;test/playwright/setup.mjs:165stubsNeo.main.DomAccessfor the unit tier.The Fix
src/draggable/container/SortZone.mjs:onDragStart, beside:881: record whether the gesture took focus,me.dragFocusOwner = draggedItem.containsFocus ? draggedItem : null.processDragEnd, after the one-frame wait at:647(the visibility restore has flushed by then): ifdragFocusOwneris set and not destroyed,dragFocusOwner.focus(dragFocusOwner.id, false, true, 'pointer'); clear the field. A cancelled drag restores the same way.Coverage:
test/playwright/unit/draggable/container/SortZone.spec.mjs: one arm records the stub'sfocuscalls, runs a drag start and end for an item withcontainsFocus: trueand asserts exactly one call for that item withmodality: 'pointer', then the same for an item without focus and asserts none.TabHeaderActionsNLpasses at the head with the runtime root bound.Acceptance Criteria
test/playwright/e2e/tab/TabHeaderActionsNL.spec.mjs, unchanged, passes:269at the head (headless,--workers=1,NEO_AGENTOS_RUNTIME_ROOTbound; CI does not select it) and its first error moves to:288, the consistency read that #19282 owns; with #19282 applied the spec passes. Measured after filing: the restored focus sits on the dropped tab, which the overflow projection then withholds, so focus is lost a second time (#19285); that loss does not reach any assertion once #19282 lands.DomAccess.focuscall for that item, modalitypointer; a drag whose item did not own focus ends with none; a cancelled drag behaves like a committed one.:269on dev, both with the fix reverted.Out of Scope
visibility: hiddenis what removes the original from the accessibility tree today) or parking focus on a still-visible node at drag start; a separate call, once the terminal is right.verifyComponentConsistencyreporting aremoveDomvdom node as a DOM mismatch: #19282. The projection withholding a focused tab and dropping focus to the body: #19285.Decision Record impact
none
Related
#17844 (the census row) · #19282 (the checker) · #19285 (the overflow projection's focus) · #19276 / #19277 (the read that masked this arm) · #17423 (the arm's origin) · #17875 (focus gating) · #18016 (a withdrawn action takes no rail space) · #18069 (a withdrawn action has no node)
Origin Session ID: e2fd8a01-5bfc-4d82-ad5b-5c889f4b6710 Retrieval Hint: "tab drag focus body SortZone dragFocusOwner TabHeaderActionsNL More tabs drain"