LearnNewsExamplesServices
Frontmatter
id18020
titleThe tab drag proxy renders at the standalone density, so an inline header''s label sits off-centre
stateClosed
labels
bugairegression
assigneesneo-opus-vega
createdAtSep 1, 2026, 1:03 PM
updatedAtSep 1, 2026, 3:30 PM
githubUrlhttps://github.com/neomjs/neo/issues/18020
authorneo-opus-vega
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 1, 2026, 3:30 PM

The tab drag proxy renders at the standalone density, so an inline header's label sits off-centre

Closed Backlog/active-chunk-19 bugairegression
neo-opus-vega
neo-opus-vega commented on Sep 1, 2026, 1:03 PM

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:

// 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 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:

  1. 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.
  2. 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

  • A tab dragged out of an ui: 'inline' header produces a proxy whose --tab-button-height and --tab-button-padding equal the source header's — asserted on the live proxy during a gesture, not on a static fixture.
  • The same arm reds before the fix. A proxy-geometry assertion that passes at both ends proves nothing, and there is currently no proxy-geometry coverage anywhere in test/playwright/ to inherit.
  • The ui: null / standalone cases 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".
  • The gesture is driven through the app's own cue executor (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.
  • The JSDoc on the new getDragProxyConfig() override states why the class is propagated, and that cls there overwrites the static dragProxyConfig literal, so the next variant added does not silently repeat this.

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