Context
Reported by @tobiu 2026-09-01 against the Workstation: dragging a tab shows a proxy whose label is no longer centred. He then supplied the lead that resolved it — "maybe related to ui: 'inline' => could be broken after we added it." He is right, and the mechanism is below.
Separated from inference: the observation is an off-centre label in the drag proxy. The mechanism below is confirmed in source but not yet measured on a live proxy — driving a real drag needs the cue executor, and no measurement of the running proxy's computed box has been taken. Treat the density numbers as read from the theme, not sampled from a gesture.
The Problem
ui: 'inline' is delivered as a set of CSS custom properties scoped to a direct-child selector that requires a .neo-tab-container-inline ancestor:
&.neo-tab-container-inline > .neo-tab-header-toolbar,
.neo-tab-container-inline > .neo-tab-header-toolbar {
--tab-button-border-radius : 0;
--tab-button-gap : 0;
--tab-button-height : var(--sem-height-large);
--tab-button-height-pressed: var(--sem-height-large);
--tab-button-padding : 4px var(--sem-spacing-small) 3px;
}The drag proxy is built as a detached toolbar rooted at document.body, and it inherits the owning toolbar's own class list:
getDragProxyConfig() {
return {...this.dragProxyConfig, cls: [...this.owner.cls]}
}Correction, 2026-09-01 — an earlier revision of this ticket said the proxy "clones two classes and nothing else" and pointed at the static dragProxyConfig literal in SortZone.mjs:23. That was wrong, and a peer implementing against it would have edited a dead field. cls comes after the spread, so the static literal's cls is overwritten by this.owner.cls; the proxy actually carries the source toolbar's full class list, plus the theme/language classes dashboard.dock.interaction.TabSortZone#getDragProxyConfig resolves by ancestor walk, plus neo-dragproxy.
The defect is unchanged, and the correction makes it sharper: neo-tab-container-inline lives on the tab.Container, never on the toolbar, so it is not in owner.cls and cannot be. And because the variant is expressed as a descendant rule — .neo-tab-container-inline > .neo-tab-header-toolbar — no class on the proxy alone can satisfy it: the proxy is body-rooted and has no such ancestor. The tokens never resolve and the button falls back to the theme's ui: null contract:
|
--tab-button-height |
--tab-button-padding |
source header (ui: 'inline') |
32px |
4px 12px 3px |
drag proxy (falls back to null) |
48px |
7px 16px 6px |
A 16px height difference and 4px more horizontal padding, applied to a button whose geometry came from a 32px header — the label is vertically off-centre and horizontally shifted for the whole gesture.
Why it reads as a regression. Before the ui variants existed there was exactly one header density, so the proxy's fallback happened to match its source. Introducing inline (#17950 / #17956) made the fallback wrong without touching the proxy; nothing in that change had a reason to look at SortZone.
The Architectural Reality
src/draggable/container/SortZone.mjs:446 — getDragProxyConfig() is the live seam: it returns the owning toolbar's cls, overwriting whatever cls the static dragProxyConfig declares. src/draggable/tab/header/toolbar/SortZone.mjs does not override it; dashboard.dock.interaction.TabSortZone does, and appends theme/language by walking ancestors — which is the precedent for propagating a variant the same way. Neither consults the owning container's ui, so no per-consumer variant reaches the proxy.
resources/scss/theme-{light,dark,neo-light,neo-dark}/tab/Container.scss — all four themes scope the variant tokens the same way: on the container, applied to its direct-child toolbar. The variant is a container-level contract, and the proxy is not a container's child.
src/component/Base.mjs:894 (afterSetUi) emits neo-<ntype>-<ui> on the container, which is exactly the ancestor the proxy lacks.
src/draggable/DragZone.mjs:96 — dragProxyConfig_ is the generic seam; any fix here should stay in the tab-specific SortZone unless the same gap is shown for other consumers.
Ruled out during triage, recorded so it is not re-walked: #17963 (fa20136f29) was the obvious suspect on timing, since it rewrote src/tab/header/Toolbar.scss — the sheet whose class the proxy clones. It is not the cause: that commit only added a nested &.neo-toolbar > .neo-toolbar-action.neo-button block, and the proxy carries a tab button, not an action.
The Fix
src/draggable/tab/header/toolbar/SortZone.mjs plus the four theme sheets, or a single self-scoped selector — one of:
- Propagate the owner's variant onto the proxy and let the variant selector match the toolbar itself: extend each theme's rule to
.neo-tab-header-toolbar.neo-tab-container-inline (self-scoped, alongside the existing descendant form), and have the SortZone add the owning container's ui-derived class to dragProxyConfig.cls at drag start. Keeps one token source.
- Copy the resolved token values onto the proxy at drag start. Rejected as a default because it snapshots values rather than participating in the cascade — a theme switch mid-drag would strand them — but it is the smaller change if 1 proves awkward.
Adding neo-tab-container-inline to the proxy's cls alone does nothing: the existing selector requires the class on the parent, and the proxy is the toolbar. That is the trap this ticket exists to document.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Behavior |
Fallback |
Docs |
Evidence |
draggable.tab.header.toolbar.SortZone#getDragProxyConfig() (new override) |
this ticket |
the proxy carries the owning container's ui-derived class so variant tokens resolve on it |
none — a proxy at the wrong density is the defect |
method JSDoc names why the class is propagated rather than hardcoded, and that cls here overwrites the static literal |
computed --tab-button-height / --tab-button-padding on the live proxy equal the source header's |
the four tab/Container.scss variant blocks |
#17950 / #17956 |
gain a self-scoped form so a detached toolbar can match |
descendant form unchanged for the in-container case |
in-sheet comment naming the detached-proxy case |
a proxy measured at 32px under inline, 48px under null |
Decision Record impact
none.
Acceptance Criteria
Out of Scope
- The other drag-proxy consumers.
DragZone#dragProxyConfig is generic; whether container/grid/dashboard proxies have the same variant gap is a separate census, not assumed here.
#17889's Pointer Events migration. Adjacent surface, unrelated mechanism.
- The label-overlap defect witnessed by
WorkstationTabDragLabelOverlapNL — two different heading strings interleaving on one baseline is a different failure, and that spec is red for its own reasons.
- Any change to what
ui: 'inline' means. This ticket makes the proxy honour the existing contract; it does not renegotiate it.
Avoided Traps
- Adding the variant class to the proxy and stopping there. The selector needs it on the parent; the proxy is the toolbar. This looks like a one-line fix and is a no-op.
- Blaming
#17963 on timing. It rewrote the very sheet the proxy clones and landed the day the symptom appeared, which is exactly the shape of a false positive. Its added rule is action-scoped and the proxy carries a tab button.
- Asserting proxy geometry from a static fixture. The proxy only exists mid-gesture; a spec that builds a lookalike measures its own construction.
Handoff Retrieval Hints
query_raw_memories("drag proxy inline density tab header toolbar variant tokens") · anchors src/draggable/container/SortZone.mjs:446, src/draggable/tab/header/toolbar/SortZone.mjs, resources/scss/theme-neo-dark/tab/Container.scss:18, src/component/Base.mjs:894.
Related: #17836 (DockLayouts goal), #17950 / #17956 (the variants whose introduction exposed this), #18015 (the sibling rail-geometry defect).
Authored by Vega (Opus 5, Claude Code) · session ec8ab0bd-05dd-4133-be3e-3588ce0c9f86
Context
Reported by @tobiu 2026-09-01 against the Workstation: dragging a tab shows a proxy whose label is no longer centred. He then supplied the lead that resolved it — "maybe related to
ui: 'inline'=> could be broken after we added it." He is right, and the mechanism is below.Separated from inference: the observation is an off-centre label in the drag proxy. The mechanism below is confirmed in source but not yet measured on a live proxy — driving a real drag needs the cue executor, and no measurement of the running proxy's computed box has been taken. Treat the density numbers as read from the theme, not sampled from a gesture.
The Problem
ui: 'inline'is delivered as a set of CSS custom properties scoped to a direct-child selector that requires a.neo-tab-container-inlineancestor:// resources/scss/theme-neo-dark/tab/Container.scss:18 &.neo-tab-container-inline > .neo-tab-header-toolbar, .neo-tab-container-inline > .neo-tab-header-toolbar { --tab-button-border-radius : 0; --tab-button-gap : 0; --tab-button-height : var(--sem-height-large); --tab-button-height-pressed: var(--sem-height-large); --tab-button-padding : 4px var(--sem-spacing-small) 3px; }The drag proxy is built as a detached toolbar rooted at
document.body, and it inherits the owning toolbar's own class list:// src/draggable/container/SortZone.mjs:446 — the seam every tab drag actually goes through getDragProxyConfig() { return {...this.dragProxyConfig, cls: [...this.owner.cls]} }Correction, 2026-09-01 — an earlier revision of this ticket said the proxy "clones two classes and nothing else" and pointed at the static
dragProxyConfigliteral inSortZone.mjs:23. That was wrong, and a peer implementing against it would have edited a dead field.clscomes after the spread, so the static literal'sclsis overwritten bythis.owner.cls; the proxy actually carries the source toolbar's full class list, plus the theme/language classesdashboard.dock.interaction.TabSortZone#getDragProxyConfigresolves by ancestor walk, plusneo-dragproxy.The defect is unchanged, and the correction makes it sharper:
neo-tab-container-inlinelives on the tab.Container, never on the toolbar, so it is not inowner.clsand cannot be. And because the variant is expressed as a descendant rule —.neo-tab-container-inline > .neo-tab-header-toolbar— no class on the proxy alone can satisfy it: the proxy is body-rooted and has no such ancestor. The tokens never resolve and the button falls back to the theme'sui: nullcontract:--tab-button-height--tab-button-paddingui: 'inline')4px 12px 3pxnull)7px 16px 6pxA 16px height difference and 4px more horizontal padding, applied to a button whose geometry came from a 32px header — the label is vertically off-centre and horizontally shifted for the whole gesture.
Why it reads as a regression. Before the
uivariants existed there was exactly one header density, so the proxy's fallback happened to match its source. Introducinginline(#17950/#17956) made the fallback wrong without touching the proxy; nothing in that change had a reason to look atSortZone.The Architectural Reality
src/draggable/container/SortZone.mjs:446—getDragProxyConfig()is the live seam: it returns the owning toolbar'scls, overwriting whateverclsthe staticdragProxyConfigdeclares.src/draggable/tab/header/toolbar/SortZone.mjsdoes not override it;dashboard.dock.interaction.TabSortZonedoes, and appends theme/language by walking ancestors — which is the precedent for propagating a variant the same way. Neither consults the owning container'sui, so no per-consumer variant reaches the proxy.resources/scss/theme-{light,dark,neo-light,neo-dark}/tab/Container.scss— all four themes scope the variant tokens the same way: on the container, applied to its direct-child toolbar. The variant is a container-level contract, and the proxy is not a container's child.src/component/Base.mjs:894(afterSetUi) emitsneo-<ntype>-<ui>on the container, which is exactly the ancestor the proxy lacks.src/draggable/DragZone.mjs:96—dragProxyConfig_is the generic seam; any fix here should stay in the tab-specific SortZone unless the same gap is shown for other consumers.Ruled out during triage, recorded so it is not re-walked:
#17963(fa20136f29) was the obvious suspect on timing, since it rewrotesrc/tab/header/Toolbar.scss— the sheet whose class the proxy clones. It is not the cause: that commit only added a nested&.neo-toolbar > .neo-toolbar-action.neo-buttonblock, and the proxy carries a tab button, not an action.The Fix
src/draggable/tab/header/toolbar/SortZone.mjsplus the four theme sheets, or a single self-scoped selector — one of:.neo-tab-header-toolbar.neo-tab-container-inline(self-scoped, alongside the existing descendant form), and have the SortZone add the owning container'sui-derived class todragProxyConfig.clsat drag start. Keeps one token source.Adding
neo-tab-container-inlineto the proxy'sclsalone does nothing: the existing selector requires the class on the parent, and the proxy is the toolbar. That is the trap this ticket exists to document.Contract Ledger Matrix
draggable.tab.header.toolbar.SortZone#getDragProxyConfig()(new override)ui-derived class so variant tokens resolve on itclshere overwrites the static literal--tab-button-height/--tab-button-paddingon the live proxy equal the source header'stab/Container.scssvariant blocks#17950/#17956inline, 48px undernullDecision Record impact
none.Acceptance Criteria
ui: 'inline'header produces a proxy whose--tab-button-heightand--tab-button-paddingequal the source header's — asserted on the live proxy during a gesture, not on a static fixture.test/playwright/to inherit.ui: null/standalonecases are unchanged — the proxy still matches its source header, so the fix is "the proxy inherits its owner's variant", not "the proxy is always inline".Workspace#executeCue) or an equivalently trusted pointer path. The browser tool's synthetic click cannot arm a drag sensor, so a proxy that never exists would read as a green assertion.getDragProxyConfig()override states why the class is propagated, and thatclsthere overwrites the staticdragProxyConfigliteral, so the next variant added does not silently repeat this.Out of Scope
DragZone#dragProxyConfigis generic; whether container/grid/dashboard proxies have the same variant gap is a separate census, not assumed here.#17889's Pointer Events migration. Adjacent surface, unrelated mechanism.WorkstationTabDragLabelOverlapNL— two different heading strings interleaving on one baseline is a different failure, and that spec is red for its own reasons.ui: 'inline'means. This ticket makes the proxy honour the existing contract; it does not renegotiate it.Avoided Traps
#17963on timing. It rewrote the very sheet the proxy clones and landed the day the symptom appeared, which is exactly the shape of a false positive. Its added rule is action-scoped and the proxy carries a tab button.Handoff Retrieval Hints
query_raw_memories("drag proxy inline density tab header toolbar variant tokens")· anchorssrc/draggable/container/SortZone.mjs:446,src/draggable/tab/header/toolbar/SortZone.mjs,resources/scss/theme-neo-dark/tab/Container.scss:18,src/component/Base.mjs:894.Related:
#17836(DockLayouts goal),#17950/#17956(the variants whose introduction exposed this),#18015(the sibling rail-geometry defect).Authored by Vega (Opus 5, Claude Code) · session
ec8ab0bd-05dd-4133-be3e-3588ce0c9f86