Context
Part of the pane-header parity family under epic #13158 — and the direct continuation of the epic's own "tab overflow menu" gap line, whose first half shipped as Neo.tab.plugin.Overflow. Family ordering contract (shared, frozen): [tab overflow, only while overflowing] → [host actions] → [engine set: lock · reload · pin · pop-out · maximize — focus-gated] → [close — always visible, always last]. Anchors verified at Engine dev@34499f737b.
The Problem
The overflow control is an out-of-collection floating button rooted at document.body (src/tab/plugin/Overflow.mjs:16-24) — a deliberate, documented decision: independent mount/alignment/menu lifecycle, and no tab-button activeIndex wiring. That design is correct for a lone tab container. Inside a dock pane header it breaks down: the header's trailing corner is now an action cluster (close ships today; the #17928 family adds more), and a floating control aligned to that same corner collides with it — visually (two unrelated chromes in one corner) and geometrically (the float knows nothing about action-cluster width; the cluster knows nothing about the float).
Two facts have changed since the floating decision was recorded:
- The flat-action mechanism exists, and the plugin's own jsdoc already concedes the premise: "Ordinary flat toolbar actions can live in that collection because the tab toolbar and SortZone now expose one explicit tab-button subset."
- Dock headers — the composition this family targets — give the corner an owner: the action cluster.
The Architectural Reality
- Plugin (shipped): pure partition in
Overflow.computeOverflow (active-never-hidden packing, overflow-only controlWidth reservation); runtime half measures natural widths once and caches (removeDom-hidden buttons measure 0 — the cache discipline at Overflow.mjs:25-30); the control is created on first overflow and torn down when everything fits (Overflow.mjs:136-139); menu selection routes through the existing activeIndex path — zero operations, zero persisted state (ADR 0029 §2.4's binding line).
- Action substrate (shipped): stable action instances whose
hidden changes fire actionVisibilityChange (src/toolbar/Base.mjs:314) — and the Overflow plugin is itself a consumer of that signal (Overflow.mjs:356 onActionSetChange), which makes self-feedback a real trap this ticket names below.
- Gating (shipped):
showOnFocus resolution (toolbar/Base.mjs:384-396) — an action declaring showOnFocus: false is exempt from the tab header's focus-gating default.
- Dock opt-in seam (shipped): the adapter installs the plugin per projected header (
src/dashboard/dock/projection/LayoutAdapter.mjs:1029).
The Fix
An opt-in render mode on Neo.tab.plugin.Overflow (working name projectAsAction, frozen at claim time). When on, the plugin contributes its control as one stable action item, first in the action area instead of the floating button — through a NEW toolbar-owned contribution seam, never by manual insertion into items and never by writing the consumer's actions array: toolbar.Base#syncActions (toolbar/Base.mjs:469-489) removes every owned action item and rebuilds from the consumer actions alone, so a manually inserted item is destroyed by the next headerActions replacement (tab.Container#afterSetHeaderActions, Container.mjs:221-226), while prepending into owner.actions would make the plugin a second writer of consumer state. The seam: the rebuild merges [contributed items] + [rebuilt consumer actions], with contributed INSTANCES preserved across rebuilds — one owner per collection (consumer owns actions, the toolbar owns the merge, the contributor owns its contribution), and the stable-instance discipline holds through replacement by construction. The frozen surface: toolbar.Base#addActionContribution(config) adopts one contributed action item (stamped isToolbarActionContribution: true, inserted at the contribution slot ahead of every consumer action, bound like any action item) and returns the instance; toolbar.Base#removeActionContribution(instance) retires it; the toolbar's own destroy retires all contributions. syncActions sweeps only NON-contributed action items and re-inserts contributions first, so the contributed instance survives every consumer replacement; actionsChange keeps firing with the full merged action list, and contributors self-exclude via the marker — no reinjection listener, no ownership loop.
- Mounted only while overflowing (operator-confirmed contract): the action item's
hidden flips with the partition verdict — Neo hidden is removeDom, so nothing occupies the DOM when everything fits, exactly matching today's created-on-first-overflow lifecycle.
- Exempt from focus gating (
showOnFocus: false): when tabs are hidden behind it, reaching them must not require focusing the container first.
- First action, before host actions — it reads as the continuation of the tab strip, and
close keeps the corner-most slot.
- Selection keeps routing through
activeIndex; width reservation feeds computeOverflow from the action item's measured extent while visible (the floating mode's controlWidth: 40 constant stays the floating default).
- Self-exclusion invariant: the plugin must exclude its own action item from the geometry it partitions and from the
actionVisibilityChange events it consumes — its own hidden-flips must never re-enter its own overflow math.
- The dock adapter opts in at its plugin block; the floating default stays byte-identical for every generic tab consumer.
Contract Ledger (consumed cells)
| Cell |
Producer |
Behavior |
Fallback |
Docs |
Evidence |
| render mode |
NEW plugin config, default off |
Off ⇒ floating control exactly as today; on ⇒ one stable action item, first slot, no floating control |
Floating default |
Plugin jsdoc rewrites the out-of-collection paragraph to name both modes |
Plugin + action mechanism both shipping |
| contribution seam |
NEW on toolbar.Base: addActionContribution(config) / removeActionContribution(instance) + the isToolbarActionContribution marker; syncActions sweeps only non-contributed action items and re-inserts contributions first |
Contributed items render first and survive a consumer actions/headerActions replacement with instance identity intact; toolbar destroy retires contributions; actionsChange carries the merged list and contributors self-exclude via the marker (no reinjection loop) |
No contributors ⇒ today's rebuild, byte-identical |
Seam jsdoc names the one-owner-per-collection rule |
toolbar/Base.mjs:469-489 (the destruction this prevents) + tab/Container.mjs:221-226 (the replacement path that triggers it) |
| presence |
The plugin's own partition verdict driving the action's hidden |
Mounted (removeDom-visible) only while ≥1 tab overflows; teardown semantics preserved as hidden-toggle on a stable instance |
Hidden |
Same jsdoc |
computeOverflow overflow-only reservation shipping |
| gating exemption |
toolbar.Base showOnFocus resolution (toolbar/Base.mjs:384-396) |
showOnFocus: false ⇒ visible without container focus whenever mounted |
Header default would gate it — explicitly overridden |
Config jsdoc |
Resolution order shipping; close uses the same exemption shape |
| menu routing |
Existing activeIndex path (§2.4) |
Unchanged: selection activates the tab; nothing persists |
n/a |
ADR 0029 §2.4 |
Shipping |
| self-exclusion |
NEW guard inside the plugin |
Own action item excluded from partition input and from consumed actionVisibilityChange events |
Without it: feedback loop — named trap, spec-pinned |
Plugin jsdoc |
Overflow.mjs:356 proves the plugin consumes the signal it would now also emit |
| dock opt-in |
projection.LayoutAdapter plugin block (LayoutAdapter.mjs:1029) |
Dock projections pass the mode; generic consumers untouched |
n/a |
Inline comment |
Seam shipping |
Decision Record impact
amends ADR 0029 §2.4 — the section currently specifies "a floating control"; the amendment makes that the default and adds the opt-in action-slot projection for compositions whose headers carry an action cluster. The ADR edit rides the implementing PR (the #17838-style amendment trail).
Acceptance Criteria
Out of Scope
- Redesigning the floating mode or its alignment.
- Overflow badges/counters, menu content changes.
- The action semantics family (those leaves own their actions).
Related
Parent: #13158 (its original gap list named the overflow menu; this finishes that line for dock compositions). Siblings: #17928 and the pin / pop-out / reload / lock leaves filed alongside. Authority: ADR 0029 §2.4. Prior decision engaged: the out-of-collection paragraph at src/tab/plugin/Overflow.mjs:16-24.
Origin Session ID: 914dffcd-0057-46a5-a0c6-9ca807fd5c91
Retrieval Hint: "tab overflow control as first header action projectAsAction dock corner collision"
— Vega (Fable 5, Claude Code) 🌿
Context
Part of the pane-header parity family under epic #13158 — and the direct continuation of the epic's own "tab overflow menu" gap line, whose first half shipped as
Neo.tab.plugin.Overflow. Family ordering contract (shared, frozen): [tab overflow, only while overflowing] → [host actions] → [engine set: lock · reload · pin · pop-out · maximize — focus-gated] → [close — always visible, always last]. Anchors verified at Enginedev@34499f737b.The Problem
The overflow control is an out-of-collection floating button rooted at
document.body(src/tab/plugin/Overflow.mjs:16-24) — a deliberate, documented decision: independent mount/alignment/menu lifecycle, and no tab-buttonactiveIndexwiring. That design is correct for a lone tab container. Inside a dock pane header it breaks down: the header's trailing corner is now an action cluster (closeships today; the#17928family adds more), and a floating control aligned to that same corner collides with it — visually (two unrelated chromes in one corner) and geometrically (the float knows nothing about action-cluster width; the cluster knows nothing about the float).Two facts have changed since the floating decision was recorded:
The Architectural Reality
Overflow.computeOverflow(active-never-hidden packing, overflow-onlycontrolWidthreservation); runtime half measures natural widths once and caches (removeDom-hidden buttons measure 0 — the cache discipline atOverflow.mjs:25-30); the control is created on first overflow and torn down when everything fits (Overflow.mjs:136-139); menu selection routes through the existingactiveIndexpath — zero operations, zero persisted state (ADR 0029 §2.4's binding line).hiddenchanges fireactionVisibilityChange(src/toolbar/Base.mjs:314) — and the Overflow plugin is itself a consumer of that signal (Overflow.mjs:356 onActionSetChange), which makes self-feedback a real trap this ticket names below.showOnFocusresolution (toolbar/Base.mjs:384-396) — an action declaringshowOnFocus: falseis exempt from the tab header's focus-gating default.src/dashboard/dock/projection/LayoutAdapter.mjs:1029).The Fix
An opt-in render mode on
Neo.tab.plugin.Overflow(working nameprojectAsAction, frozen at claim time). When on, the plugin contributes its control as one stable action item, first in the action area instead of the floating button — through a NEW toolbar-owned contribution seam, never by manual insertion intoitemsand never by writing the consumer'sactionsarray:toolbar.Base#syncActions(toolbar/Base.mjs:469-489) removes every owned action item and rebuilds from the consumeractionsalone, so a manually inserted item is destroyed by the nextheaderActionsreplacement (tab.Container#afterSetHeaderActions, Container.mjs:221-226), while prepending intoowner.actionswould make the plugin a second writer of consumer state. The seam: the rebuild merges[contributed items] + [rebuilt consumer actions], with contributed INSTANCES preserved across rebuilds — one owner per collection (consumer ownsactions, the toolbar owns the merge, the contributor owns its contribution), and the stable-instance discipline holds through replacement by construction. The frozen surface:toolbar.Base#addActionContribution(config)adopts one contributed action item (stampedisToolbarActionContribution: true, inserted at the contribution slot ahead of every consumer action, bound like any action item) and returns the instance;toolbar.Base#removeActionContribution(instance)retires it; the toolbar's own destroy retires all contributions.syncActionssweeps only NON-contributed action items and re-inserts contributions first, so the contributed instance survives every consumer replacement;actionsChangekeeps firing with the full merged action list, and contributors self-exclude via the marker — no reinjection listener, no ownership loop.hiddenflips with the partition verdict — Neohiddenis removeDom, so nothing occupies the DOM when everything fits, exactly matching today's created-on-first-overflow lifecycle.showOnFocus: false): when tabs are hidden behind it, reaching them must not require focusing the container first.closekeeps the corner-most slot.activeIndex; width reservation feedscomputeOverflowfrom the action item's measured extent while visible (the floating mode'scontrolWidth: 40constant stays the floating default).actionVisibilityChangeevents it consumes — its own hidden-flips must never re-enter its own overflow math.Contract Ledger (consumed cells)
toolbar.Base:addActionContribution(config)/removeActionContribution(instance)+ theisToolbarActionContributionmarker;syncActionssweeps only non-contributed action items and re-inserts contributions firstactions/headerActionsreplacement with instance identity intact; toolbar destroy retires contributions;actionsChangecarries the merged list and contributors self-exclude via the marker (no reinjection loop)toolbar/Base.mjs:469-489(the destruction this prevents) +tab/Container.mjs:221-226(the replacement path that triggers it)hiddencomputeOverflowoverflow-only reservation shippingtoolbar.BaseshowOnFocusresolution (toolbar/Base.mjs:384-396)showOnFocus: false⇒ visible without container focus whenever mountedcloseuses the same exemption shapeactiveIndexpath (§2.4)actionVisibilityChangeeventsOverflow.mjs:356proves the plugin consumes the signal it would now also emitprojection.LayoutAdapterplugin block (LayoutAdapter.mjs:1029)Decision Record impact
amends ADR 0029 §2.4— the section currently specifies "a floating control"; the amendment makes that the default and adds the opt-in action-slot projection for compositions whose headers carry an action cluster. The ADR edit rides the implementing PR (the#17838-style amendment trail).Acceptance Criteria
closeremains last; no floating control exists.headerActionswhile overflowing preserves the contributed overflow action's INSTANCE and first position acrossafterSetHeaderActions→syncActions(the rebuild that destroys a manually inserted item).headerActionswhile everything fits (the contribution hidden/absent from the DOM) followed by a shrink that overflows again recovers exactly ONE working overflow action, first, menu routing intact — the arm that catches a contribution which only exists after first overflow or is lost while hidden.closein one header with the natural-width cache intact — partition results stable across the action item's own show/hide cycles (self-exclusion witnessed with a spec that would loop without the guard).Out of Scope
Related
Parent: #13158 (its original gap list named the overflow menu; this finishes that line for dock compositions). Siblings: #17928 and the pin / pop-out / reload / lock leaves filed alongside. Authority: ADR 0029 §2.4. Prior decision engaged: the out-of-collection paragraph at
src/tab/plugin/Overflow.mjs:16-24.Origin Session ID: 914dffcd-0057-46a5-a0c6-9ca807fd5c91
Retrieval Hint: "tab overflow control as first header action projectAsAction dock corner collision"
— Vega (Fable 5, Claude Code) 🌿